OpenTelemetry for Next.js and Node.js: A Practical Setup Guide
Instrument a Next.js app and a Node.js/Express API with OpenTelemetry, connect logs to traces, and run a Collector on Kubernetes that filters and scrubs data — with copy-paste config.
By Hiren Patel, Founder & CEO · · 9 min read
OpenTelemetry (OTel) is now the default way to instrument applications. It’s vendor-neutral, supported by every serious backend, and once your code emits OTel, switching where the data goes is a config change instead of a rewrite.
The docs are thorough but spread out. This guide is the practical path for a common stack: a Next.js frontend, a Node.js / Express API, and Kubernetes, ending with logs and traces connected and an OpenTelemetry Collector you control.
What you’ll set up:
- Consistent naming for every service
- Tracing for Next.js
- Tracing and metrics for a Node.js / Express API
- Logs connected to traces
- An OpenTelemetry Collector on Kubernetes that filters and scrubs data
- A quick check that it all works
Step 1: Agree on naming before writing code
This is the step everyone skips, and it’s the one that decides whether your data is usable six months from now. OpenTelemetry describes who is sending data through resource attributes. Set these four on every service:
| Attribute | Example | Why it matters |
|---|---|---|
service.name | checkout-api | Without it, everything shows as unknown_service |
service.namespace | storefront | Groups services that make up one product |
service.version | 1.42.0 or a Git SHA | Lets you connect errors to the deploy that caused them |
deployment.environment.name | production | Keeps staging noise out of production views |
The simplest way to set them is with environment variables, which every OTel SDK reads:
OTEL_SERVICE_NAME=checkout-api
OTEL_RESOURCE_ATTRIBUTES=service.namespace=storefront,service.version=1.42.0,deployment.environment.name=production
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc.cluster.local:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
Note: Use a Git SHA or release tag for
service.version, set by your CI pipeline. It’s the cheapest way to answer “did the last deploy cause this?”
Step 2: Instrument Next.js
Next.js has built-in support for OpenTelemetry through an instrumentation.ts file, which runs once when the server starts. The easiest way to use it is the @vercel/otel package. Despite the name, it works wherever you run Next.js, including your own Kubernetes cluster.
Install:
npm install @vercel/otel @opentelemetry/api
Create instrumentation.ts in your project root (or in src/ if you use a src directory):
// instrumentation.ts
import { registerOTel } from '@vercel/otel';
export function register() {
registerOTel({
serviceName: process.env.OTEL_SERVICE_NAME ?? 'web-frontend',
});
}
That’s enough to get traces for incoming requests, server-side rendering, route handlers, and fetch calls made on the server. The exporter reads the standard OTEL_EXPORTER_OTLP_* environment variables from Step 1.
Add your own spans where you want more detail, for example around a slow piece of business logic:
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('web-frontend');
export async function getRecommendations(userId: string) {
return tracer.startActiveSpan('getRecommendations', async (span) => {
try {
span.setAttribute('app.recommendation.source', 'ml-service');
return await fetchRecommendations(userId);
} catch (err) {
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
}
Note: Don’t put
userIditself on the span as an attribute if you can avoid it. High-cardinality values like user IDs make metrics expensive and can bring personal data into places it doesn’t belong.
Things to know:
- Server-side instrumentation runs in the Node.js runtime. Code on the Edge runtime has limited OTel support, so keep critical paths on Node.js if you need full traces.
- Browser-side tracing is a separate setup. Start with the server; it’s where most production issues show up first.
Step 3: Instrument a Node.js / Express API
For a plain Node.js service, use the OpenTelemetry Node SDK with auto-instrumentations. These hook into common libraries automatically: HTTP, Express, popular database clients, Redis, gRPC, and others.
Install:
npm install @opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/sdk-metrics
Create instrumentation.js:
// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
exportIntervalMillis: 30000,
}),
instrumentations: [
getNodeAutoInstrumentations({
// File-system spans are very noisy and rarely useful
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});
Load it before your app starts. This is the single most common mistake: if your app’s modules load first, auto-instrumentation can’t hook into them.
node --require ./instrumentation.js server.js
In a Dockerfile:
CMD ["node", "--require", "./instrumentation.js", "server.js"]
Prefer zero code? You can skip the file entirely and let the auto-instrumentation package configure itself from environment variables:
node --require @opentelemetry/auto-instrumentations-node/register server.js
Note: The
--requireapproach is for CommonJS apps. If your service uses ES modules ("type": "module"), check the OpenTelemetry JavaScript docs for the ESM loader setup, because the hook works differently.
Step 4: Connect logs to traces
Traces show what happened. Logs often show why. The real value comes when every log line carries the trace_id of the request that produced it, so you can jump straight from a slow span to its logs.
If you use pino or winston, the auto-instrumentations from Step 3 already include integrations that inject trace_id and span_id into each log record. Just make sure you log inside a request context:
const pino = require('pino');
const logger = pino();
app.post('/checkout', async (req, res) => {
logger.info({ cartSize: req.body.items.length }, 'checkout started');
// → {"msg":"checkout started","cartSize":3,"trace_id":"4bf92f...","span_id":"00f067..."}
});
From there you have two options:
- Keep logging to stdout and let a log collector (for example the OpenTelemetry Collector’s
filelogreceiver on Kubernetes) pick the logs up. Thetrace_idfield is already there. - Export logs over OTLP directly from the SDK, so logs, traces, and metrics travel the same path.
Either works. What matters is that trace_id is present on every log line.
Step 5: Run an OpenTelemetry Collector on Kubernetes
You can send data straight from your apps to a backend. Don’t. Put a Collector in between. It gives you one place to:
- Filter noisy data (health checks, debug logs) before it costs anything
- Scrub sensitive fields (auth headers, emails) before they’re stored anywhere
- Batch and retry, so a backend hiccup doesn’t drop data or slow your app
- Switch backends by changing one exporter, without redeploying any services
Use the contrib distribution of the Collector (otelcol-contrib), which includes the processors below. The official OpenTelemetry Helm chart or the OpenTelemetry Operator are the easiest ways to deploy it.
A production-ready starting config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# Protect the Collector itself from running out of memory
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 25
# Drop health-check and readiness-probe spans
filter/healthchecks:
error_mode: ignore
traces:
span:
- 'attributes["url.path"] == "/healthz"'
- 'attributes["url.path"] == "/readyz"'
# Remove or hash sensitive attributes before export
attributes/scrub:
actions:
- key: http.request.header.authorization
action: delete
- key: http.request.header.cookie
action: delete
- key: user.email
action: hash
- key: enduser.id
action: hash
batch:
send_batch_size: 8192
timeout: 5s
exporters:
otlphttp:
endpoint: https://YOUR-OTLP-BACKEND:4318
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/healthchecks, attributes/scrub, batch]
exporters: [otlphttp]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlphttp]
logs:
receivers: [otlp]
processors: [memory_limiter, attributes/scrub, batch]
exporters: [otlphttp]
Why the scrubbing matters: whatever backend or AI tool you use later, data that was never stored can’t leak. Scrubbing at the Collector protects you once, everywhere. (More on this in where your data goes when you turn on an AI SRE agent.)
Next steps once this is running:
- Add the tail sampling processor to keep 100% of error and slow traces and a small share of healthy ones. It’s one of the most effective ways to control cost without losing important data.
- Add the k8sattributes processor to tag every signal with its pod, namespace, and deployment automatically.
Step 6: Check that it works
Generate some traffic, then look for these five things in your backend:
- Each service appears with its real
service.name(notunknown_service) - One request through Next.js → API shows up as a single trace spanning both services
- Log lines from that request carry the same
trace_id - Health-check spans are gone
- No
authorizationheaders or raw emails appear in span attributes
If the trace is split in two, context isn’t propagating between services. Make sure the outgoing call from Next.js uses instrumented fetch or an instrumented HTTP client, and that no proxy in between strips the traceparent header.
Common problems and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Everything shows as unknown_service | service.name not set | Set OTEL_SERVICE_NAME |
| No Express or database spans | Instrumentation loaded after the app | Use --require so it loads first |
| Traces break between services | traceparent header not passed | Use instrumented clients; check proxies |
| Metrics bill or storage exploding | High-cardinality attributes | Remove IDs and full URLs from metric attributes |
| Data missing on shutdown | SDK not flushed | Call sdk.shutdown() on SIGTERM |
| Collector restarting | Memory pressure | Keep memory_limiter first in every pipeline |
Sending your OpenTelemetry data to Modulis
Modulis is built on OpenTelemetry, so everything above works unchanged. Modulis runs inside your own cloud, so your Collector exports to an endpoint in your own cluster instead of an external service.
Two small additions:
- Point the exporter at the Modulis OTLP endpoint shown during setup.
- Tell Modulis which application each service belongs to by adding one resource attribute:
OTEL_RESOURCE_ATTRIBUTES=modulis.application=storefront,service.namespace=storefront,service.version=1.42.0,deployment.environment.name=production
If you don’t set modulis.application, Modulis falls back to service.namespace, which is another reason to set it in Step 1.
From there, Modulis correlates errors, traces, logs, session replays, and Git metadata automatically, so the trace you just verified in Step 6 shows up next to the logs, the user session, and the commit behind it.
Already instrumented with OpenTelemetry? You’re most of the way there. Start free and connect your Collector in minutes, or book a 20-minute walkthrough.
Send your OpenTelemetry data somewhere that connects it
Point your Collector at Modulis, running in your own cloud, and get errors, traces, logs and sessions correlated automatically. Start free.
Related posts
What One Production Incident Really Costs You (With a Calculator)
Most incident cost isn't the fix. It's the hours engineers spend gathering context across tools. Here's a phase-by-phase breakdown and a calculator you can run with your own numbers.
· 8 min read
Before You Turn On an AI SRE Agent: Where Your Code and Logs Actually Go
AI root-cause agents need your stack traces, logs and source code to work. Here's the data path most vendors don't draw for you, 12 questions to ask before you enable one, and what it means for SOC 2, HIPAA and GDPR.
· 7 min read
Why Your Monitoring Bill Grows Faster Than Your Traffic
Your services grew 4x. Your monitoring bill grew 5x. Here's the compounding math behind per-host, per-GB and custom-metric pricing — and a checklist to cut spend this week.
· 8 min read