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
| Concern | Posture (unchanged) |
|---|---|
mintTraceparent | Random 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 switch | Strips traceparent (and tracestate); no provider, nothing trace-related leaves the app. Correlation handling untouched. |
x-pramaan-correlation-id | Unchanged. cid retirement is a later ticket (#1454/G7), out of scope here. |
| Provider ownership | No 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
- Active span wins: with a mocked propagator injecting id A, the outbound
traceparent'straceIdequals the active span's id — not a random/minted id — and overrides a present inbound/proxy-stampedtraceparent. - tracestate carried: a
tracestatefrom the active context rides alongsidetraceparent. - No-active-span fallback: with no provider the default no-op propagator injects nothing → the helper falls back to
resolveTraceparent/mintTraceparent(background/no-request unchanged). - Kill switch:
PRAMAAN_OTEL_DISABLED=1stripstraceparenteven when a span is active; correlation unaffected.
Verification green
pnpm lint— clean (1 pre-existingno-img-elementwarning inavatar.tsx, unrelated).pnpm exec tsc --noEmit— clean.pnpm test— 557 passed (96 files); new active-span cases intests/unit/lib/outbound-headers.test.ts.pnpm build— pass.git diff --check— clean.
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.ts | Library | +60 | -7 |
| tests/unit/lib/outbound-headers.test.ts | Test | +85 | -0 |