How to Set Up OpenTelemetry Tracing in a Node.js Microservice
A service that crashes silently in production is a nightmare. A service that crashes silently inside a chain of five other microservices is a special kind of hell. That is exactly the problem distributed tracing was built to solve — and OpenTelemetry is now the industry-standard way to do it.
This tutorial walks you through instrumenting a bare Node.js HTTP service with OpenTelemetry, propagating trace context across service boundaries, and shipping spans to a local Jaeger instance you can query immediately. No hand-waving. No "left as an exercise for the reader."
What You Are Actually Building
Before touching code, understand the three moving parts:
- Your Node.js service — the thing being instrumented.
- The OpenTelemetry SDK — libraries that create and manage spans inside your process.
- Jaeger — an open-source backend that receives, stores, and visualises your traces.
A trace is the full journey of a single request across your system. A span is one hop in that journey — one function call, one database query, one outbound HTTP request. Spans nest inside each other to form a tree, and that tree is your trace.
Step 1 — Spin Up Jaeger Locally
The fastest path is Docker:
docker run -d --name jaeger \
-p 16686:16686 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
Port 4318 is the OTLP/HTTP receiver. Port 16686 is the Jaeger UI. Open http://localhost:16686 and you will see an empty dashboard — not for long.
Step 2 — Install the OpenTelemetry Packages
Inside your Node.js project:
npm install \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
The auto-instrumentations-node package is worth its weight in gold — it automatically patches http, express, pg, redis, and dozens of other common libraries so you get spans for them without writing a single line of custom instrumentation.
Step 3 — Write the Tracer Initialisation File
Create tracing.js at the root of your project. This file must be loaded before anything else.
// tracing.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { Resource } = require('@opentelemetry/resources');
const { SEMRESATTRS_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');
const exporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
});
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'order-service', // change to your service name
}),
traceExporter: exporter,
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});
Start your application with this file required first:
node -r ./tracing.js src/server.js
That single -r flag is all it takes. Every inbound HTTP request to your Express app now automatically becomes a root span. Every outbound http.get becomes a child span. Your database queries become child spans of those. You have a full trace tree with no further changes.
Step 4 — Add Custom Business-Logic Spans
Auto-instrumentation covers infrastructure. It will not know that your calculateShippingCost function is worth tracing independently. For that, you write manual spans:
const { trace, context } = require('@opentelemetry/api');
async function calculateShippingCost(orderId, destination) {
const tracer = trace.getTracer('order-service');
return tracer.startActiveSpan('calculateShippingCost', async (span) => {
try {
span.setAttribute('order.id', orderId);
span.setAttribute('shipping.destination', destination);
const cost = await fetchRatesFromProvider(destination);
span.setAttribute('shipping.cost_usd', cost);
return cost;
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end(); // always end your spans
}
});
}
Two things to notice here. First, startActiveSpan automatically makes this span the parent of anything called inside the callback — that is context propagation working locally. Second, always call span.end() in a finally block. A span that never ends will never be exported.
Step 5 — Propagate Context Across Services
This is where most tutorials stop too early. A trace that dies at the first service boundary is not a distributed trace — it is just local logging with extra steps.
When your service makes an outbound HTTP call to another service, the OpenTelemetry SDK (via the auto-instrumentation) automatically injects a traceparent header into the request. The downstream service, also running the SDK, extracts that header and attaches its spans as children of the upstream span.
The result: one unified trace, across two (or twenty) services, visible in Jaeger as a single waterfall diagram. You can immediately see that the 800ms latency your users are experiencing lives entirely in the inventory service's database query — not in your order service at all.
What to Instrument First
When you are starting from zero, prioritise in this order:
- Entry points — every inbound HTTP route. Auto-instrumentation handles this.
- External calls — outbound HTTP, gRPC, message queue publishes. Also auto-instrumented.
- Database operations — queries, transactions. Auto-instrumented for most drivers.
- High-value business logic — functions where latency directly affects user experience. Manual spans.
- Background jobs — cron tasks and queue consumers need their own root spans created manually.
Resist the temptation to instrument everything on day one. Traces generate data volume. Start with the critical path, measure, then expand.
Reading the Traces in Jaeger
Once your service receives a few requests, open http://localhost:16686, select your service name from the dropdown, and click Find Traces. You will see a list of recent traces sorted by duration. Click any one to open the waterfall view.
Spans that took longer than expected show up immediately as wide bars. Gaps between spans reveal time spent waiting — often a sign of missing await keywords or synchronous blocking code that nobody noticed until now.
Why This Matters for Your Project
Whether you are running a two-service MVP or a twenty-service platform, the moment you add a second network hop to a request, you need distributed tracing. Setting up OpenTelemetry early — before bugs appear — means that when something does go wrong at 2 a.m., you will have a precise timeline of exactly what happened and where, instead of tailing logs across six terminals and guessing. For SaaS teams shipping fast, that difference in mean time to resolution is not a nice-to-have; it is a competitive advantage.





