Instrumentation and observability
See what production is doing: instrumentation.ts and register(), OpenTelemetry spans, onRequestError reports, client instrumentation and safe JSON logs.
- 01Set up instrumentationUse instrumentation.ts and register() to start OpenTelemetry once per server, and read the spans Next.js makes for each request.
- 02Report errorsSend each error Next.js catches on the server, with its digest and route, from onRequestError, and the browser's from instrumentation-client.ts.
- 03Log safelyWrite one JSON line per event with a request id from proxy.ts, and keep cookies, secrets and personal data out of logs.
Watching a running shop
On your computer, next dev shows you every error as it happens (Debugging reads them). In production nobody watches the screen: the shop runs on a server, for visitors you never see. Observability is being able to tell what that running app is doing from what it sends out: logs, traces and error reports. Instrumentation is the code that sends them.
Next.js has one place for that code on the server: a file called instrumentation.ts at the project's root, next to app. It can export a function called register, which Next.js calls itself. Here is the tea shop with the smallest one possible. This is a recording of a real Next.js 16.4.3 project, built with next build, run with next start, and asked for three pages. Open the Terminal tab.
instrumentation.tsexport function register() { console.log(`register() ran in the ${process.env.NEXT_RUNTIME} runtime`);}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ◐ /teas ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Readyregister() ran in the nodejs runtimeTeaList rendered 2 teasTeaList rendered 2 teasLine 2 of instrumentation.ts prints which runtime it runs in. In the Terminal tab, the output of next build has no such line. Under $ next start, the line register() ran in the nodejs runtime comes once, right after ✓ Ready and before anything about a request. Then the server answered /, the home page, which was prerendered, and /teas twice: each visit printed TeaList rendered 2 teas, but register() never ran again.
next start calls register() once, when the server starts, and only then answers requests. Each request after that runs your pages and Route Handlers, not register().Before the doors open, the shop switches on its cameras and its till, once for the whole day. After that, every customer gets a receipt listing what they bought. When a cup breaks, someone writes an incident report with the time and the table. And a greeter at the door notes who came in and where they went.
- In real life: Switching on the cameras and the till once, before the doors open
- In Next.js:
register(), called once when the server starts - In real life: A receipt for each customer, with every item on it
- In Next.js: A trace for each request, with a span for each part of the work
- In real life: An incident report when a cup breaks
- In Next.js:
onRequestError, called for each error Next.js catches on the server - In real life: A greeter at the door who notes each guest who comes in
- In Next.js:
instrumentation-client.ts, in each visitor's browser
instrumentation.ts is where a Next.js server sets up its observability: register() runs once when a server starts, and onRequestError runs for each error Next.js catches on the server. instrumentation-client.ts does the same job in the browser. What they send (logs, traces, error reports) is how you see what production is doing.
instrumentation.ts and register()
A few rules about the file, from the recording and from the instrumentation.js reference on nextjs.org:
- Where it goes. In the project's root, beside
app, or insidesrcif the project uses one. Never insideapp: there it would be just another file. - When it runs. Once for each new server, and the docs say it must finish before that server handles requests. The recording shows the server-start part: one line, before any request's line. A server here means one running copy of the app. Two containers behind a load balancer each call
register()once, and a serverless platform calls it in each new instance it starts. - It can be async.
registermay return a promise, for setup that waits, like loading a library withimport(). - Which runtime. Next.js has two server runtimes: Node.js, which runs pages and Route Handlers by default, and the Edge runtime, a smaller one. The same file is used for both, so
process.env.NEXT_RUNTIMEsays which one is calling:nodejsin the recording.
When the two runtimes need different setup, load each from its own file. This is the docs' pattern, not recorded:
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./instrumentation.node");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./instrumentation.edge");
}
}
Line 2 checks for Node.js and line 3 loads instrumentation.node.ts only there, so a library that needs Node.js never reaches the Edge runtime. Lines 5 and 6 do the same for the Edge runtime.
Traces and spans with OpenTelemetry
A log line says one thing happened. A trace shows how one request went, step by step. It's made of spans: each span is one piece of work with a name, a start and an end. Spans sit inside other spans, and the first one, the root span, lasts the whole request. An exporter sends finished spans to a tracing service, which draws them for you.
GET /teas, lasts the whole request. Each span inside it is a part of the work: rendering the route, and inside the render, the shop's own load teas from the database. Bars are not to scale.OpenTelemetry is the open standard for traces, logs and metrics, so you can change the service you send them to without changing your code. Next.js already makes spans for its own work. You turn them on by starting OpenTelemetry in register(). Next.js's guide recommends the @vercel/otel package for that (2.1.4 on npm on October 30, 2026). It works on any server, not only on Vercel. These are the guide's install command and one more for this example:
# From Next.js's OpenTelemetry guide
npm install @vercel/otel @opentelemetry/sdk-logs @opentelemetry/api-logs @opentelemetry/instrumentation
# For spans of your own, and the types print-traces.ts uses
npm install @opentelemetry/api @opentelemetry/sdk-trace-baseThe first command is the guide's. The second adds @opentelemetry/api, for spans of your own, and @opentelemetry/sdk-trace-base, for the types the example uses. The example's package.json lists the versions npm installed. A real app would send its spans to a tracing service; this one prints each request's trace in the terminal instead, with print-traces.ts, so you can read them. Read instrumentation.ts and print-traces.ts, then the Terminal tab.
instrumentation.tsimport { registerOTel } from "@vercel/otel";import { printTraces } from "./print-traces"; export function register() { registerOTel({ serviceName: "tea-shop", spanProcessors: [printTraces] });}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (6/6) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/printed├ ƒ /api/teas└ ◐ /teas ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ ReadyGET / (200) resolve page components load route module prepare route moduleTeaList rendered 2 teasGET /teas (200) resolve page components load route module prepare route module render route (app) /teas build component tree resolve segment modules resolve segment modules load teas from the database build component tree resolve segment modules resolve segment modules start response NextNodeServer.clientComponentLoadingGET /api/teas (200) resolve page components load route module prepare route module load app route module executing api route (app) /api/teas load teas from the database start responseLine 5 of instrumentation.ts calls registerOTel with a name for the service and one span processor: the code OpenTelemetry hands each span to when it ends. In print-traces.ts, onEnd keeps each trace's spans by their trace id (lines 12 to 14). When the root span ends, the one with no parent (line 15), and it's a request's (line 17), print writes the tree on line 19: each span's name, indented under the span it ran inside, in the order they started. This processor handles each span the moment it ends, which suits a terminal. A real setup hands spans to an exporter in batches, which @vercel/otel does when you leave spanProcessors out.
A root span ends a moment after its answer has gone out, so line 20 counts each printed tree, and app/api/printed/route.ts answers once the count it's asked for is reached (line 18 leaves that route's own trace out). The recording asks it for 3 last, so all three trees are in the output before the recording ends. It's a stand-in for the recording; your tracing service needs nothing like it.
In app/_data/teas.ts, the shop makes a span of its own. Line 4 gets a tracer named tea-shop, and line 9 starts a span called load teas from the database. Line 12 adds an attribute, teas.count, and line 15 ends it. Because the page called getTeas while it rendered, the span landed inside the render.
The Terminal tab shows three trees. GET / (200), the prerendered home page, is short: Next.js found the route and sent the saved page. GET /teas (200) has render route (app) /teas, with the shop's own span inside it. GET /api/teas (200), the Route Handler, has executing api route (app) /api/teas instead, with the shop's span inside that. Explore them below:
The whole request, from when it arrived to when its answer finished. Next.js names it after the method and the route, and print-traces.ts adds the status.
Next.js names the root span [method] [route], with the route pattern, so /teas/mint and /teas/green would both be GET /teas/[id]. Its guide lists the spans Next.js makes by default, and names two environment variables: NEXT_OTEL_VERBOSE=1 for more spans, and NEXT_OTEL_FETCH_DISABLED=1 to turn off the span Next.js makes for each fetch in your code.
To send the spans to a real service, run an OpenTelemetry collector, a program that receives spans and passes them on, or use your tracing service's address. @vercel/otel reads the standard environment variables. Leave spanProcessors out, or start it with "auto", so its own export runs:
# Where @vercel/otel sends the spans: an OpenTelemetry collector, over OTLP
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.internal:4318
OTEL_SERVICE_NAME=tea-shop
# Next.js: more spans than the default set, or no span for each fetch
NEXT_OTEL_VERBOSE=1
NEXT_OTEL_FETCH_DISABLED=1Line 2 is the collector's address (4318 is OTLP's usual port for HTTP), and line 3 names the service, instead of the name in registerOTel. Lines 6 and 7 are Next.js's switches. These come from the docs, not from a recording.
Reporting server errors with onRequestError
Traces show how requests went. For errors, instrumentation.ts has a second export: onRequestError. Next.js calls it for each error it catches on the server while it handles a request: while rendering, in a Route Handler, in a Server Action or in proxy.ts. An error your own code catches and handles never reaches it. It gets three things: the error, the request (path, method and headers) and the context (which router, which route, and what kind of work failed). Here it reports each error as one line of JSON, the text format that log tools read (JSON). Both /orders and the Route Handler /api/orders call a getOrders that throws.
instrumentation.tsimport type { Instrumentation } from "next"; export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => { const report = { message: error instanceof Error ? error.message : String(error), digest: typeof error === "object" && error !== null && "digest" in error ? String(error.digest) : undefined, method: request.method, context, }; // A real app sends the report to its error tracker here, and awaits it. console.log(JSON.stringify(report));};$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/orders└ ◐ /orders ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready⨯ Error: The orders database did not answer. at e (.next/server/chunks/ssr/_090-87abj0x4l._.js:1:118) at async f (.next/server/chunks/ssr/_090-87abj0x4l._.js:1:188) { digest: '3549031975'}{"message":"The orders database did not answer.","digest":"3549031975","method":"GET","context":{"routerKind":"App Router","routePath":"/orders","routeType":"render","renderSource":"react-server-components"}}⨯ Error: The orders database did not answer. at r (.next/server/chunks/[root-of-the-server]__0vw7ijbc6l9p6._.js:1:95) at async n (.next/server/chunks/[root-of-the-server]__0vw7ijbc6l9p6._.js:1:180){"message":"The orders database did not answer.","method":"GET","context":{"routerKind":"App Router","routePath":"/api/orders","routeType":"route"}}Lines 5 and 6 read the message and the digest. The error is typed unknown, so the code checks it first. The docs say it may not be the error your code threw, because React can process a Server Component's error, and that its digest identifies it. Lines 7 and 8 add the method and the context, whose routePath is the route's pattern, like /orders. The report leaves out request.path on purpose: that's the URL as it was asked for, and its query string can hold a token or someone's email address. Line 11 prints the report. In a real app, that line sends the report to an error-tracking service, and the docs say to await it, so the report goes out before Next.js moves on.
In the Terminal tab, the page's error comes twice: Next.js's own ⨯ Error with digest: '3549031975', and then the report, with the same digest. The Route Handler's error comes the same way, but its report has no digest. Each row compares the two reports:
| Question | The page, /orders | The Route Handler, /api/orders |
|---|---|---|
| Where it was thrown | OrderList, a Server Component on /orders. | The GET function of the Route Handler /api/orders. |
| What the visitor got | 200, the page's shell, and an error screen drawn in the browser. | 500, with an empty body. |
digest | '3549031975', the same number next start printed. | None: a Route Handler's error has no digest. |
context.routeType | render | route |
context.renderSource | react-server-components | Not there: nothing was rendered. |
OrderList throws, Next.js prints the error with its digest, and calls onRequestError with the same error. Your report carries the digest and the route's context to the service that collects errors.The docs give the other values: routeType is action for a Server Action and proxy for proxy.ts, and revalidateReason says whether the error happened while a page was revalidated (on-demand or stale). The docs also list a renderType field, but it isn't in Next.js 16.4.3's type for the context, or in this recording. A visitor's screen shows the digest (Error handling), so the digest in the report is how a support ticket leads to the error.
instrumentation-client.ts in the browser
The server's file can't see what happens in the visitor's browser. For that, Next.js loads instrumentation-client.ts, from the project's root, into every page. You don't export a function for setup: the file's top-level code runs. The docs say it runs after the HTML has loaded and before React hydrates the page, so it can catch errors from the very start. It can also export onRouterTransitionStart, which Next.js calls at the start of each client-side navigation (Linking and navigating). This one was recorded in real Chrome:
instrumentation-client.ts// Runs in the browser once the HTML has loaded, before React hydrates the page.console.log("instrumentation-client.ts ran");performance.mark("tea-shop:start"); function report(message: string) { const body = JSON.stringify({ message: message.slice(0, 500), path: location.pathname }); fetch("/api/client-errors", { method: "POST", body, keepalive: true });} // Errors nobody caught, and promises that failed with nobody waiting for them.window.addEventListener("error", (event) => report(event.message));window.addEventListener("unhandledrejection", (event) => report(String(event.reason))); export function onRouterTransitionStart(url: string, navigationType: "push" | "replace" | "traverse") { console.log(`navigation to ${url} (${navigationType})`);}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh tea, sent to your door.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Everything Chrome's console printed during this step (2)
[log] instrumentation-client.ts ran[log] Welcome hydrated
Requests the page made during this step (3), without the files it loaded
GET/200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
Line 2 logs that the file ran, and line 3 marks the moment in the browser's performance timeline. Lines 5 to 8 send a short report to /api/client-errors, a Route Handler that logs it on the server; keepalive lets the request finish even if the visitor leaves the page. Line 11 reports errors nobody caught. The error event doesn't see a promise that failed with nobody waiting for it, so line 12 listens for unhandledrejection too. Lines 14 to 16 log each navigation. Pick each step in the Steps tab. Each row of the table is one step, with what it printed in the browser and on the server:
| Step | In Chrome's console | On the server |
|---|---|---|
Open / | instrumentation-client.ts ran, then Welcome hydrated. | Nothing. |
| Click the Teas link | navigation to /teas (push), from onRouterTransitionStart. | Nothing. |
| Click Brew Mint tea | The error, and a POST /api/client-errors that answered 204. | The report: {"level":"error","source":"browser",…}. |
| Click Check stock | The failed promise, and a second POST /api/client-errors that answered 204. | Its report, with Error: The stock service did not answer (Mint tea). |
In step 1, instrumentation-client.ts ran comes before Welcome hydrated, which the Welcome component logs from an Effect, once React has hydrated it. In step 3, the Brew button's click handler throws, and in step 5, the Check stock button starts a promise that fails. Each one is reported, and the Terminal tab shows both reports under $ next start.
Anyone can call /api/client-errors, not only your pages, so open app/api/client-errors/route.ts. Line 4 accepts two short texts and nothing else. Lines 7 to 9 refuse a body over 2,000 characters with 413, before and after reading it. Line 17 logs only the approved fields, with the server's own level and source, so a report can't change them. Before the browser steps, the recorder sent two reports the way another program could: one with a level and source of its own, logged as "level":"error","source":"browser" anyway, and one of 3,000 characters, refused with 413 and never logged. Anyone can still send a false report, so treat browser reports as hints, and limit how many one visitor can send (rate limiting), or keep only a sample of them. Keep this file small: the docs say Next.js warns in development when it takes more than 16 milliseconds, because the page waits for it. For measuring how fast pages are for real visitors, Core Web Vitals covers useReportWebVitals.
Logs in production
In production, console.log writes to the server's output, and the platform that runs the server collects it: the container's logs, or the hosting service's log page. Two habits make those logs useful:
- Structured logs. Write one JSON object per line, with named fields, instead of a sentence. A log service can then search and count by any field: every
error, every line about/orders. - A request id. Give each request an id, and put it on every line about that request. Then all the lines of one request, from different files, can be found together.
proxy.ts runs before every route, so it's where the id is made. Here the shop logs with a small helper, log, and its onRequestError writes the same kind of line:
app/_lib/log.tsimport { headers } from "next/headers"; type Fields = Record<string, string | number | boolean>; // One JSON object per line, so a log service can search every field.export async function log(level: "info" | "warn" | "error", message: string, fields: Fields = {}) { const requestId = (await headers()).get("x-request-id") ?? "none"; console.log(JSON.stringify({ level, message, requestId, ...fields }));}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (6/6) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/orders├ ◐ /orders└ ◐ /teas ƒ Proxy (Middleware) ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready{"level":"info","message":"teas loaded","requestId":"lb-5e1d","count":2}TeaList rendered 2 teas⨯ Error: The orders database did not answer. at e (.next/server/chunks/ssr/[root-of-the-server]__1cao97s_-q9wk._.js:1:1619) at async f (.next/server/chunks/ssr/[root-of-the-server]__1cao97s_-q9wk._.js:1:1689) { digest: '734150183'}{"level":"error","message":"The orders database did not answer.","requestId":"lb-5e1e","route":"/orders"}{"level":"info","message":"teas loaded","requestId":"45f2ce1c-441b-465f-b73d-0c0804642a3b","count":2}TeaList rendered 2 teasIn app/_lib/log.ts, line 7 reads the request's x-request-id header with headers(), and line 8 prints one JSON object with the level, the message, the id and any other fields. In proxy.ts, line 6 reads an id the load balancer in front set, and line 7 keeps it only if it's a short id of letters, digits, _ and -; otherwise it makes one with crypto.randomUUID(). Lines 8 to 10 pass it to the route, as proxy.ts showed, and line 11 sends it back to the browser.
Keeping an incoming id is only safe when a proxy you control, like this shop's load balancer, replaces x-request-id on every request, as lines 4 and 5 say. Otherwise a visitor could send any id they like, and their lines would pretend to belong to someone else's request: always make your own with crypto.randomUUID().
The Terminal tab has the lines: {"level":"info","message":"teas loaded","requestId":"lb-5e1d","count":2} for the first request, the error report for /orders with "requestId":"lb-5e1e", and for the request without an id, a new random one. The Network tab shows each response's x-request-id, so a visitor can quote it, and you can find every line about their request.
proxy.ts gives the request its id. The error report and the response's x-request-id header carry the same id, so a support ticket, a log line and an error report about one request can be matched.The lines have no time in them: the platform that collects the output stamps each line as it arrives. If yours doesn't, add a time field with new Date().toISOString().
A log is read by many people and tools, and kept for a long time, so what goes in it matters as much as what comes out. Compare two versions of the shop's error report, for the same request from Asha, who is signed in:
instrumentation.tsNext.jsinstrumentation.tsimport type { Instrumentation } from "next"; export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => { console.log(JSON.stringify({ message: String(error), route: context.routePath, headers: request.headers }));};$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/orders└ ◐ /orders ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready⨯ Error: The orders database did not answer. at e (.next/server/chunks/ssr/_090-87abj0x4l._.js:1:118) at async f (.next/server/chunks/ssr/_090-87abj0x4l._.js:1:188) { digest: '3549031975'}{"message":"Error: The orders database did not answer.","route":"/orders","headers":{"cookie":"session=asha-7f3k9q2m","x-request-id":"lb-5e1d","host":"127.0.0.1:3000","connection":"keep-alive","x-forwarded-host":"127.0.0.1:3000","x-forwarded-port":"3000","x-forwarded-proto":"http","x-forwarded-for":"127.0.0.1"}}Line 4 logs request.headers as they came. The line in the log has "cookie":"session=asha-7f3k9q2m": anyone who can read the logs can sign in as Asha.
The first version logged the whole request.headers, and with it "cookie":"session=asha-7f3k9q2m": Asha's session. Anyone who can read the logs could use it to act as her (Authentication). The second logs the message, the path and the request id, which is enough to find the error, and nothing about Asha.
What to log, and what never to
A rule that holds up: log what helps you find a problem, and nothing that would hurt someone if the log leaked. Never log secrets, like keys, tokens, passwords and session cookies (Server-only code keeps them out of the browser; keep them out of logs too). Never log personal data, like names, email addresses and addresses; log an internal id instead, which only your own database turns back into a person. Sort these:
- The request's id,
lb-5e1d - The route,
/orders - The error's digest
- The
cookieheader - The
authorizationheader - A form's password field, to debug a failed sign-in
- A customer's email address
- How many teas the order had
Sort each value: fine to log, or never to log.
A setup that covers a production shop: register() starts OpenTelemetry with a real exporter; onRequestError sends each error Next.js catches on the server, with its digest and route, to an error tracker; instrumentation-client.ts reports the browser's errors; proxy.ts gives each request an id; and every log line is one JSON object with that id. Work that the visitor shouldn't wait for can go in after() in Server Components, Server Functions, Route Handlers and proxy.ts, where its docs say it works. In onRequestError, await the report instead, as its docs say.
Common mix-ups
- "register() runs for every request." No. Once when a server starts. The recording answered three requests and printed its line once.
- "instrumentation.ts goes in the app folder." No. In the project's root, or in
src, besideapp. - "OpenTelemetry with @vercel/otel only works on Vercel." No. It works on any server; it sends spans to any collector you point it at.
- "Next.js makes no spans until I write some." No. Once OpenTelemetry is started, Next.js makes spans for each request, its render and its route. Your own spans join them.
- "onRequestError catches errors in the browser." No. It runs on the server, for server errors. Browser errors need
instrumentation-client.tsor a library there. - "Logging the whole request is safer: nothing gets missed." No. The recorded line had Asha's session cookie in it. Log the fields you need.
- "A log line needs a sentence." No. One JSON object per line, with named fields, is easier to search than a sentence.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The shop runs this instrumentation.ts with next start and answers three requests. How many times does its line appear in the server's output?
export function register() {
console.log(`register() ran in the ${process.env.NEXT_RUNTIME} runtime`);
}
Once. next start printed register() ran in the nodejs runtime right after Ready, and three requests later it hadn't printed it again.
With the OpenTelemetry example, a request for the Route Handler /api/teas answers 200. What does print-traces.ts print as its trace's first line?
GET /api/teas (200). The recorded trace starts with it, and executing api route (app) /api/teas sits inside it.
The Route Handler /api/orders throws. What is context.routeType in the report this onRequestError prints?
import type { Instrumentation } from "next";
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
const report = {
message: error instanceof Error ? error.message : String(error),
digest: typeof error === "object" && error !== null && "digest" in error ? String(error.digest) : undefined,
method: request.method,
context,
};
// A real app sends the report to its error tracker here, and awaits it.
console.log(JSON.stringify(report));
};
route. The recorded report for /api/orders has "routeType":"route", and no digest and no renderSource, because nothing was rendered.
A teammate's onRequestError logs request.headers as they came. Which header in the recorded line must never be in a log?
import type { Instrumentation } from "next";
export const onRequestError: Instrumentation.onRequestError = async (error, request) => {
const message = error instanceof Error ? error.message : String(error);
const requestId = request.headers["x-request-id"];
console.log(JSON.stringify({ message, path: request.path, requestId }));
};cookie: it carried session=asha-7f3k9q2m, Asha's session. Pick the fields you need instead, like the request id, as in the second version of the playground.
On the first load of the home page, Chrome's console prints Welcome hydrated and instrumentation-client.ts ran. Which comes first?
instrumentation-client.ts ran. The file runs after the HTML has loaded and before React hydrates the page; the Effect that logs Welcome hydrated runs after hydration. The recording's first step has them in that order.
In the request id example, the load balancer sent lb-5e1d with the request for /teas and lb-5e1e with the one for /orders. Which requestId is in the error report?
lb-5e1e. The request for /orders came with x-request-id: lb-5e1e, proxy.ts kept it because it's a safe id, and the error report printed "requestId":"lb-5e1e". The response's x-request-id header has the same id.
Check your understanding
For each question, think about where the code runs, and when.
Question 1 of 7When does
register()ininstrumentation.tsrun?Choose an answer to see the explanation.
Question 2 of 7This is the shop's
instrumentation.ts. Which runtime's name does it print innext start?Read the code, then predictTypeScriptexport function register() { console.log(`register() ran in the ${process.env.NEXT_RUNTIME} runtime`); }Choose an answer to see the explanation.
Question 3 of 7In the recorded trace of
GET /teas, which span is the root?Choose an answer to see the explanation.
Question 4 of 7A Server Component throws in production. What does
onRequestErrorget that the visitor's browser doesn't?Choose an answer to see the explanation.
Question 5 of 7What is wrong with this
onRequestError?Read the code, then predictTypeScriptimport type { Instrumentation } from "next"; export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => { console.log(JSON.stringify({ message: String(error), route: context.routePath, headers: request.headers })); };Choose an answer to see the explanation.
Question 6 of 7When does
instrumentation-client.tsrun?Choose an answer to see the explanation.
Question 7 of 7Why give every request an id in
proxy.ts?Choose an answer to see the explanation.
Key takeaways
instrumentation.tssits at the project's root. Itsregister()runs once when a server starts, before requests, andNEXT_RUNTIMEsays which runtime is calling.- Start OpenTelemetry in
register(), with@vercel/otel: Next.js makes spans for each request, likeGET /teasandrender route (app) /teas, and your own spans join them. onRequestErrorgets each error Next.js catches on the server, with the request and the context; send its message, digest and route to an error tracker.instrumentation-client.tsruns in the browser before hydration; report browser errors from it, and follow navigations withonRouterTransitionStart.- Log one JSON object per line with a request id from
proxy.ts; never log secrets, session cookies or personal data.
Remember the one-liner.
Set up once in register(), report every error with its digest, and log facts with a request id, never secrets.
The names of Next.js's spans, like BaseServer.handleRequest for a request's root span, are listed in packages/next/src/server/lib/trace/constants.ts, at release v16.4.3.
Coming next: Deploying Next.js: running the app on a Node.js server, in Docker, as a static export or on a platform, and which features each one supports.