cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Instrumentation and observability

See what production is doing: instrumentation.ts and register(), OpenTelemetry spans, onRequestError reports, client instrumentation and safe JSON logs.

By the end, you can
  • 01
    Set up instrumentationUse instrumentation.ts and register() to start OpenTelemetry once per server, and read the spans Next.js makes for each request.
  • 02
    Report errorsSend each error Next.js catches on the server, with its digest and route, from onRequestError, and the browser's from instrumentation-client.ts.
  • 03
    Log 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.

register() in the tea shopNext.js
app/_data/teas/
instrumentation.ts
export function register() {  console.log(`register() ran in the ${process.env.NEXT_RUNTIME} runtime`);}
Recorded from Next.js 16.4.3
$ 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 teas

Line 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.

A tag for next start points to a card register(), labeled once, before any request. Arrows lead from it to three request cards: GET /, GET /teas and GET /teas, labeled every request.next startregister()once, before any requestGET /GET /teasGET /teasevery request
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().
Real-life analogyOpening a tea shop for the day

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
The main idea

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 inside src if the project uses one. Never inside app: 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. register may return a promise, for setup that waits, like loading a library with import().
  • 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_RUNTIME says which one is calling: nodejs in the recording.

When the two runtimes need different setup, load each from its own file. This is the docs' pattern, not recorded:

instrumentation.ts: a file for each runtimeTypeScript
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.

A trace for GET /teas, drawn as bars in time from left to right. The root bar, GET /teas, spans the whole width. Under it, a bar for render route (app) /teas covers most of it, and under that a short bar for load teas from the database.GET /teas (200)render route (app) /teasload teas from the databasetime →the same trace id joins every span
A trace is one request's spans. The root span, 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:

Installing OpenTelemetryText
# 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-base

The 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.

The tea shop with OpenTelemetryNext.js
app/_data/api/printed/teas/teas/
instrumentation.ts
import { registerOTel } from "@vercel/otel";import { printTraces } from "./print-traces"; export function register() {  registerOTel({ serviceName: "tea-shop", spanProcessors: [printTraces] });}
Recorded from Next.js 16.4.3
$ 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 response

Line 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:

Playground: what is each span?
Try it yourself
GET / (200)

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.

Each tree is a trace that print-traces.ts printed while next start, from Next.js 16.4.3, answered the shop's requests: the static home page, the teas page made for each request, and the Route Handler. Pick a request, then a span. Reset goes back to GET /.

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:

Environment variables for tracesText
# 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=1

Line 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.

onRequestError in the tea shopNext.js
app/_data/api/orders/orders/
instrumentation.ts
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));};
Recorded from Next.js 16.4.3
$ 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:

