Next.js instrumentation hook — observability in Next.js 15
169 views
01
01
Understand the problem
Question presented to candidate:
"Where do you initialise tracing in a Next.js app, and how do you capture server-side errors that never reach an error boundary?"
What a strong answer should cover:
The instrumentation file at the project root (or src/) is the designated place. It exports two optional functions.
register() runs once when the server starts, before any request. That is where OpenTelemetry, an APM agent, or any global initialisation goes — importantly, before the code it needs to patch is loaded.
onRequestError(error, request, context) is called for server-side errors, including the ones no error boundary can see: route handlers, Server Actions, and Server Component renders.
The context argument is the useful part: it says which router (App or Pages), the route path, the route type (a render, a route handler, an action, a proxy) and, for renders, the render source.
That lets you route errors sensibly — a failing Server Action is a different alert from a failing static render.
It complements error boundaries rather than replacing them: boundaries produce the user-facing fallback, instrumentation produces the operator-facing signal.
It pairs with the digest a production boundary shows the user — that is the key you match against.
It is server-side only; browser errors still need a client-side reporter.
Clarifying questions expected:
"Which Next.js version?" — the hook has moved through experimental flags to stable.
"Do we need traces, error reporting, or both?" — register versus onRequestError.
Code / implementation expected: Optional. The two exported function signatures are the answer.
nextjsobservability
02
02
Attempt it yourself
Sketch your approach before reading the solution — that's what interviews test.
Nudge consolestandby
Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.
03
03
Study the solution
Target Audience: Engineers preparing for Next.js interviews — assumes basic App Router knowledge.
Difficulty: Medium
How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. A note on verification: this could not be executed — there is no
Solution ready — 2 min read
Classified // press E to declassify
04
04
Explore the playground snippets
The two exports, with the context fields taken from the shipped types
import { useState } from"react";
// Not runnable as Next.js code in a browser playground — this is an annotated// reference plus a live explorer for the context object. The type shapes below// are read from next/dist/server/instrumentation/types.d.ts in next@16.3.4.constFILE = `// instrumentation.ts — project root, or src/
import type { Instrumentation } from "next";
export async function register() {
// Runs ONCE when the server process starts, before any request is handled.
// That timing is the point: an APM agent patches modules like http and your
// database driver as they are imported, so it must run before they are.
if (process.env.NEXT_RUNTIME === "nodejs") {
// Most tracing SDKs are Node-only, so import conditionally or the edge
// build breaks.
await import("./instrumentation.node");
}
}
export const onRequestError: Instrumentation.onRequestError = async (
error, // unknown
request, // { path, method, headers }
context, // the useful one — see the table below
) => {
await sendToMonitoring({
message: String(error),
path: request.path,
method: request.method,
router: context.routerKind,
route: context.routePath,
type: context.routeType,
source: context.renderSource,
revalidate: context.revalidateReason,
});
};`;
// Straight from the installed package's type definitions.constCONTEXT_FIELDS = [
["routerKind", "'Pages Router' | 'App Router'", "which router produced it"],
["routePath", "string", "the route that failed"],
["routeType", "'render' | 'route' | 'action' | 'proxy'", "page render, route handler, Server Action, or proxy"],
["renderSource", "'react-server-components' | 'react-server-components-payload' | 'server-rendering'", "only present for renders"],
["revalidateReason", "'on-demand' | 'stale' | undefined", "why a revalidation was running"],
];
constSCENARIOS = [
{ label: "a Server Component threw", routeType: "render", renderSource: "react-server-components", caught: true },
{ label: "a route handler threw", routeType: "route", renderSource: "—", caught: false },
{ label: "a Server Action failed", routeType: "action", renderSource: "—", caught: false },
{ label: "a client render error", routeType: "(not server-side)", renderSource: "—", caught: true },
];
exportdefaultfunctionApp() {
const [i, setI] = useState(0);
const s = SCENARIOS[i];
return (
<divstyle={{padding:24, fontFamily: "system-ui", lineHeight:1.6, maxWidth:640 }}><prestyle={{background: "#f6f6f8", border: "1pxsolid #ddd", borderRadius:8,
padding:12, fontSize:12, overflowX: "auto", whiteSpace: "pre-wrap" }}>
{FILE}
</pre><h4style={{margin: "14px06px" }}>The context argument</h4><tablestyle={{width: "100%", fontSize:12, borderCollapse: "collapse" }}><tbody>
{CONTEXT_FIELDS.map(([name, type, note]) => (
<trkey={name}style={{borderBottom: "1pxsolid #eee" }}><tdstyle={{padding: "3px8px3px0", whiteSpace: "nowrap" }}><code>{name}</code></td><tdstyle={{padding: "3px8px3px0", color: "#4f46e5" }}><code>{type}</code></td><tdstyle={{color: "#666" }}>{note}</td></tr>
))}
</tbody></table><h4style={{margin: "14px06px" }}>Who sees what</h4><divstyle={{display: "flex", gap:6, flexWrap: "wrap", marginBottom:8 }}>
{SCENARIOS.map((sc, n) => (
<buttonkey={sc.label}onClick={() => setI(n)} style={{ fontWeight: n === i ? "bold" : "normal", fontSize: 12 }}>
{sc.label}
</button>
))}
</div><divstyle={{border: "1pxsolid #ddd", borderRadius:8, padding:12, fontSize:13 }}><div>error boundary shows a fallback: <strongstyle={{color:s.caught ? "#161" : "#a33" }}>{s.caught ? "yes" : "no"}</strong></div><div>
onRequestError fires:{" "}
<strongstyle={{color:s.routeType === "(not server-side)" ? "#a33" : "#161" }}>
{s.routeType === "(not server-side)" ? "no — server-side only" : "yes"}
</strong></div><divstyle={{color: "#666" }}>
context.routeType = <code>{s.routeType}</code> · renderSource = <code>{s.renderSource}</code></div></div><pstyle={{fontSize:13, color: "#666" }}>
The two middle scenarios are the reason this hook exists: a route
handler and a Server Action fail outside any render, so no boundary is
in the window and the user-facing mechanism reports nothing at all.
</p></div>
);
}