/** * @factory/observability — W3C `traceparent` capture/resume (TASK-0135). * * The API→worker propagation primitive: the dispatcher stores the active * `traceparent` in the BullMQ job METADATA; the processor resumes the span * under it, so a job's trace continues the request that enqueued it (one * trace across api → queue → worker — the spec's "job metadata carries the * W3C traceparent — the dispatcher stores it, the processor resumes the * span"). * * The module is `@opentelemetry/api`-only (NO SDK dependency): it ships a * minimal, spec-complete W3C trace-context propagator (version `00`, 32-hex * trace-id, 16-hex span-id, 2-hex flags) that `initOtel` registers on the * global propagation API. That makes capture/resume behave identically in a * full SDK process and in a bare one (dev/tests, OTEL disabled — the * no-op contract: `captureTraceparent()` returns `undefined`, * `resumeTraceparent()` runs the callback ambient). */ import { context, propagation, trace, type Context, type SpanContext, type TextMapGetter, type TextMapPropagator, type TextMapSetter } from "@opentelemetry/api"; export const TRACE_HEADER = "traceparent"; export const TRACESTATE_HEADER = "tracestate"; const HEADER_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; const INVALID_TRACE_ID = "00000000000000000000000000000000"; const INVALID_SPAN_ID = "0000000000000000"; /** Parse a W3C `traceparent` header into its parts (or null when invalid). */ export function parseTraceparent(tp: string): { traceId: string; spanId: string; flags: number } | null { const m = HEADER_RE.exec(tp.trim()); if (!m) return null; const traceId = m[1]!; const spanId = m[2]!; if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID) return null; return { traceId, spanId, flags: Number.parseInt(m[3]!, 16) }; } /** Build a `traceparent` string from raw parts (tests / dispatcher fallback). */ export function buildTraceparent(traceId: string, spanId: string, sampled = true): string { return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`; } /** * The minimal W3C trace-context propagator (spec 00). `inject` writes * `traceparent` from the active span context; `extract` parses it back into * a REMOTE span context. Invalid / all-zero ids are ignored (no context is * produced) — the spec's "invalid value" rule. * * (Self-contained by design: `@opentelemetry/core`'s propagator pulls a * whole extra dependency tree, and ours has to behave identically when the * SDK is NOT loaded — the no-op contract.) */ export class W3CTraceContextPropagator implements TextMapPropagator { fields(): string[] { return [TRACE_HEADER]; } inject(ctx: Context, carrier: Record, _setter?: TextMapSetter>): void { const sc = trace.getSpanContext(ctx); if (!sc || !trace.isSpanContextValid(sc)) return; carrier[TRACE_HEADER] = `00-${sc.traceId}-${sc.spanId}-${sc.traceFlags.toString(16).padStart(2, "0")}`; } extract( ctx: Context, carrier: Record, _getter?: TextMapGetter> ): Context { const raw = headerGetter.get(carrier, TRACE_HEADER); if (typeof raw !== "string") return ctx; const parts = parseTraceparent(raw); if (!parts) return ctx; const sc: SpanContext = { traceId: parts.traceId, spanId: parts.spanId, isRemote: true, traceFlags: parts.flags }; return trace.setSpanContext(ctx, sc); } } /** Reads a string-keyed carrier (single string values; joins arrays). */ const headerGetter: TextMapGetter> = { keys(carrier: Record): string[] { return Object.keys(carrier); }, get(carrier: Record, key: string): string | undefined { const v = carrier[key]; return Array.isArray(v) ? v.join(",") : v; } }; /** * The singleton propagator this package uses (registered by `initOtel`). * Typed with the OPTIONAL carrier helper params (the global API's * `TextMapPropagator` marks them required) so call sites can use the * 2-arg form — the package always injects/extracts via plain objects. */ export interface W3CPropagatorLike { fields(): string[]; inject(ctx: Context, carrier: Record, setter?: TextMapSetter>): void; extract( ctx: Context, carrier: Record, getter?: TextMapGetter> ): Context; } export const traceparentPropagator: W3CPropagatorLike = new W3CTraceContextPropagator(); /** * Make this package's propagator the global one (idempotent). `initOtel` * calls it; a test can call it directly to exercise capture/resume without * booting the SDK. */ export function registerTraceparentPropagator(): void { propagation.setGlobalPropagator(traceparentPropagator); } /** * Capture the ACTIVE W3C traceparent (from whatever context is currently * active). Returns `undefined` when there is no active span or no propagator * is registered (dev, tests, OTEL disabled) — store that as "no trace to * resume". */ export function captureTraceparent(ctx: Context = context.active()): string | undefined { const carrier: Record = {}; propagation.inject(ctx, carrier); return carrier[TRACE_HEADER]; } /** * Run `fn` with the parent context reconstructed from a stored * `traceparent` (the worker-processor side). Missing/invalid header or no * registered propagator (OTEL disabled) → runs `fn` in the ambient context * (the no-op contract). */ export function resumeTraceparent(tp: string | undefined, fn: (parent: Context) => T): T { if (tp) { const extracted = propagation.extract(context.active(), { [TRACE_HEADER]: tp }); if (extracted !== context.active()) { return context.with(extracted, fn, undefined, extracted); } } return fn(context.active()); } /** * The shared BullMQ metadata carrier shape (what the API dispatcher stores * + the worker processor reads). Lives here — not in a BullMQ-typed module — * so both sides share ONE definition without importing BullMQ into this * package. */ export interface TraceMetadata { /** W3C traceparent of the enqueueing request (absent = untraced). */ traceparent?: string; } /** Pull a VALID traceparent out of a loosely typed BullMQ job metadata map. */ export function metadataTraceparent(metadata: Record | null | undefined): string | undefined { if (!metadata) return undefined; const tp = metadata["traceparent"]; return typeof tp === "string" && parseTraceparent(tp) !== null ? tp : undefined; } /** * Validate a traceparent carried as a raw string — the worker-side read of * BullMQ 5's `job.opts.telemetry.metadata` field (the API stores the * traceparent there; see apps/api/src/common/trace-metadata.ts). Invalid or * absent → undefined (the no-op contract). */ export function telemetryTraceparent(metadata: string | null | undefined): string | undefined { return typeof metadata === "string" && parseTraceparent(metadata) !== null ? metadata : undefined; }