Skip to solution
mediumFrontend

Next.js instrumentation hook — observability in Next.js 15

169 views
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

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

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

Explore the playground snippets

The two exports, with the context fields taken from the shipped types
Run Playground
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.

const FILE = `// 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.
const CONTEXT_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"],
];

const SCENARIOS = [
  { 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 },
];

export default function App() {
  const [i, setI] = useState(0);
  const s = SCENARIOS[i];

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 640 }}>
      <pre style={{ background: "#f6f6f8", border: "1px solid #ddd", borderRadius: 8,
                    padding: 12, fontSize: 12, overflowX: "auto", whiteSpace: "pre-wrap" }}>
{FILE}
      </pre>

      <h4 style={{ margin: "14px 0 6px" }}>The context argument</h4>
      <table style={{ width: "100%", fontSize: 12, borderCollapse: "collapse" }}>
        <tbody>
          {CONTEXT_FIELDS.map(([name, type, note]) => (
            <tr key={name} style={{ borderBottom: "1px solid #eee" }}>
              <td style={{ padding: "3px 8px 3px 0", whiteSpace: "nowrap" }}><code>{name}</code></td>
              <td style={{ padding: "3px 8px 3px 0", color: "#4f46e5" }}><code>{type}</code></td>
              <td style={{ color: "#666" }}>{note}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <h4 style={{ margin: "14px 0 6px" }}>Who sees what</h4>
      <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 8 }}>
        {SCENARIOS.map((sc, n) => (
          <button key={sc.label} onClick={() => setI(n)} style={{ fontWeight: n === i ? "bold" : "normal", fontSize: 12 }}>
            {sc.label}
          </button>
        ))}
      </div>
      <div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12, fontSize: 13 }}>
        <div>error boundary shows a fallback: <strong style={{ color: s.caught ? "#161" : "#a33" }}>{s.caught ? "yes" : "no"}</strong></div>
        <div>
          onRequestError fires:{" "}
          <strong style={{ color: s.routeType === "(not server-side)" ? "#a33" : "#161" }}>
            {s.routeType === "(not server-side)" ? "no — server-side only" : "yes"}
          </strong>
        </div>
        <div style={{ color: "#666" }}>
          context.routeType = <code>{s.routeType}</code> · renderSource = <code>{s.renderSource}</code>
        </div>
      </div>

      <p style={{ 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>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 67 of 119 decoded in the React.js track. One more won't hurt.

Back to track