@decocms/start 6.0.1 → 6.1.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.
@@ -266,6 +266,105 @@ describe("serializeError", () => {
266
266
  });
267
267
  });
268
268
 
269
+ describe("request.id stamping (Phase 1, D-9)", () => {
270
+ afterEach(() => {
271
+ configureLogger(defaultLoggerAdapter);
272
+ setLogLevel("info");
273
+ });
274
+
275
+ it("stamps request.id from RequestContext on every log line emitted inside a request scope", async () => {
276
+ const { RequestContext } = await import("./requestContext");
277
+
278
+ const seen: Array<Record<string, unknown> | undefined> = [];
279
+ configureLogger({
280
+ log(_l, _m, attrs) {
281
+ seen.push(attrs);
282
+ },
283
+ });
284
+
285
+ const reqWithId = new Request("https://example.com/", {
286
+ headers: { "x-request-id": "client-supplied-uuid" },
287
+ });
288
+ await RequestContext.run(reqWithId, async () => {
289
+ logger.info("inside-scope", { custom: "yes" });
290
+ });
291
+ // Outside the scope, no request.id is stamped — the fast path stays
292
+ // fast (no allocation, no key) when RequestContext.requestId is null.
293
+ logger.info("outside-scope", { custom: "no" });
294
+
295
+ expect(seen[0]).toMatchObject({
296
+ "request.id": "client-supplied-uuid",
297
+ custom: "yes",
298
+ });
299
+ expect(seen[1]).toEqual({ custom: "no" });
300
+ expect(seen[1]).not.toHaveProperty("request.id");
301
+ });
302
+
303
+ it("prefers caller-supplied request.id over the auto-generated one", async () => {
304
+ const { RequestContext } = await import("./requestContext");
305
+
306
+ const seen: Array<Record<string, unknown> | undefined> = [];
307
+ configureLogger({
308
+ log(_l, _m, attrs) {
309
+ seen.push(attrs);
310
+ },
311
+ });
312
+
313
+ const req = new Request("https://example.com/", {
314
+ headers: { "x-request-id": "from-headers" },
315
+ });
316
+
317
+ await RequestContext.run(req, async () => {
318
+ // Caller can still override by passing the key directly in attrs.
319
+ logger.info("override", { "request.id": "explicit-from-caller" });
320
+ });
321
+
322
+ expect(seen[0]?.["request.id"]).toBe("explicit-from-caller");
323
+ });
324
+
325
+ it("falls back to cf-ray when x-request-id is absent", async () => {
326
+ const { RequestContext } = await import("./requestContext");
327
+
328
+ const seen: Array<Record<string, unknown> | undefined> = [];
329
+ configureLogger({
330
+ log(_l, _m, attrs) {
331
+ seen.push(attrs);
332
+ },
333
+ });
334
+
335
+ const req = new Request("https://example.com/", {
336
+ headers: { "cf-ray": "8a1b2c3d4e5f6a7b" },
337
+ });
338
+
339
+ await RequestContext.run(req, async () => {
340
+ logger.info("cf-ray-stamped");
341
+ });
342
+
343
+ expect(seen[0]?.["request.id"]).toBe("8a1b2c3d4e5f6a7b");
344
+ });
345
+
346
+ it("generates a fresh UUID when neither header is set", async () => {
347
+ const { RequestContext } = await import("./requestContext");
348
+
349
+ const seen: Array<Record<string, unknown> | undefined> = [];
350
+ configureLogger({
351
+ log(_l, _m, attrs) {
352
+ seen.push(attrs);
353
+ },
354
+ });
355
+
356
+ const req = new Request("https://example.com/");
357
+
358
+ await RequestContext.run(req, async () => {
359
+ logger.info("uuid-stamped");
360
+ });
361
+
362
+ const stamped = seen[0]?.["request.id"];
363
+ expect(typeof stamped).toBe("string");
364
+ expect((stamped as string).length).toBeGreaterThan(8);
365
+ });
366
+ });
367
+
269
368
  describe("trace correlation", () => {
270
369
  afterEach(() => {
271
370
  configureLogger(defaultLoggerAdapter);
package/src/sdk/logger.ts CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { getActiveSpan } from "./observability";
28
+ import { RequestContext } from "./requestContext";
28
29
 
29
30
  export type LogLevel = "debug" | "info" | "warn" | "error";
30
31
 
@@ -252,20 +253,30 @@ function emit(level: LogLevel, msg: string, attrs?: Record<string, unknown>): vo
252
253
  // to its trace in ClickStack/HyperDX. No active span → no-op; caller
253
254
  // attrs always win so explicit `trace_id` overrides keep working.
254
255
  const ctx = getActiveSpan()?.spanContext?.();
255
- const traceAttrs: Record<string, unknown> | undefined = ctx
256
- ? { trace_id: ctx.traceId, span_id: ctx.spanId }
257
- : undefined;
258
- // Merge order: floor trace context → caller attrs. Caller wins; trace
259
- // context only overrides floor (which never sets trace_id anyway).
256
+ // Pull request.id from the AsyncLocalStorage-backed RequestContext so
257
+ // every log line in the request also carries the join key used by
258
+ // direct-POST metrics + tail-worker rows. Single read, no allocation
259
+ // when outside a request scope.
260
+ const requestId = RequestContext.requestId;
261
+ const requestAttrs: Record<string, unknown> | undefined =
262
+ ctx || requestId
263
+ ? {
264
+ ...(ctx ? { trace_id: ctx.traceId, span_id: ctx.spanId } : {}),
265
+ ...(requestId ? { "request.id": requestId } : {}),
266
+ }
267
+ : undefined;
268
+ // Merge order: floor → trace / request context → caller attrs. Caller
269
+ // wins; the request-scoped context only overrides floor keys (which
270
+ // never set `trace_id` / `request.id` anyway).
260
271
  const s = getState();
261
272
  const hasFloor = Object.keys(s.attributeFloor).length > 0;
262
273
  let merged: Record<string, unknown> | undefined;
263
- if (!hasFloor && !traceAttrs && !attrs) {
274
+ if (!hasFloor && !requestAttrs && !attrs) {
264
275
  merged = undefined;
265
276
  } else {
266
277
  merged = {
267
278
  ...(hasFloor ? s.attributeFloor : {}),
268
- ...(traceAttrs ?? {}),
279
+ ...(requestAttrs ?? {}),
269
280
  ...(attrs ?? {}),
270
281
  };
271
282
  }
@@ -52,6 +52,8 @@ export {
52
52
  // Tracer / meter / request log primitives (re-exported from the middleware)
53
53
  export {
54
54
  type CacheDecision,
55
+ type CacheLayer,
56
+ type CommerceMetricLabels,
55
57
  configureMeter,
56
58
  configureTracer,
57
59
  getActiveSpan,
@@ -62,16 +64,32 @@ export {
62
64
  type MeterAdapter,
63
65
  MetricNames,
64
66
  recordCacheMetric,
67
+ recordCommerceMetric,
65
68
  recordRequestMetric,
69
+ type RequestMetricLabels,
66
70
  type RequestStore,
67
71
  type Span,
68
72
  setObservabilitySpanStore,
69
73
  setSpanAttribute,
74
+ statusClassFor,
70
75
  type TracerAdapter,
71
76
  withTracing,
72
77
  } from "../middleware/observability";
73
78
  // Worker-entry wrapper + adapter wiring
74
79
  export { instrumentWorker, type OtelOptions } from "./otel";
80
+ // Direct-POST OTLP trace exporter (Phase 3 / D-12). Exported for sites
81
+ // that need to wire a custom traces endpoint outside `instrumentWorker`,
82
+ // and for the audit tooling that asserts framework spans are flowing.
83
+ export {
84
+ createOtlpHttpTracerAdapter,
85
+ newSpanId,
86
+ newTraceId,
87
+ type OtlpHttpTracer,
88
+ type OtlpHttpTracerOptions,
89
+ parseTraceparent,
90
+ shouldSampleTrace,
91
+ type TraceContext,
92
+ } from "./otelHttpTracer";
75
93
  // AE meter adapter + runtime env helpers (for tests / custom wiring)
76
94
  export {
77
95
  type AnalyticsEngineDataset,
package/src/sdk/otel.ts CHANGED
@@ -61,10 +61,16 @@
61
61
  import { SpanStatusCode, trace } from "@opentelemetry/api";
62
62
  import { createCompositeLogger, createCompositeMeter } from "./composite";
63
63
  import { configureLogger, defaultLoggerAdapter, setLoggerAttributeFloor } from "./logger";
64
- import { configureMeter, configureTracer } from "./observability";
64
+ import { configureMeter, configureTracer, getActiveSpan } from "./observability";
65
65
  import { createAnalyticsEngineMeterAdapter } from "./otelAdapters";
66
66
  import { createOtlpHttpErrorLogAdapter, type OtlpHttpErrorLog } from "./otelHttpErrorLog";
67
67
  import { createOtlpHttpMeterAdapter, type OtlpHttpMeter } from "./otelHttpMeter";
68
+ import {
69
+ createOtlpHttpTracerAdapter,
70
+ type OtlpHttpTracer,
71
+ type TraceContext,
72
+ } from "./otelHttpTracer";
73
+ import { RequestContext } from "./requestContext";
68
74
 
69
75
  // ---------------------------------------------------------------------------
70
76
  // Types
@@ -98,6 +104,36 @@ export interface OtelOptions {
98
104
  otlpErrorLogsEndpointEnvVar?: string;
99
105
  /** Set to `false` to disable the OTLP/HTTP error-log exporter explicitly. */
100
106
  otlpErrorLogsEnabled?: boolean;
107
+ /**
108
+ * Env var name holding the OTLP/HTTP traces endpoint used by the
109
+ * direct-POST span exporter. Defaults to `"DECO_OTEL_TRACES_ENDPOINT"`.
110
+ * When set (and `otlpTracesEnabled !== false`), framework `deco.*`
111
+ * spans created via `withTracing` are captured, sampled, and POSTed
112
+ * directly to this endpoint. Flushed alongside metrics via
113
+ * `ctx.waitUntil`.
114
+ *
115
+ * Without this endpoint configured, `withTracing` falls back to the
116
+ * `@opentelemetry/api` global tracer (the legacy CF auto-instrumentation
117
+ * path). The framework registers BOTH adapters when a traces endpoint
118
+ * is set so CF auto-spans stay intact AND framework spans get
119
+ * direct-POSTed to ClickHouse. See Phase 3 in
120
+ * `MIGRATION_TOOLING_PLAN.md`.
121
+ */
122
+ otlpTracesEndpointEnvVar?: string;
123
+ /** Set to `false` to disable the OTLP/HTTP traces exporter explicitly. */
124
+ otlpTracesEnabled?: boolean;
125
+ /**
126
+ * Head sampling rate for framework spans direct-POSTed via the OTLP
127
+ * traces endpoint. Default `0.01` matches the CF Destinations
128
+ * `traces.head_sampling_rate` recommendation. Decisions are consistent
129
+ * per trace (hash of `trace_id`), so child spans (`deco.cache.lookup`,
130
+ * `deco.cms.resolvePage`, ...) are kept iff their root
131
+ * `deco.http.request` span is kept. Set to `1` to capture every trace
132
+ * (preview / debug only — production cost grows linearly).
133
+ */
134
+ otlpTracesSamplingRate?: number;
135
+ /** Test seam — replace the global `fetch` used by the traces exporter. */
136
+ otlpTracesFetchImpl?: typeof fetch;
101
137
  /**
102
138
  * Version of `@decocms/start` to advertise as `deco.runtime.version`
103
139
  * on every span and every log line. Falls back to a build-time constant;
@@ -150,6 +186,37 @@ let otlpMeter: OtlpHttpMeter | null = null;
150
186
  */
151
187
  let otlpErrorLog: OtlpHttpErrorLog | null = null;
152
188
 
189
+ /**
190
+ * Module-level handle to the OTLP/HTTP trace exporter — installed by
191
+ * `bootObservability` when `DECO_OTEL_TRACES_ENDPOINT` is set on `env`.
192
+ * Flushed alongside the metrics + error-log exporters via
193
+ * `ctx.waitUntil(...)` at the end of every request.
194
+ */
195
+ let otlpTracer: OtlpHttpTracer | null = null;
196
+
197
+ /**
198
+ * Per-request inbound W3C trace context, parsed from the `traceparent`
199
+ * header at request entry. Read by the OTLP trace exporter when it
200
+ * creates a root span so we honor remote parents and the `sampled`
201
+ * flag. Stored on a request-scoped slot (via `RequestContext.bag`) so
202
+ * concurrent requests in the same isolate don't trample each other.
203
+ */
204
+ const TRACE_CTX_BAG_KEY = "deco.observability.traceContext.v1";
205
+
206
+ function getRequestTraceContext(): TraceContext | null {
207
+ return RequestContext.getBag<TraceContext>(TRACE_CTX_BAG_KEY) ?? null;
208
+ }
209
+
210
+ /**
211
+ * Public entry point used by `workerEntry.ts` to stash the parsed
212
+ * traceparent for the OTLP tracer to consume. Exported (not just
213
+ * module-local) because the parser lives in `otelHttpTracer.ts` and
214
+ * the call site is `workerEntry.ts`.
215
+ */
216
+ export function _setRequestTraceContext(ctx: TraceContext | null): void {
217
+ if (ctx) RequestContext.setBag(TRACE_CTX_BAG_KEY, ctx);
218
+ }
219
+
153
220
  /**
154
221
  * Per-span attribute floor — stamped on every span we create via
155
222
  * `configureTracer().startSpan(...)`. CF's trace export emits its own
@@ -183,38 +250,13 @@ export function instrumentWorker(
183
250
  handler: WorkerHandler,
184
251
  options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
185
252
  ): WorkerHandler {
186
- // Bridge our pluggable TracerAdapter onto @opentelemetry/api. Framework
187
- // code calls `withTracing("name", fn, { attr: val })`; that delegates here
188
- // and lands on `trace.getTracer("@decocms/start").startSpan(...)`.
189
- //
190
- // CF Workers Tracing (when `observability.traces.enabled = true` in
191
- // wrangler) installs its own TracerProvider into the @opentelemetry/api
192
- // global, so these spans flow through to the CF dashboard. Without CF
193
- // tracing the global tracer is a no-op proxy and the spans simply drop
194
- // — same outcome as before, no error.
195
- configureTracer({
196
- startSpan: (name, attrs) => {
197
- const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
198
- const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
199
- return {
200
- end: () => span.end(),
201
- setError: (error) => {
202
- const message = error instanceof Error ? error.message : String(error);
203
- if (error instanceof Error) span.recordException(error);
204
- span.setStatus({ code: SpanStatusCode.ERROR, message });
205
- },
206
- setAttribute: (k, v) => span.setAttribute(k, v),
207
- spanContext: () => {
208
- const ctx = span.spanContext();
209
- return {
210
- traceId: ctx.traceId,
211
- spanId: ctx.spanId,
212
- traceFlags: ctx.traceFlags,
213
- };
214
- },
215
- };
216
- },
217
- });
253
+ // Default tracer bridge delegates to `@opentelemetry/api` global. When
254
+ // `bootObservability` discovers `DECO_OTEL_TRACES_ENDPOINT`, it composes
255
+ // this bridge with the direct-POST OTLP tracer so framework spans flow to
256
+ // BOTH the CF dashboard AND ClickHouse (the bridge stays a no-op when CF
257
+ // tracing isn't configured, which is the common case today). See
258
+ // `configureTracerStack` below.
259
+ configureTracer(buildOtelApiTracer());
218
260
 
219
261
  return {
220
262
  async fetch(request, env, ctx) {
@@ -227,11 +269,11 @@ export function instrumentWorker(
227
269
  try {
228
270
  return await handler.fetch(request, env, ctx);
229
271
  } finally {
230
- // Drain the OTLP metrics + error-log buffers via ctx.waitUntil
231
- // so neither POST blocks the response. Both exporters throttle
232
- // themselves per isolate — calling on every request is cheap;
233
- // the network only fires when the cooldown elapses or the
234
- // buffer fills.
272
+ // Drain the OTLP metrics + error-log + traces buffers via
273
+ // ctx.waitUntil so no POST blocks the response. Each exporter
274
+ // throttles itself per isolate — calling on every request is
275
+ // cheap; the network only fires when the cooldown elapses or
276
+ // the buffer fills.
235
277
  if (otlpMeter) {
236
278
  try {
237
279
  ctx.waitUntil(otlpMeter.flush());
@@ -246,11 +288,122 @@ export function instrumentWorker(
246
288
  /* swallow */
247
289
  }
248
290
  }
291
+ if (otlpTracer) {
292
+ try {
293
+ ctx.waitUntil(otlpTracer.flush());
294
+ } catch {
295
+ /* swallow */
296
+ }
297
+ }
249
298
  }
250
299
  },
251
300
  };
252
301
  }
253
302
 
303
+ /**
304
+ * Build the legacy `@opentelemetry/api` global-tracer bridge. Stays a
305
+ * no-op when no global TracerProvider is registered — same outcome as
306
+ * the historical configuration.
307
+ */
308
+ function buildOtelApiTracer(): import("../middleware/observability").TracerAdapter {
309
+ return {
310
+ startSpan: (name, attrs) => {
311
+ const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
312
+ const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
313
+ return {
314
+ end: () => span.end(),
315
+ setError: (error) => {
316
+ const message = error instanceof Error ? error.message : String(error);
317
+ if (error instanceof Error) span.recordException(error);
318
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
319
+ },
320
+ setAttribute: (k, v) => span.setAttribute(k, v),
321
+ spanContext: () => {
322
+ const ctx = span.spanContext();
323
+ return {
324
+ traceId: ctx.traceId,
325
+ spanId: ctx.spanId,
326
+ traceFlags: ctx.traceFlags,
327
+ };
328
+ },
329
+ };
330
+ },
331
+ };
332
+ }
333
+
334
+ /**
335
+ * Wire the framework tracer. When the OTLP traces endpoint is configured,
336
+ * compose the direct-POST tracer ALONGSIDE the `@opentelemetry/api` bridge
337
+ * via a fanout adapter so:
338
+ * 1. `withTracing` calls feed BOTH adapters.
339
+ * 2. A child span's `spanContext()` reports the direct-POST span's IDs
340
+ * (the bridge is best-effort — if CF tracing isn't installed those
341
+ * IDs are zeros anyway).
342
+ *
343
+ * When no traces endpoint is configured, fall back to the bridge alone —
344
+ * preserves the legacy behavior for sites that haven't bumped wrangler.
345
+ */
346
+ function configureTracerStack(otlpAdapter: OtlpHttpTracer | null): void {
347
+ const bridge = buildOtelApiTracer();
348
+ if (!otlpAdapter) {
349
+ configureTracer(bridge);
350
+ return;
351
+ }
352
+ // Compose. The OTLP adapter is the "primary" (its IDs win for
353
+ // `spanContext()` because callers downstream use them for trace
354
+ // propagation). The bridge is best-effort — fed the same name/attrs
355
+ // so CF Workers Observability still sees the spans if that channel
356
+ // is enabled in wrangler.jsonc.
357
+ configureTracer({
358
+ startSpan(name, attrs) {
359
+ const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
360
+ const primary = otlpAdapter.startSpan(name, merged);
361
+ const secondary = bridge.startSpan(name, merged);
362
+ return {
363
+ end(): void {
364
+ try {
365
+ primary.end();
366
+ } finally {
367
+ try {
368
+ secondary.end();
369
+ } catch {
370
+ /* swallow */
371
+ }
372
+ }
373
+ },
374
+ setError(error: unknown): void {
375
+ try {
376
+ primary.setError?.(error);
377
+ } finally {
378
+ try {
379
+ secondary.setError?.(error);
380
+ } catch {
381
+ /* swallow */
382
+ }
383
+ }
384
+ },
385
+ setAttribute(key: string, value: string | number | boolean): void {
386
+ primary.setAttribute?.(key, value);
387
+ try {
388
+ secondary.setAttribute?.(key, value);
389
+ } catch {
390
+ /* swallow */
391
+ }
392
+ },
393
+ spanContext() {
394
+ // The OTLP adapter owns the canonical IDs — those are the IDs
395
+ // we propagate downstream via `traceparent` headers.
396
+ return primary.spanContext?.() ?? secondary.spanContext?.() ?? {
397
+ traceId: "",
398
+ spanId: "",
399
+ traceFlags: 0,
400
+ };
401
+ },
402
+ };
403
+ },
404
+ });
405
+ }
406
+
254
407
  // ---------------------------------------------------------------------------
255
408
  // Boot — wires the loggers/meters once (per worker isolate)
256
409
  // ---------------------------------------------------------------------------
@@ -391,6 +544,41 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
391
544
  // composite becomes a 0-element no-op via createCompositeMeter's filter.
392
545
  configureMeter(composedMeter);
393
546
 
547
+ // Traces — direct-POST exporter for framework `deco.*` spans. Without
548
+ // this, `withTracing` delegates to the no-op `@opentelemetry/api`
549
+ // global tracer and every framework span silently disappears (the
550
+ // Phase 3 gap documented in `MIGRATION_TOOLING_PLAN.md`). Same
551
+ // transport pattern as metrics/error-logs — buffered, sampled by
552
+ // trace-id hash, flushed via `ctx.waitUntil`.
553
+ const otlpTracesEnvVar = opts.otlpTracesEndpointEnvVar ?? "DECO_OTEL_TRACES_ENDPOINT";
554
+ const otlpTracesEndpoint = (env[otlpTracesEnvVar] as string | undefined) ?? "";
555
+ const otlpTracesEnabled =
556
+ opts.otlpTracesEnabled !== false && otlpTracesEndpoint.length > 0;
557
+ if (otlpTracesEnabled) {
558
+ otlpTracer = createOtlpHttpTracerAdapter({
559
+ endpoint: otlpTracesEndpoint,
560
+ resourceAttributes: floor,
561
+ scopeVersion: decoRuntimeVersion,
562
+ headSamplingRate: opts.otlpTracesSamplingRate ?? 0.01,
563
+ fetchImpl: opts.otlpTracesFetchImpl,
564
+ getActiveSpanForParent: () => getActiveSpan(),
565
+ getRequestTraceContext,
566
+ onError: (kind, err) => {
567
+ defaultLoggerAdapter.log("warn", "otlp traces exporter", {
568
+ kind,
569
+ error: err instanceof Error ? err.message : String(err),
570
+ });
571
+ },
572
+ });
573
+ } else {
574
+ otlpTracer = null;
575
+ }
576
+
577
+ // Wire the tracer stack — composes the OTLP direct-POST adapter (when
578
+ // configured) with the @opentelemetry/api bridge. See
579
+ // `configureTracerStack` for the fanout semantics.
580
+ configureTracerStack(otlpTracer);
581
+
394
582
  booted = true;
395
583
 
396
584
  // Single boot-time breadcrumb so operators can confirm the wiring at a
@@ -400,6 +588,7 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
400
588
  analyticsEngine: aeEnabled,
401
589
  otlpMetrics: otlpEnabled,
402
590
  otlpErrorLogs: otlpErrorLogsEnabled,
591
+ otlpTraces: otlpTracesEnabled,
403
592
  runtimeVersion: decoRuntimeVersion,
404
593
  deploymentEnvironment,
405
594
  ...(serviceVersion ? { serviceVersion } : {}),
@@ -415,6 +604,7 @@ export function _resetBootStateForTests(): void {
415
604
  spanAttributeFloor = {};
416
605
  otlpMeter = null;
417
606
  otlpErrorLog = null;
607
+ otlpTracer = null;
418
608
  setLoggerAttributeFloor({});
419
609
  }
420
610