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

A single slow database query can degrade your entire platform — and when that query lives inside one of eight microservices, plain logs will not tell you which one. You will chase timestamps across four terminals and still not know. Distributed tracing solves this by stitching every hop of a request into one visual timeline, so you see exactly where time is spent and where things break.

This guide walks through instrumenting a Node.js microservices stack with OpenTelemetry and Jaeger. By the end, you will have trace data flowing from HTTP entry point to downstream services, visible in a local Jaeger UI.


Why Logs Are Not Enough

Logs are event records. They tell you what happened inside one process at one point in time. In a distributed system, a single user request might touch an API gateway, an auth service, an orders service, and a payments service before returning a response.

Correlating those four log streams manually — especially under load, with interleaved requests — is fragile and slow. What you need is context propagation: a trace ID that travels with the request across every service boundary, turning isolated log lines into a connected story.

That is the job of distributed tracing.


The OpenTelemetry and Jaeger Stack

OpenTelemetry (OTel) is the CNCF-backed open standard for generating and exporting telemetry data — traces, metrics, and logs. It provides vendor-neutral SDKs for most languages, including Node.js. You instrument your code once and can route data to Jaeger, Zipkin, Datadog, or any OTel-compatible backend.

Jaeger is an open-source distributed tracing backend originally built at Uber. It ingests trace data, stores it, and provides a UI to search and visualise traces. For local development and small production setups, it is an excellent choice.


Project Setup

Assume you have two Node.js services using Express:

  • api-gateway — receives client requests on port 3000
  • orders-service — handles order logic on port 3001

Install the required OpenTelemetry packages in both services:

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

Writing the Tracer Initialiser

Create a tracer.js file in each service. This file must be loaded before any other module — it patches Node's HTTP, Express, and other libraries automatically.

// tracer.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 { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: process.env.SERVICE_NAME || 'unknown-service',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces', // Jaeger OTLP HTTP endpoint
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

Start each service with:

SERVICE_NAME=api-gateway node -r ./tracer.js index.js
SERVICE_NAME=orders-service node -r ./tracer.js index.js

The -r flag preloads the tracer before your application code runs. This is the simplest way to ensure all outbound HTTP calls and incoming requests are automatically instrumented.


Running Jaeger Locally

Spin up Jaeger with Docker. The all-in-one image includes the collector, query engine, and UI:

docker run -d --name jaeger \
  -p 4318:4318 \   # OTLP HTTP receiver
  -p 16686:16686 \ # Jaeger UI
  jaegertracing/all-in-one:latest

Open http://localhost:16686 once the container is running. Your services will appear in the service dropdown after they emit their first trace.


How Context Propagation Works

When api-gateway makes an HTTP call to orders-service, OpenTelemetry automatically injects a traceparent header into the outgoing request. This header carries the trace ID and the current span ID.

On the receiving end, orders-service — also instrumented with OTel — reads that header and continues the same trace rather than starting a new one. Every span created inside orders-service for that request becomes a child of the span in api-gateway.

The result in Jaeger: one trace with a waterfall view showing every service, every span, and the time each step consumed.

No manual header wiring is required. The auto-instrumentation packages handle W3C TraceContext propagation out of the box.


Adding Custom Spans

Auto-instrumentation covers HTTP and database calls, but business logic is invisible to it. Use the OTel API to add custom spans around critical code paths:

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

async function calculateDiscount(order) {
  const span = tracer.startSpan('calculateDiscount');
  try {
    // your logic here
    span.setAttribute('order.id', order.id);
    span.setAttribute('order.value', order.total);
    return applyRules(order);
  } catch (err) {
    span.recordException(err);
    span.setStatus({ code: 2, message: err.message }); // ERROR status
    throw err;
  } finally {
    span.end();
  }
}

Custom spans let you tag business-specific attributes — customer tier, order size, feature flags — directly onto trace data. When you filter traces in Jaeger by those attributes, root-cause analysis becomes surgical.


What to Look for in the Jaeger UI

Once traces are flowing, focus on three signals:

  • High-latency spans — sort traces by duration to find the slowest requests. Zoom into the waterfall to identify which service or operation is the bottleneck.
  • Error spans — Jaeger marks spans with recorded exceptions in red. Filter by error=true to surface failure patterns across services.
  • Span gaps — large horizontal gaps between spans indicate network latency or queue wait time, not application slowness. This distinction is invisible in logs.

Production Considerations

For production deployments, replace the Jaeger all-in-one container with a proper collector pipeline:

  • Deploy the OpenTelemetry Collector as a sidecar or DaemonSet. Services export to the local collector; the collector batches, filters, and forwards to your backend.
  • Use sampling strategies to avoid overwhelming storage. Start with a 10–20% head-based sampling rate, and add tail-based sampling for error traces so every failure is always captured.
  • Store traces in a scalable backend — Jaeger with Cassandra or Elasticsearch, or a managed service like Grafana Tempo.

Why This Matters for Your Project

Once your architecture grows past two services, observability stops being optional. Tracing with OpenTelemetry and Jaeger gives your engineering team a shared, objective view of system behaviour — reducing the mean time to diagnose production incidents from hours to minutes. Whether you are building a fintech platform, a logistics API, or a SaaS product with a growing microservices footprint, investing in distributed tracing early is one of the highest-leverage engineering decisions you can make.