DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

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

Headline: Next.js's instrumentation.ts file and its onRequestError hook give the App Router real distributed tracing and centralized error reporting, but Vercel Fluid Compute reusing one warm instance across concurrent requests means any request state stored outside OpenTelemetry's own context propagation — a module-level variable, a naive "current request" global — leaks between unrelated users' traces.

Key takeaways

  • register(), exported from instrumentation.ts, runs once when a server instance boots, not once per request — that's exactly where a tracing SDK belongs, and exactly why request-scoped state can't live there.
  • @vercel/otel's registerOTel() 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 — what the OpenTelemetry SDK already uses — keeps data scoped to a single request.
  • A trace, a structured log, and a metric answer different questions about the same request. Exporting only one of them leaves gaps the other two 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. It does not run per request, per route, or per render. Since Next.js 15 this is stable behavior with no experimental.instrumentationHook flag required, which trips people who copy setup snippets from Next.js 13 or 14 tutorials.

That single-run-per-instance timing is the whole reason this file exists: it's 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.

// instrumentation.ts
import { registerOTel } from '@vercel/otel';

export function register() {
  // Runs once when this server instance boots. Never per-request.
  registerOTel({ serviceName: 'my-app' });
}
Enter fullscreen mode Exit fullscreen mode

How do I wire up OpenTelemetry without hand-rolling the Node SDK?

@vercel/otel is a package that wraps the standard @opentelemetry/sdk-node setup — exporter selection, batch span processor configuration, and resource attributes like service name — behind a single registerOTel() call. Without it, wiring up the Node OpenTelemetry SDK by hand means picking a span processor, a context manager, and an exporter, and getting the instrumentation registration order right before any other module loads.

Set OTEL_EXPORTER_OTLP_ENDPOINT to point spans at a collector — Honeycomb, Axiom, Datadog, or a self-hosted OpenTelemetry Collector all accept OTLP. On Vercel specifically, once @vercel/otel is registered, the platform's own Observability tab picks up the same trace data automatically, which covers a lot of day-to-day debugging without standing up a separate backend.

The full Node OpenTelemetry SDK needs the Node.js runtime. That's rarely a real constraint now — Fluid Compute makes Node.js the practical default runtime on Vercel anyway, and the older edge runtime was never a good fit for a stateful SDK like this.

Why did spans leak across requests that had nothing to do with each other?

I had a small helper that stashed the current user's ID in a module-level variable, to avoid threading it through five layers of function calls just to attach it to a log line. It worked in local development, where next dev rarely has more than one request in flight against the same process. In production, under real concurrent traffic, it produced log lines and trace attributes with the wrong user's ID attached — request A's logs carrying request B's identity.

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's good for cold-start latency, and it's exactly why a plain module-level variable is dangerous — it's shared mutable state across every request currently running on that instance, and two requests interleaving on the same instance will stomp on each other's value.

// Do not do this on Fluid Compute — or any environment that
// reuses one process across concurrent requests.
let currentUserId: string | undefined;

export function setUser(id: string) {
  currentUserId = id; // shared across every request on this instance
}
Enter fullscreen mode Exit fullscreen mode

The fix is AsyncLocalStorage, a Node.js primitive that scopes a value to the current async call chain rather than to the module. It's 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 you passing a request object everywhere.

import { AsyncLocalStorage } from 'node:async_hooks';

type RequestContext = { userId: string; requestId: string };
const requestContext = new AsyncLocalStorage<RequestContext>();

export function withRequestContext<T>(ctx: RequestContext, fn: () => T): T {
  return requestContext.run(ctx, fn);
}

export function currentUserId() {
  // Scoped to this async call chain, not shared across concurrent requests.
  return requestContext.getStore()?.userId;
}
Enter fullscreen mode Exit fullscreen mode

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 path, method, and headers, and a context object with routerKind, routePath, and routeType (render, route, action, or middleware). That last piece is what a scattered set of try/catch blocks never gives you for free: an error boundary inside one component only knows about its own subtree, not which route it belongs to or whether it happened during a render or a Server Action.

// instrumentation.ts
export async function onRequestError(
  error: unknown,
  request: { path: string; method: string; headers: Record<string, string> },
  context: {
    routerKind: 'Pages Router' | 'App Router';
    routePath: string;
    routeType: 'render' | 'route' | 'action' | 'middleware';
  },
) {
  await reportToErrorTracker(error, {
    route: context.routePath,
    kind: context.routeType,
    path: request.path,
  });
}
Enter fullscreen mode Exit fullscreen mode

I moved every ad-hoc console.error I had scattered across route handlers into this one hook. Nothing else in the app decides how errors get reported anymore.

How do I connect a trace to the log line that explains what happened?

A span ID by itself doesn't help whoever is reading a log at 2 a.m. — the log has to carry the same trace ID as the request that produced it. Vercel's Runtime Logs capture console.log output automatically, but they don't correlate a log line to a trace unless you attach the IDs yourself.

import { trace, context } from '@opentelemetry/api';
import pino from 'pino';

const baseLogger = pino();

export function requestLogger() {
  const spanContext = trace.getSpan(context.active())?.spanContext();
  return baseLogger.child({
    trace_id: spanContext?.traceId,
    span_id: spanContext?.spanId,
  });
}
Enter fullscreen mode Exit fullscreen mode

trace.getSpan(context.active()) reads the span attached to the currently active OpenTelemetry context — the same context that AsyncLocalStorage propagates — so this works correctly under concurrent requests for the same reason the fix above does.

Traces vs structured logs vs metrics — what should I actually export?

Each signal answers a different question, and none of them substitutes for the others.

Signal Answers Use it for
Trace Where did the time go inside this one request? A waterfall across a database call, an external API, and a render — spotting the one slow span
Structured log What happened, with what data, in this one request? Specific error payloads, business events, anything you'd grep for by request ID
Metric How often, across every request? Dashboards and alerting thresholds — error rate, p95 latency, request volume

I export all three now, and the trace ID is the thing that ties a spike on a metric dashboard back to the specific log line and the specific span that explains it.

FAQ

Q: Do I 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. 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's 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's for reporting, not for rendering.

Q: Can I use a module-level variable instead of AsyncLocalStorage to pass request context around?
A: Only if the instance running your 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 I still need a separate observability vendor if I'm 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.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)