@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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. 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;