What onRequestError got for each error
QuestionThe page, /ordersThe Route Handler, /api/orders
Where it was thrownOrderList, a Server Component on /orders.The GET function of the Route Handler /api/orders.
What the visitor got200, 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.routeTyperenderroute
context.renderSourcereact-server-componentsNot there: nothing was rendered.
A card OrderList throws. Arrows go to two windows. The terminal window shows Next.js's line ⨯ Error and digest 3549031975. The onRequestError window shows the report with the message, digest 3549031975, and routeType render, and an arrow from it goes to a tag for the error tracker.OrderList throwsterminal⨯ Errordigest: '3549031975'report"digest":"3549031975""routeType":"render"errortrackerthe same digest in both
When 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 in the tea shopNext.js
app/api/client-errors/teas/
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})`);}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Home Teas
Tea shop

Fresh tea, sent to your door.

Focus
nothing: the page itself
Scrolled
0 px down

Everything Chrome's console printed during this step (2)

  1. [log] instrumentation-client.ts ran
  2. [log] Welcome hydrated

Requests the page made during this step (3), without the files it loaded

  1. GET/200a whole page
  2. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /
  3. 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:

What each step printed
StepIn Chrome's consoleOn the server
Open /instrumentation-client.ts ran, then Welcome hydrated.Nothing.
Click the Teas linknavigation to /teas (push), from onRouterTransitionStart.Nothing.
Click Brew Mint teaThe error, and a POST /api/client-errors that answered 204.The report: {"level":"error","source":"browser",…}.
Click Check stockThe 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:

Structured logs with a request idNext.js
app/_data/_lib/api/orders/orders/teas/
app/_lib/log.ts
import { 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 }));}
Recorded from Next.js 16.4.3
$ 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 teas

In 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.

A tag for proxy.ts with the id lb-5e1e. Arrows go to two cards: the error report line with requestId lb-5e1e, and the response header x-request-id: lb-5e1e.proxy.tslb-5e1e{"level":"error",…,"requestId":"lb-5e1e"}the error report in the logx-request-id: lb-5e1ethe response's header
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:

Playground: what does the error report put in the log?
The orders page, with this version of instrumentation.tsNext.js
app/_data/api/orders/orders/
instrumentation.ts
import 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 }));};
Recorded from Next.js 16.4.3
$ 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"}}
Try it yourself
Every header

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.

Both versions were built with next build from Next.js 16.4.3 and run with next start. Asha's request carried her session cookie and the load balancer's request id; Terminal shows what next start printed. Reset goes back to the first version.

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:

Log it or not?
  • The request's id, lb-5e1d
  • The route, /orders
  • The error's digest
  • The cookie header
  • The authorization header
  • A form's password field, to debug a failed sign-in
  • A customer's email address
  • How many teas the order had
Try it yourself
0 of 8 correct

Sort each value: fine to log, or never to log.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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, beside app.
  • "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.ts or 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.

Exercise 1 · Warm-upHow often does register() run?

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?

Starter codeTypeScript
export function register() {
  console.log(`register() ran in the ${process.env.NEXT_RUNTIME} runtime`);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the root span

    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?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhich kind of work failed?

      The Route Handler /api/orders throws. What is context.routeType in the report this onRequestError prints?

      Starter codeTypeScript
      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));
      };
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the leak

        A teammate's onRequestError logs request.headers as they came. Which header in the recorded line must never be in a log?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeBefore hydration

          On the first load of the home page, Chrome's console prints Welcome hydrated and instrumentation-client.ts ran. Which comes first?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeMatch the error to the request

            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?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, think about where the code runs, and when.

              The Instrumentation and observability quiz · 7 questionsScore: first tries count
              1. Question 1 of 7When does register() in instrumentation.ts run?

                Choose an answer to see the explanation.

              2. Question 2 of 7This is the shop's instrumentation.ts. Which runtime's name does it print in next start?

                Read the code, then predictTypeScript
                export function register() {
                  console.log(`register() ran in the ${process.env.NEXT_RUNTIME} runtime`);
                }
                

                Choose an answer to see the explanation.

              3. Question 3 of 7In the recorded trace of GET /teas, which span is the root?

                Choose an answer to see the explanation.

              4. Question 4 of 7A Server Component throws in production. What does onRequestError get that the visitor's browser doesn't?

                Choose an answer to see the explanation.

              5. Question 5 of 7What is wrong with this onRequestError?

                Read the code, then predictTypeScript
                import 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.

              6. Question 6 of 7When does instrumentation-client.ts run?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why give every request an id in proxy.ts?

                Choose an answer to see the explanation.

              Key takeaways

              • instrumentation.ts sits at the project's root. Its register() runs once when a server starts, before requests, and NEXT_RUNTIME says which runtime is calling.
              • Start OpenTelemetry in register(), with @vercel/otel: Next.js makes spans for each request, like GET /teas and render route (app) /teas, and your own spans join them.
              • onRequestError gets 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.ts runs in the browser before hydration; report browser errors from it, and follow navigations with onRouterTransitionStart.
              • 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.