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 name | SpanKind | Required attrs (ADR-124 §D8) |
|---|---|---|
app.matter.create | INTERNAL | firm_id, matter_id |
app.matter.open | INTERNAL | firm_id, matter_id |
app.agent.run_start | INTERNAL | firm_id, matter_id, run_id, agent.key |
app.bug_report.submit | INTERNAL | firm_id, matter_id if present |
app.client_error.capture | INTERNAL | firm_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:
- Raw-tracer gate:
startActiveSpan/start_span/tracer.*are rejected anywhere insrcexcept the approved helper (manual-span.ts) and provider (instrumentation.ts) files (ADR-124 §D8 / observability-guardrail.md). - semconv check: the five
app.*names must match the closed registry inmanual-span.tsexactly; a hand-writtenapp.*.*span name outside the helper fails the build — no drift, no ad-hoc names.
Verification green
pnpm typecheck— clean.pnpm lint— clean (1 pre-existingno-img-elementwarning inavatar.tsx, unrelated).pnpm test— 540 passed (95 files); newtests/unit/lib/manual-span.test.tscovers the closed registry, async run, ERROR-path rethrow +recordException+end, attribute filtering, andgetCurrentTraceId(null / present / all-zero).pnpm build— pass;/api/internal/client-errorcompiles with the newtrace_idfield.- Raw-tracer guardrail grep — clean on
main; new step gates regressions.
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.yml | Workflow | +44 | -0 |
| src/app/[locale]/layout.tsx | App route | +16 | -0 |
| src/app/api/internal/client-error/route.ts | App route | +4 | -0 |
| src/lib/client-error-reporter.ts | Library | +17 | -0 |
| src/lib/observability/manual-span.ts | Library | +147 | -0 |
| src/lib/trace.ts | Library | +10 | -0 |
| tests/unit/lib/manual-span.test.ts | Test | +140 | -0 |
| vitest.config.ts | Source | +5 | -0 |