{"slug": "next-js-16-instrumentation-ts-is-stable-wiring-opentelemetry-sentry-and-custom-a", "title": "Next.js 16 `instrumentation.ts` Is Stable: Wiring OpenTelemetry, Sentry, and Custom Spans Without a Wrapper", "summary": "Next.js 16 has marked the `instrumentation.ts` file stable, giving developers a single-execution lifecycle hook that runs before middleware, route handlers, or React components load, so observability SDKs like OpenTelemetry and Sentry can initialize without wrapper functions or race conditions. The file exports one `register` function called once per runtime environment — once per cold start in serverless and once at process boot in Node.js — and Vercel's `@vercel/otel` package provides a `registerOTel` helper designed for it. Because Next.js waits for an async `register` to resolve before starting the HTTP server, slow SDK initialization directly adds to cold-start time and P99 latency.", "body_md": "*This article was written with the assistance of AI, under human supervision and review.*\n\nMost 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.\n\nThe 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.\n\nWith `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.\n\n`instrumentation.ts`, guaranteeing a single-execution lifecycle hook before all application code loads.\nThe `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.\n\nDevelopers 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.\n\nThe 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.\n\nThe 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.\n\nVercel 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.\n\n```\n// instrumentation.ts\nexport async function register() {\n  if (process.env.NEXT_RUNTIME === \"nodejs\") {\n    await import(\"@vercel/otel/register\");\n  }\n}\n```\n\nThis 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.\n\nThe 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.\n\nFor teams that need custom span attributes or manual instrumentation, the OpenTelemetry API provides direct access to the active trace context:\n\n``` js\n// instrumentation.ts\nimport { trace } from \"@opentelemetry/api\";\n\nexport async function register() {\n  if (process.env.NEXT_RUNTIME === \"nodejs\") {\n    await import(\"@vercel/otel/register\");\n  }\n}\n\n// app/api/orders/route.ts\nimport { trace } from \"@opentelemetry/api\";\n\nexport async function POST(request: Request) {\n  const tracer = trace.getTracer(\"order-service\");\n  const span = tracer.startSpan(\"validate-payment\");\n\n  try {\n    const body = await request.json();\n    span.setAttribute(\"order.amount\", body.amount);\n    span.setAttribute(\"order.currency\", body.currency);\n\n    await processPayment(body);\n    span.setStatus({ code: 1 }); // OK\n    return Response.json({ success: true });\n  } catch (error) {\n    span.recordException(error as Error);\n    span.setStatus({ code: 2, message: (error as Error).message }); // ERROR\n    throw error;\n  } finally {\n    span.end();\n  }\n}\n```\n\nThe 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.\n\nSentry'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.\n\n```\n// instrumentation.ts\nimport * as Sentry from \"@sentry/nextjs\";\n\nexport async function register() {\n  if (process.env.NEXT_RUNTIME === \"nodejs\") {\n    Sentry.init({\n      dsn: process.env.SENTRY_DSN,\n      environment: process.env.NODE_ENV,\n      tracesSampleRate: 1.0,\n      integrations: [\n        new Sentry.Integrations.Http({ tracing: true }),\n        new Sentry.Integrations.Prisma({ client: prisma }),\n      ],\n    });\n  }\n\n  if (process.env.NEXT_RUNTIME === \"edge\") {\n    Sentry.init({\n      dsn: process.env.SENTRY_DSN,\n      environment: process.env.NODE_ENV,\n      tracesSampleRate: 1.0,\n    });\n  }\n}\n```\n\nThe 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.\n\nSentry'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.\n\nThe 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.\n\nAuto-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.\n\nThe 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.\n\n``` js\n// lib/checkout.ts\nimport { trace } from \"@opentelemetry/api\";\n\nconst tracer = trace.getTracer(\"checkout-service\");\n\nexport async function processCheckout(cart: Cart) {\n  return tracer.startActiveSpan(\"checkout.process\", async (span) => {\n    span.setAttribute(\"cart.items\", cart.items.length);\n    span.setAttribute(\"cart.total\", cart.total);\n\n    const inventory = await tracer.startActiveSpan(\n      \"checkout.validate-inventory\",\n      async (inventorySpan) => {\n        const result = await checkInventory(cart.items);\n        inventorySpan.setAttribute(\"inventory.available\", result.available);\n        inventorySpan.end();\n        return result;\n      }\n    );\n\n    if (!inventory.available) {\n      span.setStatus({ code: 2, message: \"insufficient inventory\" });\n      span.end();\n      throw new Error(\"Out of stock\");\n    }\n\n    const payment = await tracer.startActiveSpan(\n      \"checkout.charge-payment\",\n      async (paymentSpan) => {\n        const result = await chargePayment(cart.total);\n        paymentSpan.setAttribute(\"payment.id\", result.id);\n        paymentSpan.setAttribute(\"payment.status\", result.status);\n        paymentSpan.end();\n        return result;\n      }\n    );\n\n    await tracer.startActiveSpan(\"checkout.enqueue-fulfillment\", async (jobSpan) => {\n      await enqueueJob({ type: \"fulfillment\", orderId: payment.id });\n      jobSpan.end();\n    });\n\n    span.setStatus({ code: 1 });\n    span.end();\n    return { orderId: payment.id };\n  });\n}\n```\n\nThe 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.\n\nThe 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.\n\nNext.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.\n\n`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.\n\nMiddleware 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.\n\nRoute 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.\n\nThe 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.\n\nAnother 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.\n\nProduction deployments require observability initialization to handle environment-specific configuration, graceful shutdown signals, and initialization failures without crashing the application.\n\nEnvironment 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.\n\n```\n// instrumentation.ts\nexport async function register() {\n  if (process.env.NEXT_RUNTIME === \"nodejs\") {\n    const { registerOTel } = await import(\"@vercel/otel\");\n    registerOTel({\n      serviceName: process.env.OTEL_SERVICE_NAME || \"nextjs-app\",\n      traceExporter: process.env.NODE_ENV === \"production\" ? \"otlp\" : \"console\",\n    });\n  }\n\n  if (process.env.NEXT_RUNTIME === \"edge\") {\n    // Edge-compatible observability only\n    console.log(\"Edge runtime detected, skipping Node.js instrumentation\");\n  }\n}\n```\n\nGraceful 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.\n\n``` js\n// instrumentation.ts\nimport { trace } from \"@opentelemetry/api\";\n\nexport async function register() {\n  if (process.env.NEXT_RUNTIME === \"nodejs\") {\n    const { NodeSDK } = await import(\"@opentelemetry/sdk-node\");\n\n    const sdk = new NodeSDK({\n      // configuration\n    });\n\n    await sdk.start();\n\n    process.on(\"SIGTERM\", async () => {\n      try {\n        await sdk.shutdown();\n        console.log(\"OpenTelemetry SDK shut down successfully\");\n      } catch (error) {\n        console.error(\"Error shutting down OpenTelemetry SDK\", error);\n      } finally {\n        process.exit(0);\n      }\n    });\n  }\n}\n```\n\nError 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.\n\nThe 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.\n\n```\n// instrumentation.ts\nexport async function register() {\n  if (process.env.NEXT_RUNTIME !== \"nodejs\") return;\n\n  try {\n    if (!process.env.SENTRY_DSN) {\n      console.warn(\"SENTRY_DSN not set, skipping Sentry initialization\");\n      return;\n    }\n\n    const Sentry = await import(\"@sentry/nextjs\");\n    Sentry.init({\n      dsn: process.env.SENTRY_DSN,\n      environment: process.env.NODE_ENV,\n      tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE || \"0.1\"),\n      beforeSend(event) {\n        if (event.exception?.values?.[0]?.type === \"AbortError\") {\n          return null; // filter noisy client disconnects\n        }\n        return event;\n      },\n    });\n  } catch (error) {\n    console.error(\"Failed to initialize Sentry\", error);\n    // Application continues without Sentry telemetry\n  }\n}\n```\n\nThis 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.\n\nThe `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.\n\nMiddleware 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.\n\nOpenTelemetry 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.\n\nNext.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.\n\nYes. 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.\n\nThe 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.\n\nDirect 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.\n\nThat covers the essential patterns for wiring observability into Next.js 16 applications. Apply these in production and the difference will be immediate.", "url": "https://wpnews.pro/news/next-js-16-instrumentation-ts-is-stable-wiring-opentelemetry-sentry-and-custom-a", "canonical_source": "https://dev.to/jsmanifest/nextjs-16-instrumentationts-is-stable-wiring-opentelemetry-sentry-and-custom-spans-without-a-46dc", "published_at": "2026-09-26 16:58:53+00:00", "updated_at": "2026-09-26 17:29:10.992814+00:00", "lang": "en", "topics": ["developer-tools", "mlops", "ai-infrastructure"], "entities": ["Next.js", "Vercel", "OpenTelemetry", "Sentry", "Prisma", "Drizzle", "@vercel/otel"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/next-js-16-instrumentation-ts-is-stable-wiring-opentelemetry-sentry-and-custom-a", "markdown": "https://wpnews.pro/news/next-js-16-instrumentation-ts-is-stable-wiring-opentelemetry-sentry-and-custom-a.md", "text": "https://wpnews.pro/news/next-js-16-instrumentation-ts-is-stable-wiring-opentelemetry-sentry-and-custom-a.txt", "jsonld": "https://wpnews.pro/news/next-js-16-instrumentation-ts-is-stable-wiring-opentelemetry-sentry-and-custom-a.jsonld"}}