When a production issue occurs, finding the actual cause can be harder than fixing it.
A request might start in a Next.js frontend, pass through multiple Node.js services, hit a database, and eventually fail somewhere along the way.
You might have logs in one place, traces in another, and error reports somewhere else.
Without proper instrumentation and correlation, debugging becomes a manual exercise.
This is where OpenTelemetry (OTel) becomes useful.
OpenTelemetry provides a vendor-neutral way to collect traces, metrics, and logs from your applications. Once your applications are instrumented, you can change telemetry backends without rewriting your instrumentation.
In this guide, we'll set up OpenTelemetry for a practical production stack:
- Next.js frontend
- Node.js / Express backend
- Distributed tracing
- Logs connected to traces
- Kubernetes OpenTelemetry Collector
- Filtering and sensitive-data scrubbing
By the end, you'll have a foundation for understanding what's happening across your services.
1. Start with consistent service naming
Before installing any packages, establish consistent naming across your services.
OpenTelemetry uses resource attributes to describe which service is generating telemetry.
Here are four attributes worth configuring:
| Attribute | Example | Purpose |
|---|---|---|
service.name | checkout-api | Identifies the service |
service.namespace | storefront | Groups related services |
service.version | 1.42.0 | Connects errors with deployments |
deployment.environment.name | production | Separates production and staging |
Set these through environment variables:
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 A useful practice: Set service.version to your Git SHA or release tag through your CI/CD pipeline.
When an error starts appearing after a deployment, you'll have a much easier time connecting the incident to the relevant release.
2. Instrument your Next.js application
Next.js supports OpenTelemetry through its instrumentation file.
One straightforward approach is using @vercel/otel. Despite the package name, it also works when running Next.js on your own infrastructure.
Install the dependencies:
npm install @vercel/otel @opentelemetry/api Create an instrumentation.ts file in your project root (or inside src/ if your application uses that directory).
import { registerOTel } from '@vercel/otel'; export function register() { registerOTel({ serviceName: process.env.OTEL_SERVICE_NAME ?? 'web-frontend', }); } This provides instrumentation for:
- Incoming requests
- Server-side rendering
- Route handlers
- Server-side fetch calls
The exporter uses the standard OTEL_EXPORTER_OTLP_* environment variables configured earlier.
Add custom spans
Automatic instrumentation is useful, but sometimes you need additional context around specific business operations.
For example, consider a recommendation service:
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(); } } ); } This creates a custom span around the operation and records exceptions when something goes wrong.
Important: Avoid adding sensitive or high-cardinality values such as raw user IDs to span attributes unless there is a justified reason and appropriate data handling.
Also remember that server-side instrumentation and browser-side tracing are separate concerns. Edge runtime instrumentation has limitations, so consider your runtime requirements when designing the setup.
3. Instrument your Node.js / Express API
For a Node.js service, the OpenTelemetry Node SDK and auto-instrumentation packages provide a practical starting point.
They can automatically instrument supported libraries such as HTTP clients, Express, database clients, Redis and gRPC.
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 an instrumentation.js file:
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({ '@opentelemetry/instrumentation-fs': { enabled: false, }, }), ], }); sdk.start(); process.on('SIGTERM', () => { sdk.shutdown().finally(() => process.exit(0)); }); Load instrumentation before your application
This is one of the most important implementation details.
OpenTelemetry auto-instrumentation needs to initialize before the application modules load. Otherwise, it might not correctly hook into Express, database clients or other libraries.
For a CommonJS application, start your service like this:
node --require ./instrumentation.js server.js In your Dockerfile:
CMD ["node", "--require", "./instrumentation.js", "server.js"] For a zero-code approach, the auto-instrumentation package can also register itself:
node --require @opentelemetry/auto-instrumentations-node/register server.js If you're using ES modules ("type": "module"), check the OpenTelemetry JavaScript documentation for the appropriate ESM loader configuration.
4. Connect logs with traces
Collecting traces and logs independently is useful.
Connecting them is much more powerful.
Imagine an API request that takes several seconds to complete.
Your trace tells you which operation was slow, but your application logs might contain the actual error or business context.
The goal is to have every relevant log line carry the trace_id of the request that generated it.
For example, with Pino:
const pino = require('pino'); const logger = pino(); app.post('/checkout', async (req, res) => { logger.info( { cartSize: req.body.items.length }, 'checkout started' ); }); With the appropriate OpenTelemetry log instrumentation and request context, the resulting log record can include:
{ "msg": "checkout started", "cartSize": 3, "trace_id": "4bf92f...", "span_id": "00f067..." } There are two practical ways to collect these logs:
- Continue writing logs to stdout and let a log collector, such as the OpenTelemetry Collector's
filelogreceiver, collect them. - Export logs directly through OTLP from the SDK.
Either approach can work.
The important part is maintaining trace context so that you can move from a trace to its corresponding logs.
5. Deploy an OpenTelemetry Collector on Kubernetes
Your applications can export telemetry directly to a backend.
However, putting an OpenTelemetry Collector between your applications and the backend gives you much better control over the telemetry pipeline.
A Collector allows you to:
- Filter noisy health-check traffic.
- Remove sensitive attributes before export.
- Batch telemetry.
- Retry exports when a backend becomes temporarily unavailable.
- Change telemetry backends without modifying every application.
For Kubernetes deployments, you can use the OpenTelemetry Helm chart or OpenTelemetry Operator.
The following is a starting configuration using the otelcol-contrib distribution.
Collector configuration
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: memory_limiter: check_interval: 1s limit_percentage: 80 spike_limit_percentage: 25 filter/healthchecks: error_mode: ignore traces: span: - 'attributes["url.path"] == "/healthz"' - 'attributes["url.path"] == "/readyz"' 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] Replace the exporter endpoint with your actual backend endpoint.
Why this configuration matters
Memory limiter
Helps protect the Collector from excessive memory usage. It should be placed early in each pipeline.
Health-check filtering
Removes /healthz and /readyz spans from the trace pipeline.
Sensitive-data scrubbing
Removes authorization and cookie headers and hashes selected user attributes before export.
Batch processing
Groups telemetry records before exporting them.
One important consideration: filtering and scrubbing policies should be reviewed against your application's actual data and security requirements.
Additional improvements
Once the basic setup is working, consider adding:
- Tail sampling to retain important error and slow traces while sampling healthy traffic.
- The
k8sattributesprocessor to enrich telemetry with Kubernetes pod, namespace and deployment metadata.
These additions can help make the telemetry pipeline more useful as your infrastructure grows.
6. Verify that your instrumentation works
Don't assume that a running Collector means your instrumentation is correct.
Generate some application traffic and check the following:
- [ ] Every service appears with its actual
service.name. - [ ] A request through Next.js and the API appears as one distributed trace.
- [ ] Log lines contain the same
trace_id. - [ ] Health-check spans are filtered out.
- [ ] Authorization headers and raw emails are not present in exported span attributes.
Common problems
| Symptom | Likely cause | What to check |
|---|---|---|
unknown_service | Service name missing | Set OTEL_SERVICE_NAME |
| No Express or database spans | Instrumentation loaded too late | Use --require |
| Traces split between services | Context propagation failure | Check traceparent |
| Increasing telemetry costs | High-cardinality attributes | Remove unnecessary IDs and full URLs from metric attributes |
| Data missing during shutdown | SDK not flushed | Call sdk.shutdown() on SIGTERM |
| Collector restarts | Memory pressure | Configure memory_limiter early in pipelines |
If traces are breaking between services, check whether the outgoing request is using an instrumented client and whether an intermediate proxy is preserving the traceparent header.
7. What happens after OpenTelemetry is configured?
At this point, you have established the telemetry foundation:
- Your applications emit traces and metrics.
- Logs carry trace context.
- The Collector filters and processes telemetry.
- Sensitive attributes are handled before export.
- Your backend receives correlated signals.
But collecting telemetry is only one part of production debugging.
When an incident occurs, engineers still need to connect the relevant error, trace, logs, user session and deployment context to understand what actually happened.
This is where having correlated engineering signals becomes valuable.
Connecting your OpenTelemetry setup to Modulis
Modulis is an engineering intelligence and incident-resolution platform built on OpenTelemetry.
It runs inside your own cloud and correlates production errors, traces, logs, session replays and Git metadata.
Once your application is instrumented, you can connect your Collector to the Modulis OTLP endpoint provided during setup.
Add the following resource attribute:
OTEL_RESOURCE_ATTRIBUTES=modulis.application=storefront,service.namespace=storefront,service.version=1.42.0,deployment.environment.name=production If modulis.application isn't configured, Modulis falls back to service.namespace.
With the telemetry connected, Modulis brings the related engineering context together. Its AI resolution workflow uses your own model key to identify likely root causes and open PR-ready fixes for your engineers to review and merge.
The deployment runs in your own cloud, and AI resolution uses your own model key.
You can explore the platform at modulis.ai.
Final thoughts
OpenTelemetry provides a solid foundation for understanding what happens across distributed applications.
For teams running Next.js, Node.js and Kubernetes, a well-configured setup can make production debugging considerably more structured.
Start with consistent resource attributes, instrument your applications, connect logs to traces, and introduce a Collector to control the telemetry pipeline.
Most importantly, verify the entire request path instead of assuming that individual services are correctly instrumented.
Collecting telemetry is the first step. Making it useful during an actual production incident is the real goal.
Original implementation guide: https://modulis.ai/blog/opentelemetry-nextjs-nodejs-setup-guide/


