@decocms/start 6.0.0 → 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.
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