Story-1448 pramaan-admin EPIC-1400 trace graph migration (Phase-2)

The admin surface gets a real tracer, and exactly one place owns it

Phase A carried a W3C traceparent header through the edge (src/proxy.ts mints it) and forwarded it on every backend call through the gateway choke point (src/lib/gateway.ts), but the surface ran no OpenTelemetry SDK — it could relay a trace identity yet never created or exported a span of its own. This Story is the Phase-B verdict: adopt @vercel/otel, the one permitted SDK, and register the provider in a single owned home so spans now export in AWS X-Ray format and join the trace the gateway already forwards.

What changed

One new file, src/instrumentation.ts, with a register() that calls registerOTel() exactly once. Next.js loads this hook before any request runs, so the provider is live for the whole runtime. Nothing else constructs a provider; the gateway keeps doing what it already did — forward traceparent — and now the SDK's tracecontext propagator lets in-process spans hang off that same identity instead of forking a new trace.

Single provider owner

registerOTel() runs only in src/instrumentation.ts. No TracerProvider construction, no raw tracer calls, no registerOTel rider anywhere else — the ADR-124 ownership rule for this surface, enforceable by a one-file grep.

X-Ray-format spans

idGenerator: new AWSXRayIdGenerator() mints trace ids in X-Ray's epoch-prefixed layout, so spans exported from the admin runtime line up with the EPIC-1400 trace graph. The drains → AWS forwarder that ships them is infra #1450, out of scope here.

Joins, never forks

propagators: ["tracecontext"] only — the same header the gateway forwards. The baggage propagator is deliberately omitted, so firm_id can never leak into baggage. The sampler is parent-based trace-id-ratio reusing the Phase-A resolveTraceSampleRatio() (0.1), one ratio source for edge and runtime.

Operational behavior

The provider respects the existing kill switch: PRAMAAN_OTEL_DISABLED=1 makes register() return before registerOTel() runs, matching the bridge's traceparent kill switch, so a single Vercel env flag disables minting, forwarding, and the SDK with no code change. With the SDK off (the default until drains land), behavior is identical to Phase A. With it on, @vercel/otel's library-owned fetch auto-instrumentation produces HTTP spans on current OTel semantic-convention keys; this Story stamps no custom domain attributes, so there are no bespoke or deprecated semconv keys to review and no manual spans to audit. Domain spans, when they come, are a later ticket using the approved withManualSpan helper.

Verification proof

Gates green: pnpm exec tsc --noEmit (exit 0), pnpm run lint (0 errors; 7 pre-existing warnings in unrelated test files), NEXT_PUBLIC_ENV=staging pnpm run build (compiled successfully, all 18 routes), and pnpm run test:unit (544 passed across 86 files).

Contract integrity: git diff --check clean; pnpm kubb:generate leaves src/gen/ with no drift — this change touches no contract surface, only adds the instrumentation entrypoint and its OpenTelemetry dependencies.

Guardrail check: registerOTel appears in exactly one product file (src/instrumentation.ts); no startActiveSpan/tracer.*/set_tracer_provider outside it; no baggage propagator registered.

Remaining live check: end-to-end span export to AWS X-Ray is gated on the drains → forwarder (infra #1450) and verified on the deployed environment, not on local or fixture data.