0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

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-instruments http, 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/resources and @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 requestregister() 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 request
  • NextNodeServer.findPageComponents — span for resolving which page/layout to render
  • AppRender.getBodyResult — span covering React server rendering
  • AppRouteRouteHandler.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:

  1. Get the global tracer: trace.getTracer('your-scope-name')
  2. Call tracer.startActiveSpan('span-name', async (span) => { ... })
  3. Set attributes on the span for searchable metadata
  4. Always call span.end() in a finally block

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 UI
  • OTEL_EXPORTER_OTLP_ENDPOINT — the collector URL (e.g. http://localhost:4318 for a local Jaeger all-in-one)
  • OTEL_TRACES_SAMPLER — e.g. parentbased_traceidratio to sample only a fraction of traces in production
  • OTEL_TRACES_SAMPLER_ARG — e.g. 0.1 to 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=1

Using @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: true in next.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 dynamic import() to prevent Edge bundling.
  • Initialise NodeSDK in a separate instrumentation.node.ts — provide a Resource, an OTLPTraceExporter, and auto-instrumentations; call sdk.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() and startActiveSpan() from @opentelemetry/api to instrument your own business logic.
  • Context propagation — W3C traceparent headers 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

  1. OpenTelemetry Tracing with instrumentation.ts
  2. Granular error.tsx and global-error Boundaries
  3. Structured Logging Across Server and Edge
  4. Capturing Server Action Failures and Telemetry
← Back to Next.js 15 Fullstack (App Router + Server Actions)