PR: #362: fix(otel): forward @vercel/otel X-Ray trace context to backend (connect app→functions) [Bug-1471] by @ankitmaheshwari-com
View PR →
Bug 1471 · EPIC-1400 Observability trace graph migration · roster PR-4 (server repoint)

Forward the @vercel/otel X-Ray trace context to the backend — connect app → functions

Story-1446 (R5a) turned on the @vercel/otel TracerProvider, so each request's Next.js server work is now a real, exported span with an AWS X-Ray-format trace_id (id A). But the outbound call to pramaan-app-api still forwarded the legacy Phase-A traceparent — a random id (id B) minted at the edge in proxy.ts. An explicit traceparent header pre-empts the propagator's fetch injection, so the backend adopted B, never A. A ≠ B by construction → 0 shared trace_ids: app spans and functions spans lived in two disjoint id spaces and the browser→app→functions parent edge never existed.

1 — The break, in one sentence

The app ran two trace contexts that were never reconciled. @vercel/otel exported the vercel.* span under id A; outbound-headers.ts forwarded the proxy-stamped random id B instead. Single-source the outbound carrier on the active span and the gap closes — the fix is single-source, not better-minting (making B X-Ray-legal would still be a different id).

2 — The fix: inject the active context via the installed propagator

On a live request the active @vercel/otel span wins. We inject it into a fresh W3C carrier using the global propagator that registerOTel already installed (src/instrumentation.ts) — so the carrier always matches the active context and tracestate is handled too. No provider is constructed here; provider ownership stays in instrumentation.ts (pramaan-docs ADR-104 Phase B).

// src/lib/outbound-headers.ts  (server-only)
function injectActiveTraceContext(): Record<string, string> | null {
  const carrier: Record<string, string> = {};
  propagation.inject(otelContext.active(), carrier);   // global W3C propagator
  return isValidTraceparent(getHeaderValue(carrier, TRACEPARENT_HEADER))
    ? carrier        // active recording span → id A landed in the carrier
    : null;          // no active span → caller falls back to minting
}

// in applyOutboundRequestHeaders():
if (isTraceBridgeDisabled()) {
  traceparent = null;                                  // kill switch
} else {
  const carrier = injectActiveTraceContext();
  if (carrier !== null) {
    traceparent = getHeaderValue(carrier, TRACEPARENT_HEADER)!;   // id A WINS
    const tracestate = getHeaderValue(carrier, TRACESTATE_HEADER);
    if (tracestate !== undefined) setHeaderValue(requestHeaders, TRACESTATE_HEADER, tracestate);
  } else {
    traceparent = resolveTraceparent(inboundTraceparent);         // fallback: mint
  }
}

The active span's traceparent overrides the proxy-stamped Phase-A header, so the id on the wire is the SAME span that @vercel/otel exports to X-Ray. With the functions-lane ADOT extractor already preferring tracecontext, pramaan-app-api adopts id A and joins the trace — no functions or infra change required.

3 — What was deliberately preserved

ConcernPosture (unchanged)
mintTraceparentRandom format, untouched. Used only as the fallback when there is genuinely no active span (true background work). In-request calls never re-mint.
PRAMAAN_OTEL_DISABLED kill switchStrips traceparent (and tracestate); no provider, nothing trace-related leaves the app. Correlation handling untouched.
x-pramaan-correlation-idUnchanged. cid retirement is a later ticket (#1454/G7), out of scope here.
Provider ownershipNo registerOTel / TracerProvider / exporter anywhere outside instrumentation.ts.
R12 baggage egress (ADR-124 §D10)Only the W3C trace-context pair (traceparent + tracestate) is copied from the carrier — not baggage. Baggage egress posture stays owned solely by the R12 stripper.

4 — Tests

Verification green

Out of scope

The broader Phase-A correlation_id retirement (#1454/G7) is NOT done here. The live X-Ray acceptance proof — one real stg.app.pramaan.io request yielding a single trace_id where service("vercel.serverless-runtime") AND service("pramaan-functions-app-api") co-occur with a parent edge — is the EPIC-1400 / #1466 gate, run on the deployed SHA after this merges. The admin surface carries the same defect and gets the same fix in a separate pramaan-admin app-lane child.

Files changed

2 file(s), +145 / -7 lines

Path Role + -
src/lib/outbound-headers.tsLibrary+60-7
tests/unit/lib/outbound-headers.test.tsTest+85-0