Next.js 16 instrumentation.ts Is Stable: Wiring OpenTelemetry, Sentry, and Custom Spans Without a Wrapper
Next.js 16 stabilizes instrumentation.ts, removing the need for custom init wrappers. Learn how to wire OpenTelemetry, Sentry, and custom spans with direct SDK initialization, lifecycle guarantees, and production-ready patterns.
Most observability integration problems in Next.js stem from initialization timing. Teams import Sentry or OpenTelemetry collectors in _app.tsx or middleware and watch silent failures cascade through production because the SDK never initialized before the first request landed. Next.js 16 solves this by marking instrumentation.ts stable, guaranteeing a single-execution lifecycle hook that runs before any application code.
The pattern developers overlooked was treating observability as a runtime concern instead of a build-time contract. Every custom wrapper, every "call this before importing anything else" comment, every race condition between middleware and SDK initialization disappears when the framework provides a dedicated entry point with deterministic execution order. This matters because silent telemetry failures cost hours of debugging time and obscure production incidents when teams need visibility most.
flowchart LR
Start("SDK imported in _app.tsx") --> Race("middleware executes first")
Race --> Silent("spans lost, no telemetry")
style Silent stroke:#ef4444,fill:#450a0a,color:#fca5a5
With instrumentation.ts, the framework executes the register function once per runtime environment before middleware, route handlers, or React components load. Developers wire OpenTelemetry auto-instrumentation, initialize Sentry with direct SDK calls, and attach custom spans to database queries or external API calls without a single wrapper function.
flowchart LR
Start("SDK imported in _app.tsx") --> Register("instrumentation.ts executes")
Register --> Telemetry("all spans captured")
style Telemetry stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- Next.js 16 stabilizes
instrumentation.ts, guaranteeing a single-execution lifecycle hook before all application code loads. - Direct SDK initialization in
instrumentation.tseliminates custom init wrappers and timing-dependent race conditions. - OpenTelemetry auto-instrumentation captures HTTP, database, and framework spans without manual instrumentation.
- Sentry, Datadog, and other observability platforms integrate with zero middleware or route handler boilerplate.
- Custom spans for business logic attach directly to the active trace context, enabling end-to-end request visibility.
How instrumentation.ts Works: Lifecycle, Execution Context, and the register Function
The instrumentation.ts file exports a single register function that Next.js calls exactly once per runtime environment. In serverless deployments this means once per cold start. In Node.js servers this means once at process boot. The distinction is critical: the function does not run on every request, does not execute in React Server Component scope, and does not share middleware execution context.
stateDiagram-v2
[*] --> ProcessStart
ProcessStart --> RegisterExecutes: instrumentation.ts loaded
RegisterExecutes --> MiddlewareReady: SDKs initialized
MiddlewareReady --> RouteHandlers: trace context available
RouteHandlers --> [*]
note right of RegisterExecutes: runs once per runtime
note right of MiddlewareReady: all spans captured from first request
Developers place instrumentation.ts at the root of src/ or at the project root alongside next.config.js. The framework discovers the file automatically and calls register during the build phase in development and at runtime boot in production. No configuration flags. No experimental toggles. The file exists or it does not.
The execution environment inside register runs in Node.js context with full access to the filesystem, environment variables, and network I/O. This is where teams initialize OpenTelemetry collectors, Sentry transports, or custom logging pipelines. The function can be synchronous or asynchronous. If async, Next.js waits for the promise to resolve before starting the HTTP server or invoking middleware.
The implication here is that slow initialization blocks the entire application boot sequence. A Sentry SDK that takes three seconds to connect to its ingest endpoint adds three seconds to cold start time. Developers must balance thorough initialization with startup performance, especially in serverless environments where cold starts directly impact P99 latency.
Wiring OpenTelemetry with @vercel/otel: Auto-Instrumentation and Custom Spans
Vercel maintains @vercel/otel, a zero-config OpenTelemetry package that auto-instruments Next.js applications with HTTP, fetch, database, and framework spans. The package exports a registerOTel function designed specifically for instrumentation.ts, removing the need for manual collector configuration or SDK registration.
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("@vercel/otel/register");
}
}This four-line snippet enables distributed tracing across the entire request lifecycle. The package detects Next.js route handlers, middleware, Server Components, and API routes, automatically creating spans for each execution boundary. Fetch calls to external APIs receive trace context propagation headers. Database queries instrumented with libraries like Prisma or Drizzle appear as child spans under the active request trace.
The environment check prevents the module from loading in Edge Runtime where Node.js APIs are unavailable. The dynamic import ensures the SDK only loads when actually needed, avoiding unnecessary bundle bloat in client-side code or edge middleware that does not support observability SDKs.
For teams that need custom span attributes or manual instrumentation, the OpenTelemetry API provides direct access to the active trace context:
// instrumentation.ts
import { trace } from "@opentelemetry/api";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("@vercel/otel/register");
}
}
// app/api/orders/route.ts
import { trace } from "@opentelemetry/api";
export async function POST(request: Request) {
const tracer = trace.getTracer("order-service");
const span = tracer.startSpan("validate-payment");
try {
const body = await request.json();
span.setAttribute("order.amount", body.amount);
span.setAttribute("order.currency", body.currency);
await processPayment(body);
span.setStatus({ code: 1 }); // OK
return Response.json({ success: true });
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: 2, message: (error as Error).message }); // ERROR
throw error;
} finally {
span.end();
}
}The manual span captures business-specific attributes that auto-instrumentation cannot infer. Payment amounts, currency codes, customer identifiers, and error details appear as structured fields in the trace timeline, enabling precise queries in observability platforms.
Integrating Sentry Without a Wrapper: Direct SDK Initialization in instrumentation.ts
Sentry's Next.js SDK traditionally required developers to create sentry.client.config.ts and sentry.server.config.ts files, then import them at the top of _app.tsx and API route files. The pattern fragmented initialization logic and introduced timing dependencies. With instrumentation.ts, the entire SDK initializes in one place with guaranteed execution order.
// instrumentation.ts
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1.0,
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Sentry.Integrations.Prisma({ client: prisma }),
],
});
}
if (process.env.NEXT_RUNTIME === "edge") {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1.0,
});
}
}The runtime branching handles both Node.js and Edge Runtime environments, initializing the appropriate SDK configuration for each. The Prisma integration automatically captures database queries as breadcrumbs and spans without manual instrumentation in every data access layer function.
Sentry's error boundary integration with React Server Components works immediately because the SDK initialized before the first component rendered. Unhandled promise rejections, synchronous throws, and Next.js-specific errors like 404s and 500s flow directly into Sentry's issue stream with full trace context.
The failure mode here is subtle but expensive: teams that skip the runtime check and initialize both SDKs unconditionally will see bundle size bloat in edge middleware where Node.js integrations cannot execute. The edge bundle includes dead code that inflates cold start time and wastes bandwidth on every deployment.
Custom Spans for Business Logic: Tracing Database Queries, External APIs, and Background Jobs
Auto-instrumentation captures framework-level operations, but business logic visibility requires manual spans. A checkout flow that validates inventory, charges a payment processor, and enqueues a fulfillment job needs discrete spans for each step to identify bottlenecks and failure points.
The OpenTelemetry API exposes startSpan and startActiveSpan methods that attach child spans to the current trace context. The distinction matters: startSpan requires manual context propagation, while startActiveSpan automatically binds the span to async callbacks and promise chains.
// lib/checkout.ts
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("checkout-service");
export async function processCheckout(cart: Cart) {
return tracer.startActiveSpan("checkout.process", async (span) => {
span.setAttribute("cart.items", cart.items.length);
span.setAttribute("cart.total", cart.total);
const inventory = await tracer.startActiveSpan(
"checkout.validate-inventory",
async (inventorySpan) => {
const result = await checkInventory(cart.items);
inventorySpan.setAttribute("inventory.available", result.available);
inventorySpan.end();
return result;
}
);
if (!inventory.available) {
span.setStatus({ code: 2, message: "insufficient inventory" });
span.end();
throw new Error("Out of stock");
}
const payment = await tracer.startActiveSpan(
"checkout.charge-payment",
async (paymentSpan) => {
const result = await chargePayment(cart.total);
paymentSpan.setAttribute("payment.id", result.id);
paymentSpan.setAttribute("payment.status", result.status);
paymentSpan.end();
return result;
}
);
await tracer.startActiveSpan("checkout.enqueue-fulfillment", async (jobSpan) => {
await enqueueJob({ type: "fulfillment", orderId: payment.id });
jobSpan.end();
});
span.setStatus({ code: 1 });
span.end();
return { orderId: payment.id };
});
}The nested span structure creates a waterfall timeline in observability platforms. Developers see that inventory validation took 120ms, payment processing took 340ms, and job enqueueing took 15ms. When checkout latency spikes, the trace identifies which operation regressed without guessing or adding ad-hoc logging.
flowchart LR
Request("incoming checkout request") --> Validate("validate inventory span")
Validate --> Charge("charge payment span")
Charge --> Enqueue("enqueue job span")
Enqueue --> Complete("checkout complete")
style Charge stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style Complete stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The pattern extends to background jobs and serverless functions. A cron job that syncs data from an external API creates a root span at job start, then attaches child spans for each API call, database write, and cache update. When the job runs in a separate process from the web application, the trace ID stored in job metadata links execution across system boundaries.
instrumentation.ts vs Middleware vs Route Handlers: When to Use Each for Observability
Next.js provides three execution contexts for observability hooks: instrumentation.ts, middleware, and route handlers. Each serves a distinct purpose and choosing the wrong one introduces latency, code duplication, or missing telemetry.
instrumentation.ts initializes SDKs and registers global handlers. This is where developers call Sentry.init, register OpenTelemetry exporters, and configure log transports. The code runs once per runtime boot, making it unsuitable for per-request logic but essential for setup that must complete before the application accepts traffic.
Middleware executes on every request before route handlers and Server Components. This is where teams attach request-scoped attributes to the active trace, enrich error context with user metadata, or sample requests based on path or headers. Middleware has access to NextRequest and NextResponse objects, enabling header manipulation and early exits.
flowchart LR
Boot("process boot") --> subgraph Inst["instrumentation.ts scope"]
Init("SDK initialization")
end
Inst --> subgraph Mid["middleware scope"]
Enrich("enrich trace context")
end
Mid --> subgraph Route["route handler scope"]
Logic("business logic spans")
end
Route --> Response("send response")
style Init stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style Enrich stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style Logic stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Route handlers and Server Components execute business logic and create custom spans. This is where teams instrument database queries, external API calls, and compute-heavy operations. The code has access to parsed request bodies, authentication state, and database connections, making it the correct location for operation-specific telemetry.
The common mistake is initializing SDKs in middleware. Teams that call Sentry.init inside middleware.ts reinitialize the SDK on every request, overwriting configuration and creating memory leaks. The middleware executes hundreds of times per second in production. SDK initialization should happen exactly once.
Another failure mode is adding business logic spans in instrumentation.ts. The register function has no access to request context, user sessions, or database connections. Attempting to create spans for operations that have not happened yet produces garbage telemetry with no correlation to actual requests.
Production Patterns: Environment Detection, Graceful Shutdown, and Error Handling
Production deployments require observability initialization to handle environment-specific configuration, graceful shutdown signals, and initialization failures without crashing the application.
Environment detection ensures SDKs only load in the correct runtime. Edge Runtime does not support Node.js APIs like fs or child_process. Attempting to import @vercel/otel in edge middleware throws a runtime error that crashes the entire deployment.
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { registerOTel } = await import("@vercel/otel");
registerOTel({
serviceName: process.env.OTEL_SERVICE_NAME || "nextjs-app",
traceExporter: process.env.NODE_ENV === "production" ? "otlp" : "console",
});
}
if (process.env.NEXT_RUNTIME === "edge") {
// Edge-compatible observability only
console.log("Edge runtime detected, skipping Node.js instrumentation");
}
}Graceful shutdown handling ensures spans flush to the collector before the process terminates. Serverless platforms like Vercel and AWS Lambda freeze execution immediately after the response sends. Spans created but not exported before freeze are lost permanently.
// instrumentation.ts
import { trace } from "@opentelemetry/api";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { NodeSDK } = await import("@opentelemetry/sdk-node");
const sdk = new NodeSDK({
// configuration
});
await sdk.start();
process.on("SIGTERM", async () => {
try {
await sdk.shutdown();
console.log("OpenTelemetry SDK shut down successfully");
} catch (error) {
console.error("Error shutting down OpenTelemetry SDK", error);
} finally {
process.exit(0);
}
});
}
}Error handling during initialization must fail gracefully without blocking application boot. A misconfigured Sentry DSN or unreachable OpenTelemetry collector should log an error and continue serving requests with degraded telemetry, not crash the server.
flowchart LR
Init("SDK initialization") --> Check("configuration valid?")
Check -->|yes| Register("register exporters")
Check -->|no| Log("log error and continue")
Register --> Boot("application boot continues")
Log --> Boot
style Log stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style Boot stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The production pattern wraps SDK initialization in try-catch blocks and validates configuration before calling init methods. Missing environment variables, network timeouts, or SDK version mismatches should degrade gracefully.
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
try {
if (!process.env.SENTRY_DSN) {
console.warn("SENTRY_DSN not set, skipping Sentry initialization");
return;
}
const Sentry = await import("@sentry/nextjs");
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE || "0.1"),
beforeSend(event) {
if (event.exception?.values?.[0]?.type === "AbortError") {
return null; // filter noisy client disconnects
}
return event;
},
});
} catch (error) {
console.error("Failed to initialize Sentry", error);
// Application continues without Sentry telemetry
}
}This pattern prevents observability failures from becoming application failures. Teams that skip error handling see production deployments crash during boot when a collector endpoint returns a 500 or a DNS lookup times out.
Frequently Asked Questions
Does instrumentation.ts execute in serverless cold starts or only on long-running Node.js servers?
The register function executes once per serverless cold start and once per Node.js server boot. In AWS Lambda or Vercel Functions each cold start runs the function, then reuses the initialized SDK across subsequent invocations in the same container.
Can middleware initialize observability SDKs if instrumentation.ts is not available?
Middleware can technically initialize SDKs, but it executes on every request and reinitializes the SDK hundreds of times per second in production. This creates memory leaks, overwrites configuration, and degrades performance. Use instrumentation.ts for SDK initialization and middleware for per-request trace enrichment.
How do custom spans in route handlers attach to the trace started by auto-instrumentation?
OpenTelemetry maintains an active trace context in async local storage. When developers call trace.getTracer().startActiveSpan() inside a route handler, the SDK retrieves the active trace from context and attaches the new span as a child automatically.
What happens if instrumentation.ts throws an unhandled error during initialization?
Next.js crashes the application boot sequence and logs the error. Developers must wrap SDK initialization in try-catch blocks to handle configuration errors, network failures, or missing environment variables gracefully.
Does instrumentation.ts support environment-specific configuration without hardcoding values?
Yes. The register function has full access to process.env and can load configuration from .env.local, .env.production, or environment variables injected by the deployment platform. Teams commonly use environment checks like process.env.NODE_ENV === "production" to toggle trace sampling rates or exporter endpoints.
Wrapping Up: Why instrumentation.ts Stability Removes the Need for Custom Init Wrappers
The stabilization of instrumentation.ts in Next.js 16 eliminates the initialization timing problems that plagued observability integrations in previous versions. Teams no longer write custom wrapper functions, import SDKs at the top of every entry point, or debug race conditions between middleware and SDK initialization. The framework guarantees a single-execution lifecycle hook that runs before any application code, providing a deterministic foundation for OpenTelemetry, Sentry, and custom telemetry pipelines.
Direct SDK initialization in instrumentation.ts replaces fragile patterns with explicit configuration. Auto-instrumentation captures HTTP, database, and framework spans without manual boilerplate. Custom spans attach to business logic with precise attributes and structured metadata. Production deployments handle environment detection, graceful shutdown, and initialization failures without crashing the application.
That covers the essential patterns for wiring observability into Next.js 16 applications. Apply these in production and the difference will be immediate.