OpenTelemetry Tracing with instrumentation.ts
Register OpenTelemetry via the instrumentation hook to trace server requests and spans.
OpenTelemetry Tracing with instrumentation.ts is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
What Is OpenTelemetry and Why Next.js Needs It
OpenTelemetry (OTel) is a vendor-neutral observability framework that produces traces, metrics, and logs from your application. A trace records the full journey of a single request — from the edge, through Server Components, Server Actions, and database calls — as a tree of spans.
Next.js 15 has built-in, first-class support for OTel via the instrumentation.ts hook. This means you get automatic span creation for:
- App Router page and layout renders
- Route Handler HTTP requests
- Server Actions invocations
- Fetch calls made inside server code
Without tracing you can only guess where latency hides. With tracing you see the exact span — and its duration — that is slow.
Enabling the Instrumentation Hook in next.config.ts
Before instrumentation.ts is picked up, you must opt in. In Next.js 15 the flag is stable, but the config key is still required for older 14-compat setups. Add it to your config:
The file next.config.ts is the TypeScript-native config introduced in Next.js 15. The experimental.instrumentationHook key tells the framework to import instrumentation.ts once when the Node.js server boots — before any request is handled.
After adding this flag, create instrumentation.ts in the project root (same level as app/, not inside it).
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
// Required in Next.js 14; stable & default-true in Next.js 15
// but explicit opt-in avoids version ambiguity
instrumentationHook: true,
},
};
export default nextConfig;Installing OpenTelemetry Packages
The OTel ecosystem is split into many small packages. For a Next.js setup you need:
@opentelemetry/sdk-node— the Node.js SDK that wires everything together@opentelemetry/auto-instrumentations-node— auto-instrumentshttp,fetch,dns, and popular libraries without manual code@opentelemetry/exporter-trace-otlp-http— exports spans over OTLP/HTTP to a collector (Jaeger, Grafana Tempo, Honeycomb, Datadog, etc.)@opentelemetry/resourcesand@opentelemetry/semantic-conventions— describe your service with standard attribute names
Install them all as production dependencies:
// Terminal — install once
// npm install @opentelemetry/sdk-node \
// @opentelemetry/auto-instrumentations-node \
// @opentelemetry/exporter-trace-otlp-http \
// @opentelemetry/resources \
// @opentelemetry/semantic-conventions
// No code to run — this is a shell command reference.
// After install, package.json will contain these entries:
const expectedDeps = [
'@opentelemetry/sdk-node',
'@opentelemetry/auto-instrumentations-node',
'@opentelemetry/exporter-trace-otlp-http',
'@opentelemetry/resources',
'@opentelemetry/semantic-conventions',
];
console.log('Required OTel packages:', expectedDeps);The register() Export — Entry Point for Instrumentation
instrumentation.ts must export a named async function called register(). Next.js calls it exactly once when the server process starts.
Key rules:
- The file runs in the Node.js runtime only — not in the Edge runtime or the browser.
- Use
process.env.NEXT_RUNTIME === 'nodejs'to guard Node-only imports, because Next.js may import the file in other runtimes during analysis. - SDK initialisation must complete synchronously or before the first request —
register()is awaited by the framework.
// instrumentation.ts (project root)
export async function register() {
// Guard: only run the heavy Node.js OTel SDK in the Node runtime.
// Edge runtime does not support 'async_hooks' which OTel depends on.
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Dynamically import so the module is never bundled into Edge chunks.
await import('./instrumentation.node');
}
}Creating instrumentation.node.ts — SDK Initialisation
By convention, the heavy setup lives in a separate file instrumentation.node.ts that is only ever imported inside the Node runtime guard. This keeps your bundle clean.
The NodeSDK class from @opentelemetry/sdk-node is the main entry point. You pass it:
- A resource — metadata about your service (name, version, environment)
- A trace exporter — where spans are sent
- Optional instrumentations — auto-instrument libraries automatically
Call sdk.start() to activate. Register a SIGTERM handler to flush in-flight spans before the process exits.
// instrumentation.node.ts (project root)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'my-nextjs-app',
[ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
}),
traceExporter: new OTLPTraceExporter({
// Default: http://localhost:4318/v1/traces
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
// Disable noisy filesystem instrumentation in Next.js
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
sdk.start();
// Flush spans before the process exits (e.g. during Vercel cold-start teardown)
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});What Spans Next.js Creates Automatically
Once the SDK is running, Next.js 15 emits spans automatically for every server-side operation. You do not write any tracing code in your components. The built-in spans include:
BaseServer.handleRequest— top-level span for every HTTP requestNextNodeServer.findPageComponents— span for resolving which page/layout to renderAppRender.getBodyResult— span covering React server renderingAppRouteRouteHandler.runHandler— span wrapping a Route Handler- Nested fetch spans — each
fetch()call in server code becomes a child span with URL, method, and status
These spans are automatically linked as a parent-child tree so you can see the full call graph in your tracing UI (Jaeger, Grafana Tempo, Honeycomb, etc.).
Adding Custom Spans with @opentelemetry/api
Auto-instrumentation covers HTTP and fetch. For your own business logic — such as a slow database query helper or a complex computation — you create custom spans using @opentelemetry/api.
The pattern is always:
- Get the global tracer:
trace.getTracer('your-scope-name') - Call
tracer.startActiveSpan('span-name', async (span) => { ... }) - Set attributes on the span for searchable metadata
- Always call
span.end()in afinallyblock
Custom spans automatically become children of the currently active span, so they nest correctly in the trace tree.
// lib/db.ts — wrapping a Postgres query with a custom span
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { sql } from '@vercel/postgres'; // or any pg client
const tracer = trace.getTracer('my-nextjs-app/db');
export async function getUserById(id: string) {
return tracer.startActiveSpan('db.getUserById', async (span) => {
span.setAttributes({
'db.system': 'postgresql',
'db.operation': 'SELECT',
'app.user.id': id,
});
try {
const result = await sql`SELECT * FROM users WHERE id = ${id} LIMIT 1`;
span.setStatus({ code: SpanStatusCode.OK });
return result.rows[0] ?? null;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
span.end();
}
});
}Tracing Inside a Server Action
Server Actions run on the server and are wrapped by Next.js in their own HTTP lifecycle. You can add custom spans inside a Server Action just as you would in any server function — the active span context is propagated automatically.
This example creates a span around the database write so you can see separately how long validation took versus the actual INSERT:
// app/actions/create-post.ts
'use server';
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const tracer = trace.getTracer('my-nextjs-app/actions');
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
});
export async function createPost(formData: FormData) {
return tracer.startActiveSpan('action.createPost', async (span) => {
try {
// Validation span — nested child
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
});
if (!parsed.success) {
span.setStatus({ code: SpanStatusCode.ERROR, message: 'Validation failed' });
return { error: parsed.error.flatten() };
}
// Simulate DB insert (replace with real client)
// await db.posts.create({ data: parsed.data });
span.setAttributes({ 'post.title': parsed.data.title });
span.setStatus({ code: SpanStatusCode.OK });
revalidatePath('/posts');
return { success: true };
} finally {
span.end();
}
});
}Propagating Trace Context Across fetch() Calls
When your Next.js server calls an external microservice with fetch(), OTel should inject W3C Trace Context headers (traceparent, tracestate) so the downstream service's spans appear as children in the same trace.
With getNodeAutoInstrumentations() enabled, the @opentelemetry/instrumentation-undici or @opentelemetry/instrumentation-http package handles this automatically for fetch and http calls. You do not need to manually add headers.
To verify propagation is working, inspect the outgoing request headers — you should see:
traceparent: 00-<traceId>-<spanId>-01- The receiving service must also run OTel and read these headers (W3C standard).
// app/api/summary/route.ts — fetch with automatic context propagation
import { NextResponse } from 'next/server';
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('my-nextjs-app/api');
export async function GET() {
return tracer.startActiveSpan('api.getSummary', async (span) => {
try {
// OTel auto-instrumentation injects 'traceparent' header automatically.
// The downstream service will see this request as a child span.
const response = await fetch('https://internal-api.example.com/data', {
next: { revalidate: 60 }, // Next.js cache hint
});
if (!response.ok) {
span.setStatus({ code: 2, message: `Upstream ${response.status}` });
return NextResponse.json({ error: 'upstream failed' }, { status: 502 });
}
const data = await response.json();
span.setStatus({ code: 1 });
return NextResponse.json(data);
} finally {
span.end();
}
});
}Environment Variables and Collector Configuration
The OTel SDK respects the standard OTel environment variables, which means you rarely need to hard-code endpoints in code. Set these in .env.local (development) and your hosting platform's env config (production):
OTEL_SERVICE_NAME— identifies your service in the tracing UIOTEL_EXPORTER_OTLP_ENDPOINT— the collector URL (e.g.http://localhost:4318for a local Jaeger all-in-one)OTEL_TRACES_SAMPLER— e.g.parentbased_traceidratioto sample only a fraction of traces in productionOTEL_TRACES_SAMPLER_ARG— e.g.0.1to sample 10% of traces
For Vercel, install the Vercel OTel integration or use @vercel/otel which wraps the SDK and reads these same env vars from the Vercel dashboard.
# .env.local — development with a local Jaeger all-in-one container
# docker run -d --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one
OTEL_SERVICE_NAME=my-nextjs-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Sample every request in dev, 10% in production
# (set OTEL_TRACES_SAMPLER_ARG=0.1 in prod env)
OTEL_TRACES_SAMPLER=always_on
OTEL_TRACES_SAMPLER_ARG=1Using @vercel/otel for Simplified Setup
Vercel provides @vercel/otel, a thin wrapper around the OTel SDK optimised for Next.js deployments. It reduces boilerplate to a single registerOTel() call and handles Edge runtime compatibility automatically.
When deploying to Vercel, this is the recommended approach. For self-hosted or Docker deployments, the manual NodeSDK setup from earlier scenes gives you more control.
Both approaches produce identical spans — the difference is only in setup complexity and Edge support.
// instrumentation.ts — simplified with @vercel/otel
// npm install @vercel/otel
import { registerOTel } from '@vercel/otel';
export function register() {
// Works in both Node.js and Edge runtimes.
// Reads OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT from env automatically.
registerOTel({
serviceName: process.env.OTEL_SERVICE_NAME ?? 'my-nextjs-app',
// Optionally add custom attributes visible on every span:
attributes: {
'deployment.environment': process.env.VERCEL_ENV ?? 'development',
'deployment.region': process.env.VERCEL_REGION ?? 'local',
},
});
}Knowledge Check: Where Does SDK Initialisation Belong?
You are setting up OpenTelemetry tracing for a Next.js 15 App Router project deployed on a self-hosted Node.js server. Which approach correctly initialises the OTel NodeSDK?
Recap: OpenTelemetry Tracing with instrumentation.ts
In this lesson you learned how to bring production-grade distributed tracing to a Next.js 15 App Router application:
- Enable the hook — set
experimental.instrumentationHook: trueinnext.config.ts(stable default in Next.js 15). - Create instrumentation.ts — export a
register()function at the project root; use it as the single entry point for all server-side initialisation. - Guard the runtime — wrap Node.js-only imports with
process.env.NEXT_RUNTIME === 'nodejs'and use dynamicimport()to prevent Edge bundling. - Initialise NodeSDK in a separate
instrumentation.node.ts— provide aResource, anOTLPTraceExporter, and auto-instrumentations; callsdk.start()once. - Automatic spans — Next.js emits spans for page renders, Route Handlers, Server Actions, and fetch calls with no extra code.
- Custom spans — use
trace.getTracer()andstartActiveSpan()from@opentelemetry/apito instrument your own business logic. - Context propagation — W3C
traceparentheaders are injected automatically into outgoing fetch calls by auto-instrumentation. - Configuration — standard
OTEL_*env vars drive the SDK; no need to hard-code collector URLs in source code.
Frequently asked questions
Is the “OpenTelemetry Tracing with instrumentation.ts” lesson free?
Yes — the full text of “OpenTelemetry Tracing with instrumentation.ts” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.
What will I learn in “OpenTelemetry Tracing with instrumentation.ts”?
Register OpenTelemetry via the instrumentation hook to trace server requests and spans. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?
No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “OpenTelemetry Tracing with instrumentation.ts” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?
Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- OpenTelemetry Tracing with instrumentation.ts
- Granular error.tsx and global-error Boundaries
- Structured Logging Across Server and Edge
- Capturing Server Action Failures and Telemetry