Observability in the Next.js App Router: Field Notes on instrumentation.ts, onRequestError, and the Trace That Leaked Across Requests
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
register(), exported from instrumentation.ts, runs once when a server instance boots — not once per request. That is exactly where we initialize a tracing SDK, and exactly why request-scoped state cannot live there.
The @vercel/otel package's registerOTel() function wraps the Node OpenTelemetry SDK with sane defaults for exporters and span processors, cutting most of the boilerplate a manual setup needs.
onRequestError, stable since Next.js 15, catches errors from Server Components, Route Handlers, Server Actions, and Middleware before Next.js renders its own error response. A component-level try/catch only ever sees its own subtree.
Fluid Compute reuses one function instance across concurrent requests, so a module-scope 'current trace' variable becomes shared mutable state. Only AsyncLocalStorage-based context keeps data scoped to a single request.
A trace, a structured log, and a metric answer different questions about the same request. We export all three, because relying on just one leaves gaps the others would have closed.
What does instrumentation.ts actually run, and when?
instrumentation.ts is a file at the project root, or inside src/, that Next.js loads before any other application module. Its register() export runs exactly once, when a new server instance starts up — not per request, per route, or per render. Since Next.js 15 this is stable behavior with no experimental flag required, which we learned the hard way after copying a setup snippet written for an older version.
That single-run-per-instance timing is the whole reason the file exists: it is the correct place to initialize a tracing SDK, open a long-lived connection, or read one-time configuration, and the wrong place to store anything specific to the request currently in flight.
How do we wire up OpenTelemetry without hand-rolling the Node SDK?
@vercel/otel wraps the standard Node OpenTelemetry SDK setup — exporter selection, batch span processor configuration, and resource attributes like service name — behind a single registerOTel() call. Without it, wiring up the SDK by hand means choosing a span processor, a context manager, and an exporter, and getting the registration order right before any other module loads.
Setting the OTEL_EXPORTER_OTLP_ENDPOINT environment variable points spans at a collector — Honeycomb, Axiom, Datadog, or a self-hosted OpenTelemetry Collector all accept OTLP. On Vercel, once @vercel/otel is registered, the platform's own Observability tab picks up the same trace data automatically, which covers a lot of our day-to-day debugging without standing up a separate backend.
The full Node OpenTelemetry SDK needs the Node.js runtime. That is rarely a real constraint for us now — Fluid Compute makes Node.js the practical default runtime on Vercel, and the older edge runtime was never a good fit for a stateful SDK like this one.
Why did spans leak across requests that had nothing to do with each other?
We had a small helper that stashed the current user's ID in a module-level variable, to avoid threading it through several layers of function calls just to attach it to a log line. It worked in local development, where a single dev process rarely has more than one request in flight. In production, under real concurrent traffic, it produced log lines and trace attributes carrying the wrong user's ID.
The cause is Fluid Compute's core behavior: it reuses one warm function instance to serve many concurrent requests instead of spinning up a fresh instance per request. That is good for cold-start latency, and it is exactly why a plain module-level variable is dangerous on it — the variable is shared mutable state across every request currently running on that instance, and two interleaving requests will overwrite each other's value.
The fix is AsyncLocalStorage, a Node.js primitive that scopes a value to the current async call chain rather than to the module. It is the same mechanism OpenTelemetry's own context manager is built on, and the same reason Next.js's own headers() and cookies() functions can be request-scoped without a request object being passed everywhere.
What does onRequestError catch that a component-level try/catch can't?
onRequestError is an optional export from instrumentation.ts, stable since Next.js 15. Next.js calls it whenever it catches an unhandled error while rendering a Server Component, executing a Route Handler, running a Server Action, or inside Middleware, before it renders its own error boundary or returns a 500 response to the client.
It receives the error, a request object with the path, method, and headers, and a context object describing the router kind, the route path, and the route type — render, route, action, or middleware. That last piece is what a scattered set of try/catch blocks never gives us for free: an error boundary inside one component only knows about its own subtree, not which route it belongs to or whether the error happened during a render or a Server Action.
We moved every ad-hoc error log we had scattered across route handlers into this one hook. Nothing else in the codebase decides how errors get reported anymore.
How do we connect a trace to the log line that explains what happened?
A span ID by itself does not help whoever is reading a log during an incident — the log has to carry the same trace ID as the request that produced it. Vercel's Runtime Logs capture console output automatically, but they do not correlate a log line to a trace unless the IDs are attached explicitly.
We read the span attached to the currently active OpenTelemetry context and attach its trace ID and span ID to every log line a request emits, using a small logger wrapper built on pino. Because that context is the same one AsyncLocalStorage propagates, this stays correct under concurrent requests for the same reason the fix above does.
Traces vs structured logs vs metrics — what should we actually export?
Each signal answers a different question, and none of them substitutes for the others. A trace shows where the time went inside one request — a waterfall across a database call, an external API, and a render — and is what we reach for to spot the one slow span. A structured log shows what happened, with what data, in one request, and is what we grep by request ID during an incident. A metric shows how often something happens across every request, and is what feeds our dashboards and alert thresholds.
We export all three now, and the trace ID is what ties a spike on a metric dashboard back to the specific log line and the specific span that explains it.
FAQ
**Q:** Do we need the experimental.instrumentationHook flag for instrumentation.ts in Next.js 16?
**A:** No. The instrumentation.ts file and its register() export have been stable since Next.js 15. The experimental flag from Next.js 13 and 14 no longer exists.
**Q:** Does instrumentation.ts run on the Edge runtime?
**A:** Only a subset of it does. The full Node OpenTelemetry SDK needs the Node.js runtime, which is the practical default on Vercel now that Fluid Compute makes Node.js the standard choice.
**Q:** What is the difference between onRequestError and a React error.tsx boundary?
**A:** An error.tsx boundary catches errors within its own component subtree and renders fallback UI for the user. onRequestError is a framework-level hook that fires for errors across Server Components, Route Handlers, Server Actions, and Middleware regardless of any particular boundary — it is for reporting, not for rendering.
**Q:** Can we use a module-level variable instead of AsyncLocalStorage to pass request context around?
**A:** Only if the instance running the code never serves more than one request at a time. Fluid Compute reuses instances across concurrent requests, so a module-level variable becomes shared mutable state and will leak data between unrelated requests.
**Q:** Do we still need a separate observability vendor if we are on Vercel?
**A:** Vercel's Observability tab shows traces automatically once @vercel/otel is registered, which covers a lot of routine debugging. Longer retention, cross-service correlation, or custom alerting usually still means exporting via OTLP to a dedicated backend.
Further Reading
Full-Stack Engineering
Database Connections in Serverless: Pool Math, PgBouncer Transaction Mode, and the Flags That Survive It
A connection pool in a serverless deployment is per-instance, not per-application, so the real ceiling is concurrent instances multiplied by pool size. Our team's field notes on why PostgreSQL starts rejecting clients, what Fluid Compute changed, and the driver settings that survive transaction-mode pooling.
Full-Stack Engineering
Environment Variables in Next.js: Build-Time Inlining, Real Leak Paths, and Failing the Build with Zod
A NEXT_PUBLIC_ variable is not read at runtime — Next.js inlines it into the bundle at build time. Our field notes on the three ways this bites production apps: stale values that survive redeploys, the real paths a server secret takes to the browser, and a Zod schema that fails the build instead of the first request.