Monitoring a Next.js app can get tricky. Client-side rendering, API routes, server components, and edge functions all produce telemetry differently. Without consistent instrumentation, you end up with gaps — partial traces, missing spans, or uncorrelated metrics.
OpenTelemetry standardizes this process. It provides SDKs and instrumentation packages that let you capture performance data across every layer of a Next.js application — from API endpoints and database calls to React rendering and network requests.
In this blog, we cover a complete OpenTelemetry setup for Next.js:
- Initializing the OpenTelemetry SDK and Collector configuration
- Instrumenting API routes, React components, and server handlers
- Exporting traces and metrics to an observability backend
Why Next.js Needs Specialized Observability
Next.js introduces architectural patterns that behave differently from conventional monolithic or single-runtime apps. It combines client-side rendering (CSR), server-side rendering (SSR), static site generation (SSG), and edge runtimes in a single workflow. Each of these layers emits telemetry in its own way — which makes unified observability more complex, not less capable.
Hybrid Rendering Complexity
A single user request in Next.js can touch multiple runtimes. For instance, an SSR route might call an API route, which then fetches data from a database or an external service. Capturing a complete trace across these layers requires instrumentation that understands both browser and Node.js execution paths.
Debugging Across Layers
Without distributed tracing, diagnosing latency issues often means switching between Vercel logs, browser DevTools, and server logs. Teams like Replit reported that full OpenTelemetry instrumentation cut debugging time from hours to minutes by connecting the entire request path into one trace.
Vercel Serverless Constraints
Next.js apps frequently run on serverless platforms with strict execution and memory limits. Observability needs to remain lightweight to avoid cold-start delays or timeouts. The configurations in this guide keep tracing overhead under 10 ms per function, even during initialization.
Managing High-Cardinality Telemetry
Next.js applications tend to produce detailed telemetry — thousands of route variants, hundreds of API endpoints, and multiple middleware layers. This richness is useful for performance analysis. With OpenTelemetry, you can safely handle this volume using controlled attribute sets, dynamic sampling, or streaming aggregation — keeping insights high without sacrificing efficiency.
Prerequisites and Setup
Before adding OpenTelemetry to your Next.js app, make sure you’re running a compatible setup. The SDKs depend on modern Node.js APIs, so you’ll need version 18 or later.
node --version
# Output should show v18.0.0 or higherIf you’re starting fresh, generate a Next.js 13+ project — both the App Router and Pages Router work fine with OpenTelemetry.
npx create-next-app@latest my-app
cd my-appInstall OpenTelemetry Packages
Now, pull in the required OpenTelemetry libraries. These handle trace and metric collection, plus the exporters to send data to your backend.
# Core OpenTelemetry SDK (required)
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-httpYou’ll also need the official Next.js instrumentation package, which hooks into the framework internals:
# Next.js instrumentation (required)
npm install @vercel/otelOptionally, add specific instrumentations for Node’s built-in HTTP module or the Fetch API. These help trace outgoing requests to APIs and external services.
# Optional instrumentations
npm install @opentelemetry/instrumentation-http \
@opentelemetry/instrumentation-fetchCheck Version Compatibility
Finally, confirm that the OpenTelemetry API version aligns with your SDK and instrumentation packages.
Version 1.0.0 or higher is required.
npm list @opentelemetry/api
# Should show 1.0.0 or higherQuick Start: Working Example in Minutes
Here’s a minimal setup that gets traces from your Next.js app visible in your observability backend within minutes.
Step 1: Create an instrumentation file
Next.js 13.4+ includes a built-in instrumentation.ts hook that automatically loads when your app starts.
// instrumentation.ts (or instrumentation.js)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation.node');
}
// Edge runtime instrumentation (if needed)
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./instrumentation.edge');
}
}This entrypoint tells Next.js which runtime-specific instrumentation files to load. It keeps your Node.js and Edge configurations separate while sharing the same initialization flow.
Step 2: Configure Node.js instrumentation
Create an instrumentation.node.ts file to initialize the OpenTelemetry SDK and set up trace exporting.
// instrumentation.node.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'my-nextjs-app',
}),
traceExporter: new OTLPTraceExporter({
// Port 4318 = HTTP (OTLP), 4317 = gRPC
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
headers: {
// Add authentication if required by your backend
'Authorization': `Bearer ${process.env.OTEL_AUTH_TOKEN}`,
},
}),
instrumentations: [
getNodeAutoInstrumentations({
// Disable verbose instrumentations
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
}),
],
});
sdk.start();
console.log('✅ OpenTelemetry instrumentation initialized');
// Graceful shutdown on process termination
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('OpenTelemetry SDK shut down successfully'))
.catch((error) => console.error('Error shutting down OpenTelemetry SDK', error))
.finally(() => process.exit(0));
});This file initializes the NodeSDK, attaches the OTLP trace exporter, and enables automatic instrumentation for core Node modules. It also handles clean shutdowns to ensure spans are flushed before the process exits.
Step 3: Enable instrumentation in Next.js configuration
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
instrumentationHook: true,
},
};
module.exports = nextConfig;This flag activates Next.js’s experimental instrumentationHook, allowing your instrumentation file to load automatically during runtime.
Step 4: Set environment variables
# .env.local
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_SERVICE_NAME=my-nextjs-app
# If using Last9 (or any authenticated backend)
OTEL_AUTH_TOKEN=your_token_hereThese variables configure your trace exporter’s destination and authentication. The OTLP endpoint points to your backend’s /v1/traces path.
Step 5: Verify the setup
# Start the Next.js dev server
npm run dev
# In another terminal, send a test request
curl http://localhost:3000If everything’s configured correctly, you’ll start seeing traces within seconds in your observability backend.
Validation checkpoint: Expect spans for HTTP requests, database queries, and Next.js route handlers.
Production Setup: Sampling, Batching, and Metrics
The dev quick start answers “Does this work?”. Production asks different questions: how much data do we send, how do we keep latency low, how do we compare versions, and what’s the simplest way to maintain this? The setup below keeps the code modular, keeps overhead low, and gives you clean filters in your backend.
What changes from dev to prod
- Sampling: You don’t need every trace in prod. Keep 100% in
development, ~10% inproduction, and a middle ground instaging. Parent-based sampling keeps a full, coherent trace once a decision is made at the root. - Batching: Export spans with a
BatchSpanProcessor. Requests aren’t blocked by network calls; spans flush on a schedule or when a batch fills up. - Metrics: Use a periodic reader so metrics flow on an interval (
10sis a sane default) independent of request volume. - Tuning: Ignore static assets and other noisy paths. Add a couple of attributes (like
http.user_agent) that help you filter without exploding label space. - Context: Tag spans and metrics with
service.name,service.version, anddeployment.environmentso you can compare releases and environments in one click.
1) Environment-aware sampling
// sampler.ts
import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
export function getSampler() {
const env = process.env.NODE_ENV;
if (env === 'development') return new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(1.0) });
if (env === 'production') return new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(0.10) });
return new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(0.50) }); // staging/default
}Here, the root sampler decides whether to keep a trace; every child span follows that decision. In development, you keep everything for easy debugging. In production, 0.10 captures a representative slice without overwhelming storage or dashboards. Keep error visibility by adding span status or error events—those still appear within sampled traces. If you need strict error capture, later you can add tail-based sampling at the Collector; for app-side setup, this head sampler is a solid baseline.
2) Batch spans to reduce request latency
// tracing/exporters.ts
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
export const spanProcessor = new BatchSpanProcessor(
new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces',
headers: { Authorization: `Bearer ${process.env.OTEL_AUTH_TOKEN ?? ''}` },
}),
{
scheduledDelayMillis: 5000,
maxExportBatchSize: 512,
maxQueueSize: 2048,
exportTimeoutMillis: 30000,
}
);Instead of exporting every span as it finishes, spans are buffered and sent in groups every 5s, or as soon as the batch reaches 512 items. This removes export work from the hot path and keeps handlers fast. The queue (maxQueueSize) is the safety net if you get a burst of spans; once full, the oldest spans are dropped to protect your app. If your functions are short-lived (serverless), this still works: a shutdown hook flushes what’s left before the process exits.
3) Export metrics on an interval
// metrics/exporter.ts
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
export const metricReader = new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? 'http://localhost:4318/v1/metrics',
headers: { Authorization: `Bearer ${process.env.OTEL_AUTH_TOKEN ?? ''}` },
}),
exportIntervalMillis: 10_000,
});In this step, metrics are collected in-process and shipped every 10s, regardless of traffic. That keeps metric export predictable and avoids tying metric delivery to request timing. If you track custom app metrics later (e.g., cache hit rate), they will ride the same pipeline. If your backend enforces rate limits, raise the interval to 30s and you’re done—no code changes elsewhere.
4) Tag spans and metrics with version and environment
// tracing/resource.ts
import { Resource } from '@opentelemetry/resources';
import {
SEMRESATTRS_SERVICE_NAME,
SEMRESATTRS_SERVICE_VERSION,
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
} from '@opentelemetry/semantic-conventions';
export const resource = new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'my-nextjs-app',
[SEMRESATTRS_SERVICE_VERSION]: process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev',
[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV ?? 'development',
});Every span and metric carries the same three attributes, so your backend can group and filter reliably. service.version lets you answer questions like “did latency regress after commit X?” without building custom queries. deployment.environment keeps staging and production separate but comparable. If you deploy multiple regions, you can add a cloud.region attribute here without touching instrumentation elsewhere.
5) Tune auto-instrumentations for Next.js
// tracing/instrumentations.ts
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
export const instrumentations = getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingPaths: ['/_next/static', '/_next/image', '/favicon.ico'],
requestHook: (span, req: any) => {
span.setAttribute('http.user_agent', req.headers?.['user-agent'] ?? '');
},
},
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
});Static assets and icons can create a flood of short, low-value spans; skipping them keeps traces readable. The requestHook adds one lightweight attribute that’s handy for filtering by client type without exploding label space. Disabling fs and dns trims noise common in web apps; if you actually profile filesystem or resolver latency, re-enable them for that use case only. You get a focused trace graph where application calls stand out.
6) Wire it together in the Node runtime
// instrumentation.node.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { resource } from './tracing/resource';
import { spanProcessor } from './tracing/exporters';
import { metricReader } from './metrics/exporter';
import { getSampler } from './sampler';
import { instrumentations } from './tracing/instrumentations';
const sdk = new NodeSDK({
resource,
sampler: getSampler(),
spanProcessor,
metricReader,
instrumentations,
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});
export default sdk;This is the single place your server runtime boots telemetry. The SDK attaches the sampler, the batching exporter, and the metric reader, then starts collecting. The SIGTERM hook makes sure spans and metrics flush before the process exits—useful for serverless lifecycles and container shutdowns alike. Keeping this file thin and declarative makes reviews and audits straightforward.
7) Production environment variables
# .env.production
OTEL_SERVICE_NAME=my-nextjs-app
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://your-backend.com/v1/traces
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://your-backend.com/v1/metrics
OTEL_AUTH_TOKEN=your_production_token
# Vercel usually sets these:
# VERCEL_GIT_COMMIT_SHA=abc123...
# NODE_ENV=productionEndpoints and tokens live outside your code, so you can promote the same build through environments. The service.name comes from env as well, which keeps names consistent across repos and CI pipelines. If you rotate tokens, only the secret changes; the app code and containers stay the same. For multi-region deployments, add region variables here and attach them as resource attributes.
Performance notes
- Latency: With batching, export work is off the request path; the typical added latency is
<10 msper request in steady state. - Memory: The example queue (
maxQueueSize: 2048) uses about~20 MB; size scales with span volume and payload size. - Cold starts: Serverless functions still benefit because flushing happens on shutdown; if you see slow starts, lower
maxExportBatchSizeslightly or raisescheduledDelayMillis. - Traffic spikes: If the queue fills during a spike, spans drop from the oldest entries first; logs from the SDK will tell you if this happens, so you can tune.
OpenTelemetry compares with traditional APM systems and how both can work together, see our article on OpenTelemetry and APM.Instrument the App Router (Next.js 13+)
The App Router is the core of modern Next.js. It brings Server Components and Server Actions, allowing parts of your UI to render on the server while streaming updates to the client. This shift improves performance—but it changes how you collect telemetry.
Unlike the traditional pages/ router, the App Router executes logic across different runtimes: React Server Components, API routes, and middleware. A single user request can touch all three, making observability trickier.
That’s why OpenTelemetry instrumentation for the App Router deserves special attention—you’re tracking execution that hops between layers of the framework.
Server Components and Server Actions
Server Components render on the server. You can wrap them in spans to see where time is spent during rendering or data fetching.
// app/products/[id]/page.tsx
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { db } from '@/lib/db';
export default async function ProductPage({ params }: { params: { id: string } }) {
const tracer = trace.getTracer('nextjs-app');
return tracer.startActiveSpan('fetch-product', async (span) => {
try {
span.setAttribute('product.id', params.id);
span.setAttribute('db.system', 'postgres');
span.setAttribute('db.operation', 'SELECT');
const product = await db.product.findUnique({ where: { id: params.id } });
span.setAttribute('product.found', Boolean(product));
span.setStatus({ code: SpanStatusCode.OK });
return product ? <div>{product.name}</div> : <div>Product not found</div>;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
span.end();
}
});
}This creates a span for a database query inside a Server Component. Semantic attributes like db.system and db.operation make your traces more searchable. The span ends manually because React doesn’t manage its lifecycle for you.
API Routes (App Router)
API routes live under app/api/ and share the same tracing context as your components. That means a trace can start from a page render and continue into an API call without losing context.
// app/api/users/route.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
const tracer = trace.getTracer('nextjs-api');
return tracer.startActiveSpan('api.get.users', async (span) => {
try {
const limit = request.nextUrl.searchParams.get('limit') ?? '10';
span.setAttribute('http.method', 'GET');
span.setAttribute('http.route', '/api/users');
span.setAttribute('query.limit', limit);
const users = await db.user.findMany({ take: parseInt(limit, 10) });
span.setAttribute('users.count', users.length);
span.setStatus({ code: SpanStatusCode.OK });
return NextResponse.json({ users });
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
} finally {
span.end();
}
});
}The startActiveSpan wrapper ensures all downstream operations (like db.user.findMany) are linked to this parent span. The trace shows each API call as a distinct node in the tree, with full HTTP metadata attached.
Middleware Instrumentation
Middleware is the entry point for most requests—it runs before routes or components.
Adding spans here helps track request routing, redirects, and security checks early in the lifecycle.
// middleware.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const tracer = trace.getTracer('nextjs-middleware');
const span = tracer.startSpan('middleware');
try {
span.setAttribute('http.url', request.url);
span.setAttribute('http.method', request.method);
const token = request.cookies.get('auth-token');
span.setAttribute('auth.token_present', Boolean(token));
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
span.setAttribute('middleware.action', 'redirect:/login');
span.setStatus({ code: SpanStatusCode.OK });
return NextResponse.redirect(new URL('/login', request.url));
}
span.setAttribute('middleware.action', 'next');
span.setStatus({ code: SpanStatusCode.OK });
return NextResponse.next();
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
span.end();
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};The middleware span captures request-level metadata and decisions like “redirect vs continue.”
It’s also the best place to add cross-cutting attributes such as user session IDs or feature flags—just make sure to keep them low-cardinality.
Why This is Important
Next.js’s App Router blurs the old frontend–backend boundary. A single render path might include middleware, Server Components, API routes, and external services—all in one request. Without explicit spans, parts of that journey disappear from your traces.
By adding instrumentation at these key points, you can:
- See which server actions or components slow down renders.
- Correlate API calls with originating pages.
- Capture auth or routing logic in middleware traces.
The result is a connected trace that mirrors how your Next.js app actually runs—not just how it looks in code.
Next.js, it helps to know how data actually moves through collectors and agents—our guide on OpenTelemetry agents explains that workflow in detail.Instrument the Pages Router (Legacy)
Many production apps still run on the Pages Router, especially large codebases that haven’t migrated to Next.js 13+. While the App Router introduces a new rendering model, the Pages Router continues to power mature, stable deployments — and it still benefits from OpenTelemetry instrumentation.
Instrumentation for the Pages Router is straightforward because it relies entirely on Node.js APIs.
Here’s how to add tracing to two key places: API Routes and server-rendered pages.
API Routes (Pages Router)
Pages Router API routes live under pages/api/.
Each file defines an endpoint that runs entirely in the Node.js runtime, so you can use @opentelemetry/api without extra configuration.
// pages/api/products.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';
import type { NextApiRequest, NextApiResponse } from 'next';
import { db } from '@/lib/db';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const tracer = trace.getTracer('nextjs-pages-api');
return tracer.startActiveSpan('api.products', async (span) => {
try {
span.setAttribute('http.method', req.method);
span.setAttribute('http.route', '/api/products');
if (req.method !== 'GET') {
span.setStatus({ code: SpanStatusCode.OK });
return res.status(405).json({ error: 'Method not allowed' });
}
const products = await db.product.findMany();
span.setAttribute('products.count', products.length);
span.setStatus({ code: SpanStatusCode.OK });
return res.status(200).json({ products });
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
return res.status(500).json({ error: 'Internal server error' });
} finally {
span.end();
}
});
}This span wraps the API handler logic, attaching http.method, http.route, and lightweight business attributes like products.count.
Since the Pages Router runs inside Node.js, downstream instrumentations (HTTP, Prisma, etc.) automatically connect to this span, building a complete trace from request to database.
getServerSideProps Instrumentation
getServerSideProps runs before rendering a page and is a common source of hidden latency.
By adding a span here, you can measure how long data loading takes for each page request.
// pages/posts/[id].tsx
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { GetServerSideProps } from 'next';
import { db } from '@/lib/db';
export const getServerSideProps: GetServerSideProps = async (context) => {
const tracer = trace.getTracer('nextjs-pages');
return tracer.startActiveSpan('getServerSideProps.post', async (span) => {
try {
const { id } = context.params as { id: string };
span.setAttribute('post.id', id);
const post = await db.post.findUnique({
where: { id },
include: { author: true },
});
span.setAttribute('post.found', Boolean(post));
if (!post) return { notFound: true };
span.setAttribute('post.author', post.author.name);
span.setStatus({ code: SpanStatusCode.OK });
return { props: { post } };
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
span.end();
}
});
};This adds a span around your data-fetching logic.
You’ll see getServerSideProps as a node in the trace tree, making it easy to spot slow queries or external API calls before rendering starts.
Instrument the Edge Runtime
Edge functions run in a lightweight, non-Node.js environment. They don’t support @opentelemetry/sdk-node, file system access, or large dependencies.
Instead, use the @vercel/otel package, which provides an Edge-compatible SDK that exports traces over HTTP.
// instrumentation.edge.ts
import { registerOTel } from '@vercel/otel';
registerOTel({
serviceName: 'my-nextjs-app-edge',
});
console.log('✅ Edge runtime OpenTelemetry initialized');This registers a minimal OpenTelemetry client suitable for Edge functions.
Then, instrument your Edge routes just like normal routes — using @opentelemetry/api for spans.
// app/api/edge/route.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';
export const runtime = 'edge';
export async function GET(request: Request) {
const tracer = trace.getTracer('nextjs-edge');
return tracer.startActiveSpan('edge.function', async (span) => {
try {
span.setAttribute('runtime', 'edge');
const response = await fetch('https://api.example.com/data');
const data = await response.json();
span.setAttribute('api.status', response.status);
span.setStatus({ code: SpanStatusCode.OK });
return new Response(JSON.stringify(data), {
headers: { 'content-type': 'application/json' },
});
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
return new Response('Error', { status: 500 });
} finally {
span.end();
}
});
}The Edge runtime doesn’t have a persistent process or a background queue.
Each function runs, sends its spans over HTTP, and exits. Keeping spans lightweight and batching minimal is key.
Attributes like runtime=edge help you separate these spans in your backend for easier analysis.
Here are a few Edge Runtime limitations:
- No
@opentelemetry/sdk-node— only@vercel/otelor@opentelemetry/sdk-trace-webworks. - Keep bundles small — Edge environments have strict size limits.
- No file system or long-lived queues — export spans immediately.
If you rely on both Node and Edge routes, configure them separately.
Use instrumentation.node.ts for server traces, and instrumentation.edge.ts for Edge handlers. Together, they give you a full picture across both environments.
trace_id and span_id alongside application fields, see our guide on structured logging.Structured Logging with Trace Correlation
Logs tell you what happened. Traces tell you when and where it happened.
When you connect them, debugging stops being guesswork.
You can add trace and span identifiers to your logs using Pino and OpenTelemetry’s context API. This helps you move from a log entry directly to the distributed trace it belongs to.
// lib/logger.ts
import { trace, context } from '@opentelemetry/api';
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
log(object) {
const span = trace.getSpan(context.active());
if (!span) return object;
const spanContext = span.spanContext();
return {
...object,
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
trace_flags: spanContext.traceFlags,
};
},
},
});
export default logger;When a span is active, this logger automatically includes its trace identifiers in every message. No extra code changes—just richer, more searchable logs.
// app/api/orders/route.ts
import logger from '@/lib/logger';
import { trace } from '@opentelemetry/api';
import { db } from '@/lib/db';
export async function POST(request: Request) {
const tracer = trace.getTracer('orders-api');
return tracer.startActiveSpan('create-order', async (span) => {
try {
const body = await request.json();
logger.info({ order_data: body }, 'Creating order');
const order = await db.order.create({ data: body });
span.setAttribute('order.id', order.id);
span.setAttribute('order.total', order.total);
logger.info(
{ order_id: order.id, order_total: order.total },
'Order created'
);
return Response.json({ order });
} catch (err) {
logger.error({ err }, 'Order creation failed');
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
}When an error appears in your logs, the trace_id links it back to the exact request and span in your observability backend. This closes the gap between “what failed” and “where it failed.”
Move from Logs to Metrics
Logs and traces help you understand a single request. Metrics show you patterns across thousands of them. Combining the three gives you a complete view—what happened, why it happened, and how often it happens.
You can use OpenTelemetry’s Metrics API to instrument key parts of your business logic.
// lib/metrics.ts
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('my-nextjs-app');
// Counter: track totals
export const orderCounter = meter.createCounter('orders.created', {
description: 'Total number of orders created',
});
// Histogram: measure distributions
export const orderValueHistogram = meter.createHistogram('orders.value', {
description: 'Distribution of order values',
unit: 'USD',
});
// UpDownCounter: track fluctuating values
export const activeUsersGauge = meter.createUpDownCounter('users.active', {
description: 'Number of currently active users',
});Use these metrics wherever your business logic runs—API routes, background jobs, or server actions.
// app/api/orders/route.ts
import { orderCounter, orderValueHistogram } from '@/lib/metrics';
import { db } from '@/lib/db';
export async function POST(request: Request) {
const body = await request.json();
const order = await db.order.create({ data: body });
orderCounter.add(1, {
'order.currency': order.currency,
'order.country': body.shipping_country,
});
orderValueHistogram.record(order.total, {
'order.currency': order.currency,
});
return Response.json({ order });
}These metrics tell you more than just system performance—they quantify how your application behaves. For example, you can measure how order volume changes by country or how average order value trends over time.
About Cardinality and Cost
Metrics with detailed labels (like user_id or customer_id) can quickly increase time-series counts in traditional systems. And here Last9 helps you. We built this platform to handle high-cardinality telemetry efficiently, so you can add meaningful attributes without cutting corners or losing visibility.
This means you can track metrics at the level that actually reflects your users’ behavior—without worrying about sampling, aggregation, or cost overruns.
Troubleshoot OpenTelemetry Setup for Next.js
Even with the right configuration, telemetry data doesn’t always flow as expected.
You may not see traces in your backend, some spans may be missing, or the application might experience higher latency than usual.
This section outlines how to verify each stage of your OpenTelemetry setup, identify where data might be dropping, and bring your telemetry pipeline to a stable, predictable state across environments.
No traces appear in your backend
When traces don’t show up at all, begin with the setup fundamentals before looking deeper into exporters or backends.
Instrumentation initialization
Each time your Next.js app starts, the console should print a confirmation message:
✅ OpenTelemetry instrumentation initializedIf this line doesn’t appear, instrumentation likely isn’t loading. Common causes include:
- The flag
experimental.instrumentationHookisn’t enabled innext.config.js. - The file
instrumentation.tsis missing from the project root. - The application is running on a Next.js version older than 13.4.
Exporter connectivity
Confirm your backend or OpenTelemetry Collector is reachable by sending a lightweight test request:
curl -X POST http://localhost:4318/v1/traces \
-H "Content-Type: application/json" \
-d '{"resourceSpans":[]}'A successful 200 OK response means the exporter can reach your OTLP endpoint.
If it fails, check collector availability and network access.
Port configuration
OTLP exporters can use either HTTP or gRPC, and it’s easy to mix up their ports.
Use 4318 for HTTP and 4317 for gRPC.
// Incorrect - gRPC port used with HTTP exporter
url: 'http://localhost:4317/v1/traces'
// Correct - HTTP port for OTLP exporter
url: 'http://localhost:4318/v1/traces'Span creation validation
If connectivity looks fine but traces still don’t appear, verify that spans are being created locally:
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('debug');
const span = tracer.startSpan('test-span');
console.log('Span created:', span.spanContext());
span.end();If this logs a span ID successfully, instrumentation is running; the issue lies in data export or backend ingestion.
Next.js instrumentation issues instantly, right from your IDE, with AI and Last9 MCP. Bring real-time context, traces, metrics, and logs, from your production environment into your local setup to debug and resolve issues faster.Traces appear but are incomplete
When traces show up but key spans are missing, it usually means part of the application isn’t instrumented or context isn’t propagated correctly.
Database queries missing
Most ORMs and database clients need their own instrumentation packages.
For example:
npm install @prisma/instrumentation
# or for PostgreSQL or MongoDB
npm install @opentelemetry/instrumentation-pg
npm install @opentelemetry/instrumentation-mongodbThen register them in your SDK:
import { PrismaInstrumentation } from '@prisma/instrumentation';
instrumentations: [
getNodeAutoInstrumentations(),
new PrismaInstrumentation(), // Adds database spans
],Disconnected middleware traces
Middleware often runs outside of an existing context.
To keep middleware spans linked to the main trace, extract and re-inject the incoming context:
import { context, trace, propagation } from '@opentelemetry/api';
export function middleware(request: NextRequest) {
const ctx = propagation.extract(context.active(), request.headers);
return context.with(ctx, () => {
const tracer = trace.getTracer('middleware');
const span = tracer.startSpan('middleware');
try {
// Middleware logic
return NextResponse.next();
} finally {
span.end();
}
});
}This preserves trace continuity across layers.
High memory usage
Memory growth or out-of-memory crashes often indicate the SDK is holding spans too long or sampling too aggressively.
Unbounded span queue
By default, the span processor queue is unlimited, which can accumulate thousands of spans in memory.
Limit it explicitly:
spanProcessor: new BatchSpanProcessor(exporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
}),This caps pending spans and ensures regular flushing.
Full sampling in production
Tracing every request is unnecessary and expensive. A 100% sampling configuration increases CPU usage and export volume:
sampler: new TraceIdRatioBasedSampler(1.0) // Local testing onlyIn production, a smaller ratio is sufficient:
sampler: new TraceIdRatioBasedSampler(0.1)At 10%, traces remain statistically representative while keeping overhead low.
High latency or performance overhead
If request latency increases after enabling OpenTelemetry, the most common cause is synchronous exporting or overly detailed instrumentation.
Synchronous span exports
Avoid the SimpleSpanProcessor, which exports spans immediately on every request.
Use BatchSpanProcessor instead for asynchronous, buffered exports:
// Avoid
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
// Use
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';Noisy instrumentation
Some libraries, especially those tracing file system or DNS operations, create too many spans.
You can disable them safely:
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-net': { enabled: false },
}),
],This keeps traces concise and focused on application-level activity.
Edge function traces missing
Edge runtimes cannot use Node.js-specific packages.
If you encounter:
Cannot find module '@opentelemetry/sdk-node'switch to the Edge-compatible SDK:
npm install @vercel/otelThen initialize tracing in instrumentation.edge.ts:
import { registerOTel } from '@vercel/otel';
registerOTel({ serviceName: 'my-nextjs-app-edge' });This approach works across Vercel Edge and other similar runtimes where Node APIs aren’t available.
Vercel deployment issues
Instrumentation not bundled in deployment
Vercel may omit tracing files during the build process.
Ensure they’re explicitly included:
// next.config.js
const nextConfig = {
experimental: {
instrumentationHook: true,
},
outputFileTracingIncludes: {
'/': ['./instrumentation.node.ts'],
},
};Cold start initialization delays
Serverless environments enforce strict startup limits.
If initialization exceeds 10 seconds, load instrumentation lazily:
let sdkInitialized = false;
export async function register() {
if (sdkInitialized) return;
await import('./instrumentation.node');
sdkInitialized = true;
}This ensures initialization happens once per container, not per request.
Final Thoughts
OpenTelemetry gives your Next.js stack the observability foundation it needs — unified instrumentation across pages, server actions, API routes, and edge functions. But production environments demand more than visibility; they require scalability, cost control, and consistent signal quality.
That’s exactly what we’ve built at Last9.
Our platform takes the telemetry you generate and turns it into actionable insight through a technically grounded set of capabilities:
Dynamic cost governance – our AI-driven control plane continuously optimizes retention, aggregation, and sampling:
sampling:
default: 0.2
rules:
- match: "error"
ratio: 1.0
- match: "edge"
ratio: 0.5These policies ensure you capture meaningful telemetry while keeping data volume predictable.
Unified correlation layer – traces, metrics, and logs share a single context, making debugging across server, edge, and browser layers seamless.
{
"trace_id": "8a3c60f7d1884a32bda7aa23d5a7a93b",
"span_id": "5c8f1d6f4d5e9b12",
"message": "Order created successfully"
}This lets you jump directly from an application log to the corresponding distributed trace.
Streaming aggregation – we process high-cardinality data (like dynamic routes, user attributes, and geo labels) at ingestion:
http_request_total{route="/products/[id]", region="us-west", user_id="u12345"}These labels remain queryable without adding overhead or losing detail.
Native OTLP ingestion – every span, metric, and log from your Next.js app flows directly to our backend without protocol conversion or custom exporters.
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.last9.io/v1/traces
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://api.last9.io/v1/metricsYou can use the same OTLP configuration you’ve already set up — no vendor lock-in, no new SDKs.
If your team is building on hybrid frameworks like Next.js, these capabilities ensure observability remains both accurate and efficient — whether your code runs on Node, Edge, or the browser.
Once your instrumentation is stable, connect it to our platform and watch your telemetry come alive — complete, correlated, and ready for production analysis.
And if you’re stuck at any point, connect with our team — we’ll help you validate your setup, fine-tune configurations, and get the most out of your observability data.
FAQs
Q: Does this setup work with Vercel, Netlify, or AWS Lambda?
Yes. OpenTelemetry runs across all major runtimes — Vercel, Netlify, AWS Lambda, Docker, and any Node.js environment. For Edge functions, replace the Node.js SDK with @vercel/otel. Everything else works the same.
Q: What’s the performance impact?
With BatchSpanProcessor and optimized batching, you’ll typically see less than 10 ms P99 latency and around 10 MB additional memory usage.
That’s negligible for most production workloads.
Q: Can I use this with the Pages Router?
Absolutely. The instrumentation.ts hook works for both the App Router and the legacy Pages Router, so you don’t need separate setups.
Q: Do I need to instrument every route manually?
No. Auto-instrumentation already tracks incoming HTTP requests, database queries, and outbound API calls.
Manual spans are useful only when you want to add business context — for example, labeling an order flow or tracking checkout performance.
Q: How should I handle PII or sensitive data?
Avoid putting sensitive fields (like emails or credit cards) in span attributes or logs.
Stick to non-identifiable keys such as user.id or session.id.
If you need stricter control, you can filter attributes at the SDK level before export.
Q: What happens if my observability backend is temporarily unavailable?
The SDK queues span in memory until the queue limit is reached.
If the limit is hit, new spans are dropped — your app keeps running normally.
Telemetry should never block your user flow.
Q: Can I send data to multiple backends?
Yes. You can attach multiple exporters using MultiSpanProcessor:
import { MultiSpanProcessor } from '@opentelemetry/sdk-trace-base';
spanProcessor: new MultiSpanProcessor([
new BatchSpanProcessor(last9Exporter),
new BatchSpanProcessor(jaegerExporter),
]),This is common when using Last9 for production and Jaeger locally.
Q: Does auto-instrumentation support Prisma, TypeORM, or Drizzle?
Yes — partially.
Prisma supports tracing via @prisma/instrumentation.
TypeORM and Drizzle need light manual wrapping around queries, but OpenTelemetry packages exist for most major ORMs.
Q: Can I see traces locally before deploying?
Yes. Run Jaeger via Docker Compose and point your app to the local OTLP endpoint:
http://localhost:4318Then open the Jaeger UI at:
http://localhost:16686You’ll see your traces as soon as you start making requests.
You can also connect the same setup to Last9 — our platform offers full OTLP compatibility, native high-cardinality support, and zero-effort trace correlation.
Start a free trial and get started with 100M events every month!