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:

  1. Consistent naming for every service
  2. Tracing for Next.js
  3. Tracing and metrics for a Node.js / Express API
  4. Logs connected to traces
  5. An OpenTelemetry Collector on Kubernetes that filters and scrubs data
  6. 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:

AttributeExampleWhy it matters
service.namecheckout-apiWithout it, everything shows as unknown_service
service.namespacestorefrontGroups services that make up one product
service.version1.42.0 or a Git SHALets you connect errors to the deploy that caused them
deployment.environment.nameproductionKeeps 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 userId itself 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 --require approach 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 filelog receiver on Kubernetes) pick the logs up. The trace_id field 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 (not unknown_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 authorization headers 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

SymptomLikely causeFix
Everything shows as unknown_serviceservice.name not setSet OTEL_SERVICE_NAME
No Express or database spansInstrumentation loaded after the appUse --require so it loads first
Traces break between servicestraceparent header not passedUse instrumented clients; check proxies
Metrics bill or storage explodingHigh-cardinality attributesRemove IDs and full URLs from metric attributes
Data missing on shutdownSDK not flushedCall sdk.shutdown() on SIGTERM
Collector restartingMemory pressureKeep 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:

  1. Point the exporter at the Modulis OTLP endpoint shown during setup.
  2. 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