PR: #360: feat(otel): surface request trace_id to RSC + app manual-span helper [Story-1447] by @ankitmaheshwari-com
View PR →
Story 1447 · EPIC-1400 Observability trace graph migration (Phase-2) · R5b

Surface the request trace_id to the RSC tree + an app manual-span helper

Story-1446 (R5a) turned on the @vercel/otel TracerProvider, so the Next.js server work for every request is now a real, exported span with an AWS X-Ray-format trace_id. But nothing on this surface could read that id, and there was no approved way to add PRAMAAN domain spans. This Story closes both gaps: layout.tsx now emits the live trace_id into the document as a <meta> tag, and a new src/lib/observability/manual-span.ts is the single, server-only home for withManualSpan over a closed span-name registry.

1 — Surfacing the live trace_id (edge → RSC)

The provider's active server span carries the X-Ray-format trace_id that joins the app into the X-Ray graph. The RSC layout reads it from the active span and falls back to the edge-minted traceparent when the OTel provider is off (kill switch). The value is per-request — never a baked correlation id.

// src/app/[locale]/layout.tsx
const traceId =
  getCurrentTraceId() ??                                  // live @vercel/otel span
  parseTraceparent(requestHeaders.get(TRACEPARENT_HEADER) ?? "")?.traceId ??
  null;                                                    // kill switch / no context

// ...in <head>, beside the existing correlation meta:
<meta name={TRACE_ID_META_NAME} content={traceId ?? ""} />   // x-pramaan-trace-id

getCurrentTraceId() lives in the new observability module; it returns null for the all-zero (no-recording-span) id so the layout can fall back cleanly. The meta name (x-pramaan-trace-id) is a runtime-neutral constant in src/lib/trace.ts so the browser error reporter can read it without importing the server-only span helper.

2 — "Forced error shows real trace_id"

The browser client-error reporter now reads the x-pramaan-trace-id meta and ships it alongside correlation_id; the /api/internal/client-error route accepts a bounded, nullable trace_id field (Zod parse → HR-21) and logs it on the canonical pino plane. So a forced render error now carries the real X-Ray trace_id end to end — support can resolve the failing request in the X-Ray graph, not just by correlation id.

3 — The app manual-span helper + closed registry

withManualSpan is the only approved seam for T3 domain spans on this surface. Callers cannot get the contract wrong: the name is a closed enum, the SpanKind is fixed per name from the registry, and attributes are restricted to safe, low-cardinality domain keys.

// src/lib/observability/manual-span.ts  (server-only)
export const APP_MANUAL_SPAN = {
  MATTER_CREATE:        "app.matter.create",
  MATTER_OPEN:          "app.matter.open",
  AGENT_RUN_START:      "app.agent.run_start",
  BUG_REPORT_SUBMIT:    "app.bug_report.submit",
  CLIENT_ERROR_CAPTURE: "app.client_error.capture",
} as const;

await withManualSpan(
  APP_MANUAL_SPAN.MATTER_CREATE,
  { firm_id, matter_id },          // safe low-cardinality attrs only
  (span) => doTheWork(),           // OK/ERROR status + recordException + end handled
);
Span nameSpanKindRequired attrs (ADR-124 §D8)
app.matter.createINTERNALfirm_id, matter_id
app.matter.openINTERNALfirm_id, matter_id
app.agent.run_startINTERNALfirm_id, matter_id, run_id, agent.key
app.bug_report.submitINTERNALfirm_id, matter_id if present
app.client_error.captureINTERNALfirm_id, matter_id if known

Guardrail facts baked in: server/Node only (the server-only import keeps it out of edge/client bundles — no manual spans in proxy.ts, whose edge custom spans are not forwarded); no TracerProvider construction (the provider stays owned by instrumentation.ts); firm_id is a span attribute, never baggage (ADR-124 §D10) — this module writes no baggage; and this is the single file allowed to call raw tracer APIs.

4 — CI guardrail + semconv check

A new quality-job step enforces the doctrine in CI:

Verification green

Out of scope (later EPIC-1400 tickets)

Wiring withManualSpan onto each domain boundary (matter create/open, agent run-start, bug-report submit, the client-error capture route) is per-feature adoption that lands with each owning surface — this Story delivers the helper, the closed registry, and the trace_id surfacing. Existing API routes are covered by the @vercel/otel auto route span (T1 floor) until those adoptions land. The live staging X-Ray smoke (forcing a sampled request and confirming the app span renders in Transaction Search) is the EPIC-1400 acceptance gate, run once the exporter wiring ships.

Files changed

8 file(s), +383 / -0 lines

Path Role + -
.github/workflows/ci.ymlWorkflow+44-0
src/app/[locale]/layout.tsxApp route+16-0
src/app/api/internal/client-error/route.tsApp route+4-0
src/lib/client-error-reporter.tsLibrary+17-0
src/lib/observability/manual-span.tsLibrary+147-0
src/lib/trace.tsLibrary+10-0
tests/unit/lib/manual-span.test.tsTest+140-0
vitest.config.tsSource+5-0