Module: observability
OpenTelemetry for the tRPC API served by apps/web, contract-first: the
span-per-procedure CONTRACT ships as a running test immediately (one server span
per procedure, named from the tRPC PROCEDURE PATH, low-cardinality), and the
Next.js instrumentation.ts + OTLP exporter wiring ships as a documented patch
you apply when you have decided where traces go. An OTLP endpoint is a runtime
dependency and a data-egress decision — made deliberately, not defaulted.
The API is the framework-neutral @app/api router mounted at
apps/web/app/api/trpc/[trpc]/route.ts; the server span is created by the
Next/tRPC layer, and the span-NAME manifest is pinned against the router itself.
Structured logging and span timing already live in @app/observability
(packages/platform/observability) — a redacting, vendor-neutral seam. This
module is what attaches OTLP export to that surface without teaching the seam a
vendor SDK.
What it adds
Section titled “What it adds”| File | Purpose |
|---|---|
packages/api/src/observability/span-routes.test.ts |
span-name manifest derived from the REAL tRPC router (appRouter._def.procedures) + low-cardinality check + it.todo seams for the SDK-backed asserts |
docs/modules/observability/otel-server.patch.md |
apps/web/instrumentation.ts wiring (via @vercel/otel, or the @opentelemetry/sdk-node register hook), a per-procedure tracing middleware, the OTLP env contract, and the seam-activation guide |
Prerequisites
Section titled “Prerequisites”- None for the shipped test (it runs in the default vitest lane immediately —
@app/apialready depends on vitest, and the file type-checks against the realappRouter). - For the wiring: an OTLP/HTTP collector you operate (on-prem doctrine), then the patch's install list — pinned through the workspace catalog like everything else.
How enabling works
Section titled “How enabling works”npx next-expo-supabase-agent-harness@latest enable observabilitycopies the files; the manifest tests join pnpm exec vitest run (and therefore
the Stop hook and CI) at once. Apply
docs/modules/observability/otel-server.patch.md when the collector exists, then
convert the two it.todo seams into real assertions (the patch shows how, with
an InMemorySpanExporter and createCallerFactory(appRouter)). No
tools/harness.config.mjs change.
How its gate can FAIL (anti-vacuity)
Section titled “How its gate can FAIL (anti-vacuity)”- Today: add any procedure to a router under
packages/api/src/routers/(or a new router toappRouter) without touching the test → the manifest expectation fails in the same PR. That friction is the contract: a procedure cannot ship without its span name being reviewed. - Today: name a procedure with a resolved-looking segment (a numeric or uuid key) → the low-cardinality check fails.
- After wiring: remove the
register()body fromapps/web/instrumentation.ts(or comment out the tracing middleware) → the activated one-span-per-call assert fails with zero spans; drop the log-correlation wiring → the correlation assert fails (notrace_idon request-scoped@app/observabilityrecords).
Honest limits
Section titled “Honest limits”- Until the patch is applied, the
it.todoseams are visible-but-inert — vitest reports them as todo on every run, so the unfinished half stays loud. They are reviewed escapes intools/test-quality-allow.json, keyed by the file's new path. - Span coverage is asserted for the API surface (the tRPC procedures
apps/webserves). The mobile side (the Expo app) is out of scope here — trace context does not cross the HTTPS boundary in the scaffold: the mobile tRPC client (apps/mobile/src/lib/trpc/client.ts) sends notraceparent. If you need end-to-end traces, propagatetraceparentthrough that client's headers and add it to the accepted headers on the web route handler, then note it in the patch. - This module exports traces, not logs.
@app/observabilitystays the logging seam; correlation is the bridge (atrace_idon each record), not a second log transport.