When a slow request reaches your Next.js server, where does the time go? Is it the database query? The external API call? The Server Component render? The Prisma middleware? Without distributed tracing, answering this question requires guesswork, log scraping, and trial-and-error profiling.
OpenTelemetry (OTel) is the vendor-neutral industry standard for distributed tracing, metrics, and logs. By instrumenting your Next.js application with OTel, every request through your system generates a trace — a time-series record of every operation, with exact durations, parent-child relationships, and the full call stack from the browser click to the database query.
Understanding Distributed Tracing
TRACE: "User checks out"
Browser Server Database
│ │ │
│── fetch /api/cart ──►│ │
│ │── SELECT items ───►│
│ │◄─────────────────── │ (12ms)
│ │── Stripe API ──────►external
│ │◄────────────────── │ (245ms)
│ │── INSERT order ───►│
│ │◄────────────────── │ (8ms)
│◄─ 200 OK ────────── │ │
│
Total: 320ms
├── DB SELECT: 12ms
├── Stripe API: 245ms ← This is the bottleneck
└── DB INSERT: 8msWithout tracing: "checkout is slow" — 45 minutes of debugging. With tracing: "Stripe API call takes 245ms" — 5 minutes to identify.
Installation
pnpm add @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventionsSetting Up the OTel SDK
Create the instrumentation file that Next.js loads before application code:
// instrumentation.ts (Next.js loads this via experimental.instrumentationHook)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
export function register() {
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'my-nextjs-app',
[SEMRESATTRS_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
environment: process.env.NODE_ENV,
}),
// Export traces to your observability platform
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces',
headers: {
'Authorization': `Bearer ${process.env.HONEYCOMB_API_KEY}`,
},
}),
// Auto-instrument common Node.js libraries
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': { enabled: true },
'@opentelemetry/instrumentation-pg': { enabled: true }, // PostgreSQL
'@opentelemetry/instrumentation-fetch': { enabled: true }, // fetch() calls
'@opentelemetry/instrumentation-redis': { enabled: true }, // Redis calls
}),
],
});
sdk.start();
}Enable it in next.config.ts:
// next.config.ts
export default {
experimental: {
instrumentationHook: true,
},
};Adding Custom Spans
Auto-instrumentation captures network and database calls. For business logic, add custom spans:
// lib/tracing.ts
import { trace, SpanStatusCode, context } from '@opentelemetry/api';
const tracer = trace.getTracer('my-nextjs-app', '1.0.0');
// Wrapper for adding custom spans to any async function
export async function traced<T>(
spanName: string,
attributes: Record<string, string | number | boolean>,
fn: () => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(spanName, { attributes }, async (span) => {
try {
const result = await fn();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : 'Unknown error',
});
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}
// app/api/checkout/route.ts
import { traced } from '@/lib/tracing';
export async function POST(request: Request) {
const session = await getServerSession();
const { items, shippingAddress } = await request.json();
return traced(
'checkout.process',
{ 'user.id': session.user.id, 'checkout.item_count': items.length },
async () => {
// Each operation gets its own child span automatically (via auto-instrumentation)
const cart = await traced('checkout.validate_cart', {}, () =>
validateCart(items, session.user.id)
);
const payment = await traced('checkout.charge', {
'payment.amount': cart.total,
'payment.currency': 'usd',
}, () =>
chargeStripe(cart.total, session.user.stripeCustomerId)
);
const order = await traced('checkout.create_order', {
'order.payment_id': payment.id,
}, () =>
createOrder(cart, payment, shippingAddress, session.user.id)
);
return NextResponse.json({ orderId: order.id });
}
);
}Connecting Browser and Server Traces
For true end-to-end tracing (browser → server → database), propagate the trace context from the browser:
// Browser: include trace context in all fetch calls
import { context, propagation } from '@opentelemetry/api';
async function tracedFetch(url: string, options: RequestInit = {}): Promise<Response> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
// Inject W3C Trace Context headers (traceparent, tracestate)
propagation.inject(context.active(), headers);
return fetch(url, { ...options, headers });
}
// Now every fetch call carries the trace context
await tracedFetch('/api/checkout', { method: 'POST', body: JSON.stringify(data) });
// Server: extract and continue the trace from incoming requests
import { propagation, context, trace } from '@opentelemetry/api';
export async function POST(request: Request) {
// Extract the incoming trace context from headers
const extractedContext = propagation.extract(context.active(), {
get: (headers, key) => request.headers.get(key) ?? undefined,
keys: () => [...request.headers.keys()],
});
return context.with(extractedContext, async () => {
// All spans created here are children of the browser span
const span = trace.getActiveSpan();
span?.setAttribute('user.agent', request.headers.get('user-agent') ?? '');
// ... rest of the handler
});
}Choosing an Observability Backend
OTel is vendor-neutral — you own your telemetry data and can send it to any compatible backend:
| Platform | Free Tier | Best For |
|---|---|---|
| Honeycomb | 20M events/month | Developer-friendly query UI |
| Grafana Tempo | Self-host free | Open-source stack |
| Jaeger | Self-host free | Local development |
| Datadog | No free tier | Enterprise monitoring |
| New Relic | 100GB/month | Existing New Relic users |
For Next.js development, Honeycomb is the fastest to get started with and has the most intuitive query interface for exploring traces.
Viewing Traces in Practice
After instrumenting and deploying, a slow checkout trace in Honeycomb shows:
Trace: POST /api/checkout (320ms total)
├── checkout.validate_cart (18ms)
│ ├── pg.query SELECT products (12ms)
│ └── business logic validation (6ms)
├── checkout.charge (285ms) ⚠️ SLOW
│ └── https fetch api.stripe.com (279ms)
└── checkout.create_order (17ms)
├── pg.query INSERT orders (8ms)
└── pg.query UPDATE inventory (9ms)The bottleneck is immediately visible: the Stripe API call takes 279ms out of 320ms total.
Conclusion
OpenTelemetry transforms debugging from "grep logs and hope" into "query traces and know." For Next.js applications, the combination of auto-instrumentation (capturing all HTTP and database calls) and custom spans (capturing business logic) gives you complete visibility into request performance. The W3C trace context propagation closes the loop between browser and server, so you can trace a user's click all the way through to the database query that served it. In production, this capability reduces mean-time-to-resolution for performance incidents from hours to minutes.