A Guide to Integrating OpenTelemetry into Node.js: Setting Up Tracing, Metrics, and Logs for Microservices

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Context & Why You Need OpenTelemetry in Microservices

When a Node.js application still runs as a monolith, debugging issues or measuring performance is fairly straightforward. You only need to inspect a centralized log file or attach a basic APM agent to immediately inspect the call stack. However, once you break down the system into 5–10 microservices running inside Docker containers or Kubernetes pods, things change dramatically.

Imagine a familiar scenario: a user clicks the “Checkout” button. This request passes through the API Gateway, routes to the User Service to verify the JWT (taking 15ms), hits the Order Service to create an order, calls the Payment Service to charge the card, and finally emits an event to the Notification Service via Kafka. Suddenly, one link in the chain stalls for 2.5 seconds or throws a 500 error.

Where do you start tracing the issue? SSH into each pod to grep log files by timestamp? This approach can eat up hours and makes it painfully easy to lose the context between asynchronous processes.

The core issue lies in context propagation. Each service generates logs independently, each with its own disparate format. Worse, the system lacks a unified trace_id that correlates the entire request journey.

As the system scales to dozens of services processing 2,000–5,000 req/sec, identifying the root cause without a centralized observability solution becomes a nightmare. That is why OpenTelemetry (OTel) has emerged as the industry standard. Backed by the CNCF, OTel delivers a vendor-neutral set of APIs and SDKs to capture all three pillars of observability: Traces, Metrics, and Logs. The biggest advantage: you have complete freedom to switch backends (Jaeger, Grafana Tempo, Datadog) without worrying about vendor lock-in.

Installing OpenTelemetry Packages for Node.js

The OpenTelemetry ecosystem for Node.js is highly modular. You do not need to manually instrument every Express route or PostgreSQL query. The auto-instrumentation package automatically hooks into popular libraries such as Express, Fastify, HTTP, pg, Redis, and Mongoose.

Open a terminal in your Node.js project directory and install the core libraries along with standard OTLP (OpenTelemetry Protocol) exporters:

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Specific roles of each package:

  • @opentelemetry/sdk-node: The primary SDK managing the lifecycle and telemetry configuration within the Node.js process.
  • @opentelemetry/auto-instrumentations-node: A bundle of plugins that automatically instruments traces for most popular libraries without modifying business logic.
  • @opentelemetry/exporter-*-otlp-http: Exports trace and metric telemetry over HTTP to an OTel Collector or storage backend.
  • @opentelemetry/resources & semantic-conventions: Attaches identifying service metadata such as service name, version, and deployment environment (production/staging).

Detailed Configuration: Tracing, Metrics, and Logs

A cardinal rule: The tracing initialization file must execute before any of your application source code. If you require Express or Mongoose before the SDK is loaded into memory, monkey-patching will fail to take effect.

1. Setting Up Distributed Tracing (tracer.js)

Create a tracer.js file in your project’s root directory:

// tracer.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { Resource } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');

const traceExporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
});

const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || 'order-service',
    [ATTR_SERVICE_VERSION]: '1.0.0',
  }),
  traceExporter,
  instrumentations: [
    getNodeAutoInstrumentations({
      // Disable fs instrumentation to avoid generating excessive spans and save RAM
      '@opentelemetry/instrumentation-fs': { enabled: false },
    }),
  ],
});

sdk.start();

// Gracefully shut down when the container or process terminates
process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('OpenTelemetry SDK shut down successfully'))
    .catch((err) => console.error('Error shutting down OTel SDK', err))
    .finally(() => process.exit(0));
});

2. Adding Metrics to Measure System Performance

While traces drill down into individual requests, metrics provide the high-level picture: throughput (RPS), 5xx error rates, and P95/P99 latency. Simply attach a MetricReader to the NodeSDK configuration:

const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');

const metricExporter = new OTLPMetricExporter({
  url: process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT || 'http://localhost:4318/v1/metrics',
});

const metricReader = new PeriodicExportingMetricReader({
  exporter: metricExporter,
  exportIntervalMillis: 15000, // Periodically export data every 15 seconds
});

// Add metricReader to NodeSDK options in tracer.js:
// const sdk = new NodeSDK({
//   ...
//   metricReader,
// });

For custom business metrics such as counting completed orders, call the Metrics API directly:

const { metrics } = require('@opentelemetry/api');

const meter = metrics.getMeter('order-meter');
const orderCounter = meter.createCounter('orders_completed_total', {
  description: 'Total number of completed orders',
});

// Increment counter when an order is processed successfully
orderCounter.add(1, { payment_method: 'credit_card', status: 'success' });

3. Correlating and Standardizing Logs with Trace IDs

The greatest benefit of unifying observability data is trace-log correlation. When spotting an error log, you can simply copy its trace_id and paste it into Grafana or Jaeger to instantly view the entire waterfall trace for that request.

Using the winston logger library, we can inject the active span context into each log line:

// logger.js
const { trace, context } = require('@opentelemetry/api');
const winston = require('winston');

const otelFormat = winston.format((info) => {
  const currentSpan = trace.getSpan(context.active());
  if (currentSpan) {
    const spanContext = currentSpan.spanContext();
    info.trace_id = spanContext.traceId;
    info.span_id = spanContext.spanId;
  }
  return info;
});

const logger = winston.createLogger({
  format: winston.format.combine(
    otelFormat(),
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()],
});

module.exports = logger;

The resulting JSON output printed to the terminal includes unified trace identifiers:

{
  "level": "info",
  "message": "Order created successfully",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "timestamp": "2026-10-30T10:15:30.123Z"
}

Testing & Real-World Monitoring

The fastest way to verify everything locally is by running an all-in-one Jaeger instance with Docker Compose.

Create a docker-compose.yml file:

version: '3.8'
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # Jaeger Web UI
      - "4318:4318"   # OTLP HTTP receiver port

Start the Jaeger container:

docker compose up -d

Start the Node.js app using the --require flag to preload the OTel configuration file first:

node --require ./tracer.js server.js

Send a few test requests via cURL or Postman, then open your browser at http://localhost:16686:

  1. Under the Service dropdown, select order-service.
  2. Click Find Traces to load captured requests.
  3. Click on a trace: Jaeger displays detailed latency breakdowns across middlewares, database queries, and external HTTP calls.

In production environments with heavy traffic (thousands of req/sec), enable trace sampling (for instance, sampling 10% of requests) using TraceIdRatioBasedSampler. This keeps CPU overhead under 2% while avoiding network bandwidth saturation and reducing backend storage costs.

Share: