How to Set Up OpenTelemetry Tracing in a Node.js Microservices App

A single slow API request in a microservices architecture can touch six services before returning a response. Without distributed tracing, you are left guessing which hop introduced the 800ms delay. OpenTelemetry eliminates that guesswork — and you do not need a five-figure APM subscription to use it.

This guide walks through instrumenting a real Node.js microservices stack with OpenTelemetry, wiring traces to a self-hosted Jaeger backend, and reading the results to find latency bottlenecks fast.


What OpenTelemetry Actually Is

OpenTelemetry (OTel) is a vendor-neutral observability framework maintained by the CNCF. It provides a single set of APIs, SDKs, and a collector for capturing traces, metrics, and logs — then shipping them to whichever backend you choose (Jaeger, Zipkin, Grafana Tempo, Datadog, etc.).

The key mental model: every incoming request spawns a trace. A trace is a tree of spans, where each span represents one unit of work — an HTTP call, a database query, a queue publish. Each span carries a trace ID that propagates across service boundaries, stitching the full journey together.


The Stack We Are Instrumenting

For this walkthrough, assume three Node.js services communicating over HTTP:

  • api-gateway — Express, receives client requests
  • order-service — Express, handles order logic
  • inventory-service — Fastify, checks stock levels

All three will emit traces to a local Jaeger instance via the OpenTelemetry Collector.


Step 1: Install the Required Packages

In each service, install the OTel SDK and the auto-instrumentation packages you need:

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

@opentelemetry/auto-instrumentations-node is a meta-package that automatically patches Express, Fastify, HTTP, pg, mongoose, Redis clients, and more — saving you from manually wrapping every library call.


Step 2: Create a Tracer Initialisation File

Create tracing.js in the root of each service. This file must be loaded before any other application code:

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

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

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: process.env.SERVICE_NAME || 'unknown-service',
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || 'development',
  }),
  traceExporter: exporter,
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

process.on('SIGTERM', () => sdk.shutdown());

Start each service with:

node -r ./tracing.js src/index.js

The -r (require) flag ensures the SDK initialises before Express or Fastify bootstraps, which is critical for patching to work correctly.


Step 3: Run Jaeger and the OTel Collector with Docker Compose

version: "3.8"
services:
  jaeger:
    image: jaegertracing/all-in-one:1.56
    ports:
      - "16686:16686"   # Jaeger UI
      - "4317:4317"     # OTLP gRPC
      - "4318:4318"     # OTLP HTTP

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.98.0
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    command: ["--config=/etc/otel-collector-config.yaml"]
    ports:
      - "4318:4318"
    depends_on:
      - jaeger

For this simple setup, you can skip the collector entirely and point the exporter directly at Jaeger's OTLP HTTP endpoint (http://localhost:4318/v1/traces). Adding a collector becomes valuable when you want to fan traces out to multiple backends, sample at volume, or enrich spans with additional attributes.


Step 4: Propagate Trace Context Across Services

Auto-instrumentation handles context propagation automatically for outbound HTTP calls made via Node's built-in http/https modules or axios. The W3C traceparent header is injected on every outbound request and extracted on every inbound one — no manual work required.

Where you do need to intervene is with message queues. If order-service publishes a job to Bull or RabbitMQ, you must manually inject and extract the trace context:

const { propagation, context } = require('@opentelemetry/api');

// On publish
const carrier = {};
propagation.inject(context.active(), carrier);
channel.sendToQueue('inventory.check', Buffer.from(JSON.stringify({ ...payload, _otel: carrier })));

// On consume
const parentContext = propagation.extract(context.active(), message._otel);
context.with(parentContext, () => {
  // your handler logic here — spans created inside will be linked
});

Step 5: Add Custom Spans for Business Logic

Auto-instrumentation traces infrastructure calls. For domain logic — pricing calculations, fraud checks, third-party API calls — add manual spans:

const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('order-service');

async function applyDiscounts(cart) {
  const span = tracer.startSpan('applyDiscounts', {
    attributes: { 'cart.item_count': cart.items.length },
  });
  try {
    const result = await calculateDiscounts(cart);
    span.setAttribute('discount.applied', result.totalDiscount);
    return result;
  } catch (err) {
    span.recordException(err);
    span.setStatus({ code: SpanStatusCode.ERROR });
    throw err;
  } finally {
    span.end();
  }
}

Custom spans show up as nested children in Jaeger's trace timeline, making it immediately obvious how much time the business logic consumes versus database round-trips.


Reading Traces in Jaeger

Open http://localhost:16686, select a service from the dropdown, and click Find Traces. Jaeger renders a Gantt-style view of each trace. You are looking for:

  • Long spans without children — usually a slow database query or an unindexed lookup
  • Sequential spans that could be parallel — multiple independent HTTP calls made one after the other
  • High span counts on a single trace — an N+1 query pattern hiding in a loop

A trace that should complete in 120ms taking 900ms is immediately visible. More importantly, the culprit service and the exact operation are named.


Keeping It Cost-Effective

Self-hosting Jaeger on a single t3.small EC2 instance or a small GKE node is sufficient for teams generating under ~5,000 traces per minute. For higher volumes, enable head-based sampling in the OTel Collector to capture, say, 10% of traces plus 100% of errored traces — giving you statistical coverage without storage costs ballooning.


Why This Matters for Your Project

Distributed tracing is not a luxury reserved for companies at Netflix scale. Any product with more than two services talking to each other will eventually face a latency mystery that logs alone cannot solve. Instrumenting early — before you have a production incident — means your team builds the habit of reading traces during code review, not just during outages. For SaaS products where response time directly affects conversion and churn, that visibility is a genuine competitive edge.