Logs and metrics tell you something broke. Distributed tracing tells you exactly where and why. If your Node.js microservices are generating errors that take three engineers and two hours to diagnose, you don't have a bug problem — you have an observability gap.

OpenTelemetry closes that gap. It is the vendor-neutral, CNCF-backed standard for collecting traces, metrics, and logs from your application stack. This guide focuses on the piece most teams skip: distributed tracing — connecting spans across service boundaries so you can follow a single user request from API gateway to database and back.

What Distributed Tracing Actually Gives You

Before touching code, it is worth being precise about what a trace is.

A trace is the full journey of one request across your system. It is made up of spans — individual units of work, each with a start time, duration, and metadata (attributes, status, and events). Every span knows its parent, so the entire call tree can be reconstructed.

What logs can never show you:

  • That your /checkout endpoint is slow only when the inventory service calls PostgreSQL on a cold connection pool
  • That a downstream gRPC call is adding 800 ms to every third request
  • That a Redis cache miss cascades into three sequential database reads

These patterns are invisible in flat log lines. They are obvious in a flame graph.

Project Setup

Start with a fresh Express service, or drop the SDK into an existing one. Install the required packages:

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 the force multiplier here. It patches http, express, pg, mongoose, redis, grpc, and over a dozen other libraries automatically — zero manual span creation required for common I/O.

Writing the Tracer Initializer

Create a tracing.js file and load it before anything else in your process. This ordering matters — the SDK must patch modules before they are required.

// tracing.js
'use strict';

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, SEMRESATTRS_SERVICE_VERSION } = 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({
    [SEMRESATTRS_SERVICE_NAME]: 'order-service',
    [SEMRESATTRS_SERVICE_VERSION]: '1.0.0',
  }),
  traceExporter: exporter,
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

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

Then update your entry point:

node -r ./tracing.js server.js

That is genuinely all you need to get HTTP and database spans flowing. No code changes to your route handlers.

Connecting Spans Across Service Boundaries

Auto-instrumentation handles context propagation for outbound HTTP calls automatically via the traceparent header (W3C Trace Context standard). When your order service calls the inventory service using axios or Node's built-in fetch, the current trace context is injected into the request headers and extracted on the other side — as long as both services run the OpenTelemetry SDK.

The result: a single trace ID that stitches the entire call chain together in your observability backend.

For message-queue boundaries (Kafka, RabbitMQ), you propagate context manually by serializing the active span context into your message headers:

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

// Producer side
const carrier = {};
propagation.inject(context.active(), carrier);
await producer.send({ topic: 'orders', messages: [{ headers: carrier, value: payload }] });

// Consumer side
const parentContext = propagation.extract(context.active(), message.headers);
context.with(parentContext, () => {
  // spans created here are children of the producer span
});

Adding Custom Spans and Attributes

Auto-instrumentation covers I/O. Business logic — pricing calculations, fraud checks, rule engines — needs manual instrumentation.

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

async function applyDiscounts(cart) {
  return tracer.startActiveSpan('applyDiscounts', async (span) => {
    span.setAttribute('cart.item_count', cart.items.length);
    span.setAttribute('cart.user_tier', cart.userTier);
    try {
      const result = await runDiscountEngine(cart);
      span.setAttribute('discount.applied', result.discountCode);
      return result;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
    }
  });
}

Attributes make spans searchable. Add user.id, tenant.id, or feature.flag values to every span that touches multi-tenant logic, and you can filter traces to a single customer's experience in seconds.

Choosing a Backend

OpenTelemetry is exporter-agnostic. The OTLP HTTP exporter used above ships data to any compatible backend:

  • Grafana Tempo + Grafana UI — fully open source, integrates with Prometheus and Loki
  • Jaeger — lightweight, easy to self-host, great for local development
  • Honeycomb, Datadog, New Relic — managed SaaS options with advanced querying
  • AWS X-Ray via ADOT — natural fit for teams already on AWS

For a local development environment, a single Docker Compose file with Jaeger is enough to get traces visible within minutes.

What to Look For Once Traces Are Flowing

Once your first traces appear in the UI, resist the urge to just admire the flame graphs. Run these specific checks:

  • N+1 queries: A span showing 47 sequential PostgreSQL SELECT calls where there should be one JOIN
  • Synchronous calls that should be async: Downstream service calls sitting on the critical path unnecessarily
  • P99 outliers: Spans whose 99th-percentile duration is 10× the median — a classic sign of connection pool exhaustion or lock contention
  • Missing spans: Gaps in the trace tree often reveal un-instrumented internal services or misconfigured context propagation

Why This Matters for Your Project

If you are building or scaling a multi-service system — whether that is a SaaS platform, a fintech backend, or a mobile app API — distributed tracing is not a luxury for large engineering teams. It is the difference between spending four hours on a production incident and spending twenty minutes. OpenTelemetry's vendor-neutral model means you instrument once and swap backends as your needs evolve. The investment is small. The diagnostic payoff, especially as your service count grows beyond three or four, is substantial.