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.





