@decocms/blocks 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability utilities for deco middleware.
|
|
3
|
+
*
|
|
4
|
+
* Pluggable adapters for tracing (spans) and metrics (counters, gauges,
|
|
5
|
+
* histograms). Works with any backend: OpenTelemetry, Sentry, Datadog, etc.
|
|
6
|
+
*
|
|
7
|
+
* Per-request active spans are propagated via an injectable `RequestStore`
|
|
8
|
+
* — by default an AsyncLocalStorage-backed implementation. Tests can swap
|
|
9
|
+
* it via `setObservabilitySpanStore()` to assert span lifecycle without
|
|
10
|
+
* relying on `node:async_hooks` semantics in the test runner.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { configureTracer, configureMeter } from "@decocms/start/middleware";
|
|
15
|
+
* import { trace, metrics } from "@opentelemetry/api";
|
|
16
|
+
*
|
|
17
|
+
* configureTracer({
|
|
18
|
+
* startSpan: (name, attrs) => {
|
|
19
|
+
* const span = trace.getTracer("deco").startSpan(name, { attributes: attrs });
|
|
20
|
+
* return {
|
|
21
|
+
* end: () => span.end(),
|
|
22
|
+
* setError: (e) => span.recordException(e),
|
|
23
|
+
* setAttribute: (k, v) => span.setAttribute(k, v),
|
|
24
|
+
* };
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* configureMeter({
|
|
29
|
+
* counterInc: (name, value, labels) => metrics.getMeter("deco").createCounter(name).add(value, labels),
|
|
30
|
+
* histogramRecord: (name, value, labels) => metrics.getMeter("deco").createHistogram(name).record(value, labels),
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import * as asyncHooks from "node:async_hooks";
|
|
36
|
+
import {
|
|
37
|
+
METRIC_HTTP_CLIENT_REQUEST_DURATION,
|
|
38
|
+
METRIC_HTTP_SERVER_REQUEST_DURATION,
|
|
39
|
+
} from "@opentelemetry/semantic-conventions";
|
|
40
|
+
import { logger } from "../sdk/logger";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// RequestStore — minimal per-request context abstraction. Inlined here so
|
|
44
|
+
// the observability module has zero cross-package dependencies and tests
|
|
45
|
+
// can inject a custom implementation via `setObservabilitySpanStore`.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface RequestStore<T> {
|
|
49
|
+
get(): T | undefined;
|
|
50
|
+
run<R>(value: T, fn: () => R): R;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class NoopRequestStore implements RequestStore<unknown> {
|
|
54
|
+
get(): undefined {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
run<R>(_value: unknown, fn: () => R): R {
|
|
58
|
+
return fn();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const noopRequestStore: RequestStore<unknown> = new NoopRequestStore();
|
|
63
|
+
|
|
64
|
+
class AlsRequestStore<T> implements RequestStore<T> {
|
|
65
|
+
private readonly als:
|
|
66
|
+
| { getStore(): T | undefined; run<R>(store: T, fn: () => R): R }
|
|
67
|
+
| null;
|
|
68
|
+
constructor() {
|
|
69
|
+
const ALS = (asyncHooks as { AsyncLocalStorage?: new <U>() => {
|
|
70
|
+
getStore(): U | undefined;
|
|
71
|
+
run<R>(store: U, fn: () => R): R;
|
|
72
|
+
} }).AsyncLocalStorage;
|
|
73
|
+
this.als = ALS ? new ALS<T>() : null;
|
|
74
|
+
}
|
|
75
|
+
get(): T | undefined {
|
|
76
|
+
return this.als?.getStore();
|
|
77
|
+
}
|
|
78
|
+
run<R>(value: T, fn: () => R): R {
|
|
79
|
+
return this.als ? this.als.run(value, fn) : fn();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Tracer
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export interface Span {
|
|
88
|
+
end(): void;
|
|
89
|
+
setError?(error: unknown): void;
|
|
90
|
+
setAttribute?(key: string, value: string | number | boolean): void;
|
|
91
|
+
/**
|
|
92
|
+
* Return W3C trace context for the current span. Used by helpers that
|
|
93
|
+
* need to correlate logs to traces (`logger`) or propagate context to
|
|
94
|
+
* downstream services (`injectTraceContext`). Optional — adapters that
|
|
95
|
+
* can't expose it simply leave it off and callers no-op gracefully.
|
|
96
|
+
*/
|
|
97
|
+
spanContext?(): { traceId: string; spanId: string; traceFlags: number };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface TracerAdapter {
|
|
101
|
+
startSpan(name: string, attributes?: Record<string, string | number | boolean>): Span;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Shared module state — pinned to globalThis via Symbol.for so multiple
|
|
106
|
+
// inlined copies of this module (one per bundled entry file if/when
|
|
107
|
+
// bundling is ever reintroduced) converge on the SAME state. Without this
|
|
108
|
+
// indirection, `configureMeter()` from one entry's copy writes to a meter
|
|
109
|
+
// that `getMeter()` in another entry's copy never sees, and direct-POST
|
|
110
|
+
// telemetry silently no-ops.
|
|
111
|
+
//
|
|
112
|
+
// Pattern borrowed from @opentelemetry/api / Sentry — both solve the same
|
|
113
|
+
// "library with multiple entry exports re-bundles internal state modules"
|
|
114
|
+
// problem. Cloudflare Workers guarantee one `globalThis` per isolate, so
|
|
115
|
+
// there's no risk of cross-isolate bleed. Defensive against future
|
|
116
|
+
// bundling changes; harmless when consumers import from src/ as today.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
interface ObservabilityState {
|
|
120
|
+
tracer: TracerAdapter | null;
|
|
121
|
+
meter: MeterAdapter | null;
|
|
122
|
+
spanStore: RequestStore<Span | null>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const STATE_KEY = Symbol.for("@decocms/start/observability/state.v1");
|
|
126
|
+
|
|
127
|
+
function getState(): ObservabilityState {
|
|
128
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
129
|
+
if (!g[STATE_KEY]) {
|
|
130
|
+
g[STATE_KEY] = {
|
|
131
|
+
tracer: null,
|
|
132
|
+
meter: null,
|
|
133
|
+
spanStore: new AlsRequestStore<Span | null>(),
|
|
134
|
+
} satisfies ObservabilityState;
|
|
135
|
+
}
|
|
136
|
+
return g[STATE_KEY] as ObservabilityState;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Swap the RequestStore used for active-span propagation.
|
|
141
|
+
*
|
|
142
|
+
* Pass `undefined` to reset to the default AsyncLocalStorage-backed store.
|
|
143
|
+
* Primarily intended for tests that need deterministic span access without
|
|
144
|
+
* setting up an actual ALS context.
|
|
145
|
+
*/
|
|
146
|
+
export function setObservabilitySpanStore(s: RequestStore<Span | null> | undefined): void {
|
|
147
|
+
getState().spanStore = s ?? new AlsRequestStore<Span | null>();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function configureTracer(t: TracerAdapter) {
|
|
151
|
+
getState().tracer = t;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getTracer(): TracerAdapter | null {
|
|
155
|
+
return getState().tracer;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Get the currently active span for the current async context, if any. */
|
|
159
|
+
export function getActiveSpan(): Span | null {
|
|
160
|
+
return getState().spanStore.get() ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Set an attribute on the active span, if one exists. */
|
|
164
|
+
export function setSpanAttribute(key: string, value: string | number | boolean) {
|
|
165
|
+
getActiveSpan()?.setAttribute?.(key, value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Inject the active span's W3C trace context into outbound request headers
|
|
170
|
+
* as a `traceparent` header (RFC W3C-tracecontext format
|
|
171
|
+
* `version-traceId-parentId-flags`). Call this from outbound `fetch`
|
|
172
|
+
* wrappers (e.g. `createInstrumentedFetch` in `@decocms/apps`) so upstream
|
|
173
|
+
* services that participate in OTel can correlate their spans with ours.
|
|
174
|
+
*
|
|
175
|
+
* No-op when no active span exists, when the active span has no
|
|
176
|
+
* `spanContext()` adapter method, or when the trace/span IDs aren't
|
|
177
|
+
* populated. Never throws.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* import { injectTraceContext } from "@decocms/start/sdk/observability";
|
|
182
|
+
*
|
|
183
|
+
* async function tracedFetch(url: string, init?: RequestInit) {
|
|
184
|
+
* const headers = new Headers(init?.headers);
|
|
185
|
+
* injectTraceContext(headers);
|
|
186
|
+
* return fetch(url, { ...init, headers });
|
|
187
|
+
* }
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export function injectTraceContext(headers: Headers): void {
|
|
191
|
+
const ctx = getActiveSpan()?.spanContext?.();
|
|
192
|
+
if (!ctx || !ctx.traceId || !ctx.spanId) return;
|
|
193
|
+
const flags = (ctx.traceFlags & 0xff).toString(16).padStart(2, "0");
|
|
194
|
+
headers.set("traceparent", `00-${ctx.traceId}-${ctx.spanId}-${flags}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function withTracing<T>(
|
|
198
|
+
name: string,
|
|
199
|
+
fn: () => Promise<T>,
|
|
200
|
+
attributes?: Record<string, string | number | boolean>,
|
|
201
|
+
): Promise<T> {
|
|
202
|
+
const s = getState();
|
|
203
|
+
if (!s.tracer) return fn();
|
|
204
|
+
|
|
205
|
+
const span = s.tracer.startSpan(name, attributes);
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const result = await s.spanStore.run(span, fn);
|
|
209
|
+
span.end();
|
|
210
|
+
return result;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
span.setError?.(error);
|
|
213
|
+
span.end();
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Meter
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
type Labels = Record<string, string | number | boolean>;
|
|
223
|
+
|
|
224
|
+
export interface MeterAdapter {
|
|
225
|
+
counterInc(name: string, value?: number, labels?: Labels): void;
|
|
226
|
+
gaugeSet?(name: string, value: number, labels?: Labels): void;
|
|
227
|
+
histogramRecord?(name: string, value: number, labels?: Labels): void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function configureMeter(m: MeterAdapter) {
|
|
231
|
+
getState().meter = m;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getMeter(): MeterAdapter | null {
|
|
235
|
+
return getState().meter;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Pre-defined metric names. Where the OTel community SemConv defines a
|
|
240
|
+
* canonical metric we use the constant imported from
|
|
241
|
+
* `@opentelemetry/semantic-conventions` (e.g.
|
|
242
|
+
* `METRIC_HTTP_SERVER_REQUEST_DURATION`) — never write the string ourselves.
|
|
243
|
+
* Browse: https://opentelemetry.io/docs/specs/semconv/http/http-metrics/
|
|
244
|
+
*
|
|
245
|
+
* Concepts the OTel community does NOT define (cache hits, CMS resolve,
|
|
246
|
+
* per-loader latency) use the Deco extension namespace `deco.*` in dotted
|
|
247
|
+
* notation, matching the OTel attribute-naming convention.
|
|
248
|
+
*
|
|
249
|
+
* Unit / value handling: emitters declare the unit honestly via the
|
|
250
|
+
* METRIC_METADATA map below; the framework never converts numeric values.
|
|
251
|
+
* Consumers (otel-ingest collector, future Go Collector swap, query
|
|
252
|
+
* layers) apply UCUM conversions if their target backend requires a
|
|
253
|
+
* specific unit (e.g. canonical OTel `s` for HTTP durations).
|
|
254
|
+
*/
|
|
255
|
+
export const MetricNames = {
|
|
256
|
+
// OTel SemConv (stable).
|
|
257
|
+
HTTP_SERVER_REQUEST_DURATION: METRIC_HTTP_SERVER_REQUEST_DURATION,
|
|
258
|
+
HTTP_CLIENT_REQUEST_DURATION: METRIC_HTTP_CLIENT_REQUEST_DURATION,
|
|
259
|
+
// Deco extensions — no canonical OTel metric exists for these concepts.
|
|
260
|
+
CACHE_HIT: "deco.cache.hits",
|
|
261
|
+
CACHE_MISS: "deco.cache.misses",
|
|
262
|
+
RESOLVE_DURATION: "deco.cms.resolve.duration",
|
|
263
|
+
LOADER_DURATION: "deco.loader.duration",
|
|
264
|
+
LOADER_ERRORS: "deco.loader.errors",
|
|
265
|
+
} as const;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Per-metric metadata emitted in the OTLP payload's `description` and
|
|
269
|
+
* `unit` fields. The framework declares units honestly — durations are in
|
|
270
|
+
* `ms` because that's what `recordRequestMetric`-style callers pass. If a
|
|
271
|
+
* downstream consumer needs canonical OTel seconds for
|
|
272
|
+
* `http.server.request.duration`, conversion happens in the collector or
|
|
273
|
+
* the query layer, NOT in this framework.
|
|
274
|
+
*
|
|
275
|
+
* Keep keys aligned with `MetricNames` values so a missing entry is a
|
|
276
|
+
* type error at compile time when a new metric ships without metadata.
|
|
277
|
+
*/
|
|
278
|
+
export const METRIC_METADATA: Record<string, { description: string; unit: string }> = {
|
|
279
|
+
[MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
|
|
280
|
+
description: "Duration of HTTP server requests handled at the Worker entry point.",
|
|
281
|
+
unit: "ms",
|
|
282
|
+
},
|
|
283
|
+
[MetricNames.HTTP_CLIENT_REQUEST_DURATION]: {
|
|
284
|
+
description: "Duration of outbound HTTP client requests (commerce, generic fetch).",
|
|
285
|
+
unit: "ms",
|
|
286
|
+
},
|
|
287
|
+
[MetricNames.CACHE_HIT]: {
|
|
288
|
+
description: "Cache lookups resulting in HIT, STALE-HIT, or STALE-ERROR.",
|
|
289
|
+
unit: "{request}",
|
|
290
|
+
},
|
|
291
|
+
[MetricNames.CACHE_MISS]: {
|
|
292
|
+
description: "Cache lookups resulting in MISS or BYPASS.",
|
|
293
|
+
unit: "{request}",
|
|
294
|
+
},
|
|
295
|
+
[MetricNames.RESOLVE_DURATION]: {
|
|
296
|
+
description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
|
|
297
|
+
unit: "ms",
|
|
298
|
+
},
|
|
299
|
+
[MetricNames.LOADER_DURATION]: {
|
|
300
|
+
description: "Per-loader execution duration, emitted by cachedLoader.",
|
|
301
|
+
unit: "ms",
|
|
302
|
+
},
|
|
303
|
+
[MetricNames.LOADER_ERRORS]: {
|
|
304
|
+
description: "Per-loader error count.",
|
|
305
|
+
unit: "{error}",
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Map an HTTP status code to its canonical class label (`2xx` / ... /
|
|
311
|
+
* `5xx`). Out-of-range numbers (e.g. -1 from a thrown fetch) fall back
|
|
312
|
+
* to `"unknown"` so dashboards don't break on edge cases.
|
|
313
|
+
*
|
|
314
|
+
* Exported because callers occasionally need the same mapping for
|
|
315
|
+
* non-metric purposes (logging, tail enrichment).
|
|
316
|
+
*/
|
|
317
|
+
export function statusClassFor(status: number): string {
|
|
318
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return "unknown";
|
|
319
|
+
if (status < 100 || status >= 600) return "unknown";
|
|
320
|
+
return `${Math.floor(status / 100)}xx`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Optional dimensions stamped on `http_requests_total` /
|
|
325
|
+
* `http_request_duration_ms` / `http_request_errors_total`. All fields
|
|
326
|
+
* are optional — callers pass what they have, the framework fills in
|
|
327
|
+
* the rest from defaults.
|
|
328
|
+
*
|
|
329
|
+
* Cardinality discipline: every field here is bounded. `route_pattern`
|
|
330
|
+
* comes from the TanStack router (a closed set), `outcome` is the CF
|
|
331
|
+
* Workers Observability enum, `cache_decision` / `cache_layer` are
|
|
332
|
+
* union types declared in this module, `region` is a small set of CF
|
|
333
|
+
* colo codes. Status is unbounded by spec but bounded in practice; the
|
|
334
|
+
* `status_class` label bounds the cardinality further for dashboards
|
|
335
|
+
* that don't need the raw value.
|
|
336
|
+
*/
|
|
337
|
+
/**
|
|
338
|
+
* Per-request identifiers that MUST NOT be stamped on metric labels.
|
|
339
|
+
* They belong on spans (where they are 1:1 with the entity being
|
|
340
|
+
* observed) and on logs (where they enable cross-channel correlation).
|
|
341
|
+
* Putting them on metric labels collapses aggregation — every request
|
|
342
|
+
* becomes its own histogram data point.
|
|
343
|
+
*/
|
|
344
|
+
const HIGH_CARDINALITY_BLOCKLIST = new Set<string>([
|
|
345
|
+
"request.id",
|
|
346
|
+
"trace.id",
|
|
347
|
+
"span.id",
|
|
348
|
+
"session.id",
|
|
349
|
+
"user.id",
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
export interface RequestMetricLabels {
|
|
353
|
+
/** TanStack route pattern (`/_products/$slug/p`) — closed set. */
|
|
354
|
+
route_pattern?: string;
|
|
355
|
+
/** Cloudflare Workers Observability `outcome` (`ok`, `exception`, ...). */
|
|
356
|
+
outcome?: string;
|
|
357
|
+
/** Cache layer + decision when known. */
|
|
358
|
+
cache_decision?: CacheDecision;
|
|
359
|
+
cache_layer?: CacheLayer;
|
|
360
|
+
/** Cloudflare colo (`GRU`, `IAD`, ...). */
|
|
361
|
+
region?: string;
|
|
362
|
+
/**
|
|
363
|
+
* Arbitrary extra labels — callers should avoid this and add fields
|
|
364
|
+
* to the typed surface above instead. Kept as an escape hatch so
|
|
365
|
+
* non-canonical experiments don't require a framework release.
|
|
366
|
+
*/
|
|
367
|
+
extra?: Record<string, string | number | boolean>;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Record an HTTP request metric.
|
|
372
|
+
*
|
|
373
|
+
* Call in middleware after the response is produced. Two-call surface
|
|
374
|
+
* for backward compat:
|
|
375
|
+
*
|
|
376
|
+
* recordRequestMetric(method, path, status, durationMs)
|
|
377
|
+
* recordRequestMetric(method, path, status, durationMs, labels)
|
|
378
|
+
*
|
|
379
|
+
* The labels argument is optional — sites that haven't bumped to the
|
|
380
|
+
* Phase 2 metric shape still emit the original three labels
|
|
381
|
+
* (`method`, `route_pattern`, `status`). Adding labels never changes
|
|
382
|
+
* existing labels' values; only adds new ones.
|
|
383
|
+
*/
|
|
384
|
+
export function recordRequestMetric(
|
|
385
|
+
method: string,
|
|
386
|
+
path: string,
|
|
387
|
+
status: number,
|
|
388
|
+
durationMs: number,
|
|
389
|
+
labels?: RequestMetricLabels,
|
|
390
|
+
) {
|
|
391
|
+
const m = getState().meter;
|
|
392
|
+
if (!m) return;
|
|
393
|
+
// Cardinality discipline:
|
|
394
|
+
// - `method`: small (GET, POST, ...).
|
|
395
|
+
// - `route_pattern`: closed set (caller-supplied) OR normalized path
|
|
396
|
+
// (fallback). Either way bounded.
|
|
397
|
+
// - `status`: full HTTP code (bounded ~50 values in practice).
|
|
398
|
+
// - `status_class`: 5-element enum (2xx / 3xx / 4xx / 5xx / unknown).
|
|
399
|
+
// - `outcome`: CF outcome enum (~7 values).
|
|
400
|
+
// - `cache_decision`: 5-element enum.
|
|
401
|
+
// - `cache_layer`: 3-element enum (edge / cachedLoader / vtex-swr).
|
|
402
|
+
// - `region`: ~250 CF colo codes worldwide.
|
|
403
|
+
// Total combinations are bounded — safe for unbounded series on
|
|
404
|
+
// ClickHouse but operators should still avoid grouping by `region`
|
|
405
|
+
// unless explicitly needed.
|
|
406
|
+
const merged: Labels = {
|
|
407
|
+
method,
|
|
408
|
+
route_pattern: labels?.route_pattern ?? normalizePath(path),
|
|
409
|
+
status,
|
|
410
|
+
status_class: statusClassFor(status),
|
|
411
|
+
};
|
|
412
|
+
if (labels?.outcome) merged.outcome = labels.outcome;
|
|
413
|
+
if (labels?.cache_decision) merged.cache_decision = labels.cache_decision;
|
|
414
|
+
if (labels?.cache_layer) merged.cache_layer = labels.cache_layer;
|
|
415
|
+
if (labels?.region) merged.region = labels.region;
|
|
416
|
+
if (labels?.extra) {
|
|
417
|
+
for (const [k, v] of Object.entries(labels.extra)) {
|
|
418
|
+
// Defense-in-depth — refuse to stamp known per-request identifiers
|
|
419
|
+
// on metric labels. These belong on spans and logs only; putting
|
|
420
|
+
// them here makes every request its own histogram data point and
|
|
421
|
+
// destroys aggregation. Caller-supplied `extra` should remain a
|
|
422
|
+
// low-cardinality escape hatch (A/B variant, feature flag, ...).
|
|
423
|
+
if (HIGH_CARDINALITY_BLOCKLIST.has(k)) continue;
|
|
424
|
+
merged[k] = v;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// OTel canonical HTTP metrics define a single histogram per direction
|
|
428
|
+
// (`http.server.request.duration` for incoming). The request count is
|
|
429
|
+
// derived from the histogram's `count` field; error rate is derived by
|
|
430
|
+
// filtering on `http.response.status_code`. Separate `_total` /
|
|
431
|
+
// `_errors_total` counters were removed because they duplicate
|
|
432
|
+
// histogram-derived signals and aren't part of the canonical spec.
|
|
433
|
+
m.histogramRecord?.(MetricNames.HTTP_SERVER_REQUEST_DURATION, durationMs, merged);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Cache decision label. Mirrors the `X-Cache` response header we set in
|
|
438
|
+
* `workerEntry.ts` so dashboards can join on it.
|
|
439
|
+
* - `HIT` — fresh entry returned from cache
|
|
440
|
+
* - `STALE-HIT` — stale entry served, async revalidation kicked off (SWR)
|
|
441
|
+
* - `STALE-ERROR` — stale entry served because origin errored (SIE)
|
|
442
|
+
* - `MISS` — cache lookup returned nothing, origin fetched
|
|
443
|
+
* - `BYPASS` — request not eligible for caching (private, cookies, etc.)
|
|
444
|
+
*/
|
|
445
|
+
export type CacheDecision = "HIT" | "STALE-HIT" | "STALE-ERROR" | "MISS" | "BYPASS";
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Where the cache lives. Phase 2 label expansion (D-11).
|
|
449
|
+
* - `edge` — Cloudflare Cache API (HTML pages, server-fn responses)
|
|
450
|
+
* - `cachedLoader` — In-memory per-isolate via `sdk/cachedLoader.ts`
|
|
451
|
+
* (loader-level SWR, dedup, in-flight)
|
|
452
|
+
* - `vtex-swr` — Apps-side in-memory cache shared by VTEX clients
|
|
453
|
+
* (intelligent-search, cross-selling, etc.)
|
|
454
|
+
*/
|
|
455
|
+
export type CacheLayer = "edge" | "cachedLoader" | "vtex-swr";
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Record a cache hit/miss metric. Also stamps the decision on the active
|
|
459
|
+
* trace span (when one exists) as `deco.cache.decision` / `deco.cache.profile`
|
|
460
|
+
* so operators can filter ClickStack traces by cache decision directly,
|
|
461
|
+
* without joining to metrics.
|
|
462
|
+
*
|
|
463
|
+
* Backward-compatible signature:
|
|
464
|
+
* recordCacheMetric(hit, profile?, decision?)
|
|
465
|
+
* recordCacheMetric(hit, profile?, decision?, layer?)
|
|
466
|
+
*
|
|
467
|
+
* `decision` is optional — when omitted, the metric still records HIT
|
|
468
|
+
* vs MISS but dashboards can't distinguish SWR/SIE paths. Pass it
|
|
469
|
+
* whenever known. `layer` defaults to `edge` when called from
|
|
470
|
+
* workerEntry; cachedLoader / vtex-swr call sites should pass their
|
|
471
|
+
* value explicitly.
|
|
472
|
+
*/
|
|
473
|
+
export function recordCacheMetric(
|
|
474
|
+
hit: boolean,
|
|
475
|
+
profile?: string,
|
|
476
|
+
decision?: CacheDecision,
|
|
477
|
+
layer?: CacheLayer,
|
|
478
|
+
) {
|
|
479
|
+
// Stamp on the active span FIRST so the attribute survives even if the
|
|
480
|
+
// meter is a no-op (e.g. on tests, or in dev without DECO_METRICS).
|
|
481
|
+
const active = getActiveSpan();
|
|
482
|
+
if (active) {
|
|
483
|
+
if (decision) active.setAttribute?.("deco.cache.decision", decision);
|
|
484
|
+
if (profile) active.setAttribute?.("deco.cache.profile", profile);
|
|
485
|
+
if (layer) active.setAttribute?.("deco.cache.layer", layer);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const m = getState().meter;
|
|
489
|
+
if (!m) return;
|
|
490
|
+
// Label names mirror the dotted span attributes `deco.cache.decision`,
|
|
491
|
+
// `deco.cache.profile`, `deco.cache.layer` (see model/registry/deco.yaml +
|
|
492
|
+
// model/metrics.yaml). Prometheus convention requires snake_case without
|
|
493
|
+
// dots — so `cache_decision` / `cache_layer` (not `decision` / `layer`).
|
|
494
|
+
const labels: Labels = {};
|
|
495
|
+
if (profile) labels.profile = profile;
|
|
496
|
+
if (decision) labels.cache_decision = decision;
|
|
497
|
+
if (layer) labels.cache_layer = layer;
|
|
498
|
+
m.counterInc(hit ? MetricNames.CACHE_HIT : MetricNames.CACHE_MISS, 1, labels);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Labels for `commerce_request_duration_ms`. Owned by the framework so
|
|
503
|
+
* apps-start (and any future provider package) can register operation
|
|
504
|
+
* strings without owning the histogram declaration. Phase 2 (D-11).
|
|
505
|
+
*/
|
|
506
|
+
export interface CommerceMetricLabels {
|
|
507
|
+
/** `vtex`, `shopify`, `wake`, ... — small closed set. */
|
|
508
|
+
provider: string;
|
|
509
|
+
/** Per-provider operation, e.g. `intelligent-search.product_search`. */
|
|
510
|
+
operation: string;
|
|
511
|
+
/** Set when known (e.g. from the HTTP response). Bounded enum. */
|
|
512
|
+
status_class?: string;
|
|
513
|
+
/** Whether the underlying fetch was served from a cache. */
|
|
514
|
+
cached?: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Record a commerce / outbound-fetch duration sample. No-op when no
|
|
519
|
+
* meter is configured. The metric name is constant
|
|
520
|
+
* (`commerce_request_duration_ms`) — providers vary by the `provider`
|
|
521
|
+
* label, not by name, so dashboards aggregate cleanly across the fleet.
|
|
522
|
+
*/
|
|
523
|
+
export function recordCommerceMetric(
|
|
524
|
+
durationMs: number,
|
|
525
|
+
labels: CommerceMetricLabels,
|
|
526
|
+
) {
|
|
527
|
+
const m = getState().meter;
|
|
528
|
+
if (!m) return;
|
|
529
|
+
const merged: Labels = {
|
|
530
|
+
provider: labels.provider,
|
|
531
|
+
operation: labels.operation,
|
|
532
|
+
};
|
|
533
|
+
if (labels.status_class) merged.status_class = labels.status_class;
|
|
534
|
+
if (typeof labels.cached === "boolean") merged.cached = labels.cached;
|
|
535
|
+
// Canonical OTel HTTP client metric — outbound commerce calls share the
|
|
536
|
+
// same metric as any other outbound HTTP request; `peer.service` /
|
|
537
|
+
// `commerce.operation` attributes disambiguate consumer queries.
|
|
538
|
+
m.histogramRecord?.(MetricNames.HTTP_CLIENT_REQUEST_DURATION, durationMs, merged);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Record a loader execution sample. Call from `cachedLoader` after the
|
|
543
|
+
* loader resolves or rejects. `cache_status` mirrors `CacheDecision` so
|
|
544
|
+
* dashboards can distinguish HIT (fresh) from STALE-HIT (SWR), STALE-ERROR
|
|
545
|
+
* (SIE fallback), MISS (origin fetch), and BYPASS (dev / no-store).
|
|
546
|
+
*/
|
|
547
|
+
export function recordLoaderMetric(
|
|
548
|
+
name: string,
|
|
549
|
+
durationMs: number,
|
|
550
|
+
cacheStatus: CacheDecision | "BYPASS",
|
|
551
|
+
) {
|
|
552
|
+
const m = getState().meter;
|
|
553
|
+
if (!m) return;
|
|
554
|
+
m.histogramRecord?.(MetricNames.LOADER_DURATION, durationMs, {
|
|
555
|
+
loader: name,
|
|
556
|
+
cache_status: cacheStatus,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Increment the loader error counter. Call when a loader throws and the
|
|
562
|
+
* error is not swallowed by a SIE fallback.
|
|
563
|
+
*/
|
|
564
|
+
export function recordLoaderError(name: string) {
|
|
565
|
+
const m = getState().meter;
|
|
566
|
+
if (!m) return;
|
|
567
|
+
m.counterInc(MetricNames.LOADER_ERRORS, 1, { loader: name });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function normalizePath(path: string): string {
|
|
571
|
+
// Collapse dynamic segments to reduce cardinality
|
|
572
|
+
return path
|
|
573
|
+
.replace(/\/[0-9a-f]{8,}/gi, "/:id")
|
|
574
|
+
.replace(/\/\d+/g, "/:id")
|
|
575
|
+
.replace(/\/[^/]+\/p$/, "/:slug/p");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// Request logging
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
const isDev =
|
|
583
|
+
typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Structured request log entry.
|
|
587
|
+
* JSON in production, colorized in development.
|
|
588
|
+
* Includes traceId when available.
|
|
589
|
+
*/
|
|
590
|
+
export function logRequest(
|
|
591
|
+
request: Request,
|
|
592
|
+
status: number,
|
|
593
|
+
durationMs: number,
|
|
594
|
+
extra?: Record<string, unknown>,
|
|
595
|
+
) {
|
|
596
|
+
const url = new URL(request.url);
|
|
597
|
+
// Truncate long paths (e.g. /_serverFn/ base64 payloads) so log messages
|
|
598
|
+
// remain readable. Full path is preserved in the `path` attribute.
|
|
599
|
+
const rawPath = url.pathname;
|
|
600
|
+
const displayPath = rawPath.length > 80 ? `${rawPath.slice(0, 80)}…` : rawPath;
|
|
601
|
+
// Route through the framework logger so the access log fans out to every
|
|
602
|
+
// configured adapter — local stdout via `defaultLoggerAdapter`, OTLP direct-
|
|
603
|
+
// POST via `otlpLog.adapter` when configured (subject to its
|
|
604
|
+
// `DECO_OTEL_LOGS_MIN_LEVEL` threshold). Always debug: filtered in prod
|
|
605
|
+
// (default `warn` minLevel), visible in dev with DECO_OTEL_LOGS_MIN_LEVEL=debug.
|
|
606
|
+
// 5xx errors are already logged at the throw site with full context; the
|
|
607
|
+
// access log is just a dev convenience, not a production alert source.
|
|
608
|
+
logger.debug(`${request.method} ${displayPath} ${status}`, {
|
|
609
|
+
method: request.method,
|
|
610
|
+
path: rawPath,
|
|
611
|
+
status,
|
|
612
|
+
duration_ms: Math.round(durationMs),
|
|
613
|
+
...extra,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// noopRequestStore is kept as a no-op fallback for advanced tests; not
|
|
618
|
+
// re-exported because consumers should reach for `setObservabilitySpanStore`
|
|
619
|
+
// instead.
|
|
620
|
+
void noopRequestStore;
|