`instrumentation.ts`: The Code That Runs Before Your First Request
Checkout starts returning 500s in production and your logs contain exactly one useful line. Your instinct is to sprinkle console.log through the handler and redeploy — except you would then be debugging a different build than the one that failed. Next.js anticipates this: it gives you one file whose entire job is to run once, before the server accepts traffic, so tracing and error capture exist from the first request instead of from the first incident.
// instrumentation.ts — project root, or src/ if you use one. Never inside app/
export async function register() {
// called once per server instance; must finish before the server is ready
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerOTel } = await import('@vercel/otel')
registerOTel('storefront')
}
}
Three properties of register() determine how you use it. It runs once per instance, so it owns one-time setup: tracing SDK, logger, cache client, and fail-fast checks. It must finish before the server goes ready, which makes it the right place to refuse to boot on bad configuration — throw in there and the load balancer never routes to the broken instance. And Next.js calls it in every runtime, which is why the NEXT_RUNTIME guard matters: a Node-only package imported at the top of the file can break your middleware or edge routes, so import conditionally and inside the function.
OpenTelemetry is the payoff. Next.js emits spans for rendering, data fetching, and middleware out of the box — the SDK is opt-in. Register one and your traces flow over OTLP to a collector, then to Jaeger, Grafana Tempo, Datadog, or whatever your team runs:
request → Next.js span (render / fetch / middleware)
→ your spans (db.query, stripe.charge)
→ OTLP collector → backend
The second export in the same file closes the loop on server-side failures:
import type { Instrumentation } from 'next'
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
const digest = typeof error === 'object' && error !== null && 'digest' in error
? String(error.digest)
: undefined
await tracker.capture({
message: error instanceof Error ? error.message : String(error),
digest, // identifies the original error
routePath: context.routePath, // /app/products/[slug]
routeType: context.routeType, // 'render' | 'route' | 'action' | 'proxy'
method: request.method,
path: request.path,
})
}
One function covers server component renders, route handlers, server actions, and middleware. Two details bite people: React may replace your original error during server rendering, so correlate on digest rather than the message; and any async work inside must be awaited, because the server treats the report as finished when the function returns.
Two practical knobs come with the SDK. Sampling decides how many traces you keep — start at 100% in staging and 1–10% in production, then raise it for a rate-limited deployment while you investigate; a tracer that records everything will hand you a bill instead of an answer. And instrument the expensive edges first: the database client, outbound fetch calls, and the payment SDK, because the question you will actually ask is "was this slow or broken because of Postgres, Stripe, or our render?" — and untraced spans are exactly where that answer hides.
The three signals stay distinct: traces show one request's path, metrics show aggregate rates and latencies, logs show discrete events with context. Instrumenting all three from register() is a morning of work; adding them after the first outage is a week.
Interview questions this answers directly: "How do you add tracing to a Next.js app?" → instrumentation.ts, a runtime guard, an OTel SDK with an exporter. "How do you capture every server-side error in one place?" → onRequestError, tagging each report with routePath and routeType so you can group by route.
From here the data problem changes shape: a trace shows one request's journey, but a user reports a failure across several requests, and stitching those together is the job of logs.
Structured Logs with Request IDs: Following One Order Across Four Processes
A buyer writes in: "I got an error at 14:32 trying to check out, order 8841." Your response depends entirely on how the last person to touch logging left things. With free-text console.log('cart error', err) scattered across four replicas, you are grepping timestamps and hoping clocks agree. With structured JSON and a request id, you paste one value into a query and get the whole story.
A single line should be machine-parseable and self-describing:
{"level":"info","time":"2026-10-14T14:32:07.412Z","msg":"action completed",
"requestId":"b7f1c2a9-4d3e-4a10-9a2b-51f0d0e77c44","route":"/checkout",
"routeType":"action","userId":"u_9182","durationMs":41,"release":"store@1f9c2ab"}
| Field | Why it earns its bytes |
|---|---|
requestId |
the join key across app logs, proxy logs, and downstream calls |
route + routeType |
groups failures by endpoint instead of by stack frame |
userId (or orderId) |
answers "who else hit this?" without a full-table scan |
durationMs |
turns a complaint into a p95 query |
release |
ties every line to the deploy that produced it |
The logger is configured once in register(), not per file:
import { pino } from 'pino'
export const log = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: { paths: ['*.password', '*.token', '*.authorization'], censor: '[redacted]' },
})
log.info({ requestId, route, durationMs }, 'action completed')
Fields go in the object, message last. LOG_LEVEL is a plain runtime variable, so staging can run at debug while production stays at info without a rebuild — the same per-environment mechanism covered in Managing Secrets Across Environments.
The request id comes from the edge of your system. Your proxy mints it (see the proxy_set_header X-Request-Id $request_id block in Self-Hosting with Standalone Output) and your middleware guarantees one exists:
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const headers = new Headers(request.headers)
const id = headers.get('x-request-id') ?? crypto.randomUUID()
headers.set('x-request-id', id)
const response = NextResponse.next({ request: { headers } })
response.headers.set('x-request-id', id) // echo it so users can quote it in a bug report
return response
}
Inside a server component or action, read it back with const id = (await headers()).get('x-request-id') and attach it to every line you write. One id, four systems: proxy access log, app log, trace span attribute, and the error report all carry it, so a support ticket collapses into a single query instead of a timeline reconstruction.
Two disciplines keep the stream useful. Never log secrets or cardholder data — tokens, Authorization headers, full request bodies, and especially process.env dumps — because log platforms retain for months and redaction at the collector is already too late. And keep metrics separate from logs: per-user labels on a Prometheus counter explode cardinality and cost real money, while a JSON line costs fractions of a mill.
Ship everything to stdout and let the platform collect it (Docker json-file, journald, Loki, CloudWatch, Datadog). Application code should never rotate files.
What you get in return is a query language instead of a text editor. During the buyer's incident:
{app="storefront", env="production"} | json | requestId="b7f1c2a9-4d3e-4a10-9a2b-51f0d0e77c44"
| line_format "{{.level}} {{.route}} {{.msg}}"
Four lines come back: the middleware that minted the id, the action that failed, the database timeout behind it, and the proxy's access entry — in order, with durations. Level discipline keeps this cheap: debug for local development, info for business events you would show a stakeholder (order.placed, action.completed), warn for recoverable trouble, error for failures a user felt. Alert on the last two with a threshold over time (say, level=error above 1% of requests for five minutes) rather than on any single line, or you will page someone about a bot hitting a bad URL.
Logs now tell you what happened to one request. They still cannot tell you what happened in the buyer's browser.
Browser Errors Never Reach Your Server Log: Instrumenting Both Sides
A user reports a blank screen. Your server logged a clean 200, the proxy agrees, and nothing appears in your error tracker. The crash happened after hydration, inside a click handler, in the only place your logs do not reach: the browser. Server and client failures land in different systems, and a production setup that instruments only one of them is blind to half the failure modes.
| Failure | Where it lands | Captured by |
|---|---|---|
| Server component / route handler / action throws | server stdout + onRequestError |
logs + server SDK |
| Client component throws during render | error.tsx boundary + console |
client SDK |
| Unhandled rejection or event handler crash | browser console only | client SDK |
| Hydration mismatch | browser console | client SDK |
| 404 / 500 / timeout responses | proxy access log | log pipeline or RUM |
A single vendor SDK covers both halves. npx @sentry/wizard -i nextjs scaffolds sentry.client.config.ts, sentry.server.config.ts, and sentry.edge.config.ts, then wires a build plugin that uploads source maps using SENTRY_AUTH_TOKEN. That last part is not optional: production bundles are minified, so without maps your stack traces read TypeError: cannot read properties of undefined (t.value). Source maps are uploaded at build time, which makes the auth token a build-environment secret — the same freeze-at-build rule as any other next build input.
Two tags do most of the organizational work:
release(the git SHA) — errors group by deploy, so "did 14:05 break it?" becomes a filter instead of a hypothesis, and a spike concentrated in one release is an automatic rollback signal.environment— staging noise never pages you, and a preview deployment's crashes stay out of the production dashboard.
What those two tags are worth depends entirely on symbolication. Without uploaded maps, the same failure arrives as this:
TypeError: Cannot read properties of undefined (reading 'map')
at t.value (static/chunks/app-8f3c21.js:1:48291) ← useless
# with maps uploaded at build:
at OrderList (app/account/orders/page.tsx:24:19) ← actionable
An issue you cannot assign to a file in ten seconds is an issue nobody fixes.
Server-side, onRequestError from the previous chapter already receives every unhandled request error, so your error.tsx boundary can stay focused on user experience:
'use client'
export default function CheckoutError({ error, reset }: { error: Error; reset: () => void }) {
return (
<section>
<h2>We could not complete that step</h2>
<button onClick={reset}>Try again</button>
</section>
)
}
Watch for double counting: a render error reaches both the boundary and onRequestError. Deduplicate with a stable fingerprint (route + digest) so one bug is one issue, not two.
Alerting follows from the tags. Group by release and route, page when new errors in production cross a threshold within ten minutes, and ignore the 404 crawl from bots. Pair the tracker with real-user monitoring so a deploy that makes the app slow shows up beside one that makes it crash — the two halves of Core Web Vitals and error health.
One boundary to keep clean: global-error.tsx replaces the root layout, so it must render its own <html> and <body> — a boundary that throws while rendering itself leaves the buyer staring at a blank document with no report anywhere. Keep it trivially simple, capture inside it, and let the client SDK's global handler act as the net underneath.
With builds verified, containers running, and both sides instrumented, every piece exists to assemble the release they were built for: Capstone: Production Storefront.
Quiz
1. When does the register() export of instrumentation.ts run?
2. A server action throws and you exported onRequestError from instrumentation.ts. What happens?
3. A buyer reports a failure and gives you the request id echoed in the response. What does it buy you?
4. Production stack traces resolve to minified frames like t.value. How do you fix that, and what does it require?
Flashcards
Question
What does instrumentation.ts give you, and where does it live?
Click to reveal answer
Answer
A project-root file (or src/) exporting register(), run once per server instance before traffic is accepted, plus onRequestError for unhandled server errors. Guard Node-only imports with process.env.NEXT_RUNTIME so edge routes keep building.
Question
What arguments does onRequestError receive and why does digest matter?
Click to reveal answer
Answer
(error, request, context) where context carries routePath, routeType (render | route | action | proxy), and routerKind. React can swap the original error during server rendering, so digest identifies the underlying error type for grouping.
Question
Which fields make a log line useful in an incident?
Click to reveal answer
Answer
requestId (join key), route + routeType, userId or orderId, durationMs, release (git SHA), level, and time — emitted as JSON to stdout with secrets redacted at the logger, because collectors retain data too long to rely on late redaction.
Question
Where do client-side failures land versus server-side failures?
Click to reveal answer
Answer
Server errors go to stdout and onRequestError; browser errors — hydration mismatches, event-handler crashes, unhandled rejections — go only to the console and a client SDK. error.tsx gives the user a recovery UI but reports nothing by itself.
Question
Why tag error reports with release and environment?
Click to reveal answer
Answer
release (git SHA) groups errors per deploy so a spike after rollout is an immediate rollback signal; environment keeps staging and preview noise out of production alerting. Pair with source maps uploaded at build so frames resolve to real function names.
Revision Notes
Key Takeaways
- 1. register() runs once before the instance is ready, so tracing, loggers, and fail-fast assertions belong there — behind a NEXT_RUNTIME guard
- 2. onRequestError is the single server-side catch point for render, route, action, and proxy errors; correlate on digest because React may replace the error
- 3. A request id minted at the edge and echoed to the client turns one support ticket into one query across proxy, app, and trace data
- 4. Browser failures never reach server logs — a client SDK with release and environment tags is mandatory, and error.tsx alone reports nothing
- 5. Source maps and NEXT_PUBLIC_ values share a rule: both are frozen at next build, so symbolication and build secrets live in the build environment
Interview Tips
- • "How do you add OpenTelemetry to a Next.js app?" — instrumentation.ts register(), NEXT_RUNTIME guard, SDK with an OTLP exporter; mention Next emits render/fetch/middleware spans automatically once a provider is registered
- • "How do you debug a failure reported by a single user?" — request id (or time + order id) as the join key, filter proxy and app logs, correlate to the trace, then check whether the release tag is new
- • "Where do you capture errors in an App Router app?" — onRequestError for server-side, client SDK for browser-side, error.tsx for user recovery, and a fingerprint so the same bug is not counted twice
- • "How would you know a deploy broke production?" — errors grouped by release with a rate threshold, health endpoint checks, and a rollback to the previous artifact; mention redaction so secrets never reach the tracker
Cheat Sheet
Logging and Error Tracking
Boot hooks (instrumentation.ts, project root):
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerOTel } = await import('@vercel/otel')
registerOTel('storefront')
}
}
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
// context.routeType: 'render' | 'route' | 'action' | 'proxy'
// context.routePath: /app/products/[slug] · correlate via error.digest
}
One log line:
{"level":"info","msg":"action completed","requestId":"b7f1...","route":"/checkout",
"routeType":"action","userId":"u_9182","durationMs":41,"release":"store@1f9c2ab"}
| Failure | Lands in | Captured by |
|---|---|---|
| Server render / route / action throws | stdout + onRequestError |
logs + server SDK |
| Client render throws | error.tsx + console |
client SDK |
| Unhandled rejection / handler crash | browser console | client SDK |
| 4xx / 5xx / timeouts | proxy access log | pipeline or RUM |
Rules: id minted at proxy/middleware and echoed to the user · redact at the logger · stdout only · tags release + environment · source maps uploaded at build · dedupe boundary + server reports by fingerprint.