PR: #359: feat(otel): adopt @vercel/otel TracerProvider [Story-1446] by @ankitmaheshwari-com
View PR →
Story 1446 · EPIC-1400 Observability trace graph migration (Phase-2)

Adopt @vercel/otel — the Phase-B TracerProvider owner

Until now pramaan-app ran no OTel SDK: it only minted and forwarded a W3C traceparent header (the pramaan-docs ADR-104 Phase-A bridge in src/proxy.ts). The Vercel half of every request joined the backend trace through trace_id in the pino logs — but was never rendered as spans. This Story turns on Phase-B: it adds the one src/instrumentation.ts that stands up a real TracerProvider via registerOTel(), so Next.js server spans now export with AWS X-Ray-format ids and land in the same trace as the Lambda.

Why a single instrumentation.ts

pramaan-docs ADR-104 freezes a provider-ownership rule: exactly one place per surface owns the SDK. Next.js auto-loads the register() export from src/instrumentation.ts once at server startup, so that file is the natural — and the only — home for the provider. ADR-124 ratifies @vercel/otel as the target. The rule the reviewer should enforce: no registerOTel, no TracerProvider construction, no exporter setup anywhere else (no riders in proxy.ts / gateway.ts). Phase-A carrying traceparent with no provider was correct and is left untouched.

What register() sets up

export function register() {
  if (isTraceBridgeDisabled()) return;          // PRAMAAN_OTEL_DISABLED=1 → no-op

  registerOTel({
    serviceName: "pramaan-app",                  // frozen runtime identity
    idGenerator: new AWSXRayIdGenerator(),        // X-Ray-format trace ids
    propagators: ["tracecontext", "baggage"],     // W3C carrier (+ pinned default)
    traceSampler: "parentbased_traceidratio",     // ratio via OTEL_TRACES_SAMPLER_ARG
  });
}
OptionValueWhy
serviceNamepramaan-appFrozen service.name per ADR-124 §D5 — identifies the deployed runtime that emitted the span.
idGeneratorAWSXRayIdGeneratorThe acceptance criterion. X-Ray ids encode an 8-hex epoch prefix; the X-Ray/CloudWatch drain rejects plain random OTel ids. This makes app spans first-class X-Ray traces.
propagatorstracecontext + baggageW3C trace-context is THE end-to-end carrier (ADR-104 carrier table); baggage is the pinned @vercel/otel default. firm_id is never put in baggage — root-span attribute only (ADR-124 guardrail). This file sets no baggage at all.
traceSamplerparentbased_traceidratioIf proxy.ts minted a traceparent with the 01 sampled flag, that decision is honored; otherwise new roots sample at OTEL_TRACES_SAMPLER_ARG (canonical 0.1 = 10%). Keeps app + Lambda sampling consistent.

How a request now traces, end to end

Kill switch

register() reuses the existing isTraceBridgeDisabled() helper (src/lib/trace.ts): PRAMAAN_OTEL_DISABLED=1 makes registration a no-op — no provider stood up, no spans exported — with no code redeploy (ADR-104 env matrix). It shares the same switch that already gates Phase-A traceparent minting, so one env var turns the whole tracing path off.

semconv check

Verification green

Out of scope (later EPIC-1400 tickets)

The signing-gateway exporter wiring (App Runner collector, bearer token, OIDC), the per-environment OTEL_EXPORTER_OTLP_* Vercel env vars, correlation_id as a root-span attribute, and the live staging drain smoke (forcing a sampled traceparent and confirming the trace renders in Transaction Search) land in their own Phase-2 tickets. This Story is provider adoption only.

Files changed

3 file(s), +248 / -16 lines

Path Role + -
package.jsonConfig+3-0
pnpm-lock.yamlConfig+198-16
src/instrumentation.tsSource+47-0