AboutBlogContact
DevOps & InfrastructureApril 17, 2026 8 min read 176Updated: May 18, 2026

OpenTelemetry in Node.js: Distributed Tracing From Zero to Production

AunimedaAunimeda
📋 Table of Contents

Your API is slow. The logs say the request came in, the response went out, it took 2.3 seconds. Nothing logged an error. You have six services that might have touched that request. Where do you start?

This is exactly the problem distributed tracing solves. A trace is a causal record of one request's journey across your entire system - every service, every database call, every queue publish. A span is one unit of work within that journey. When your API is slow, you open the trace and see: "service A spent 50ms, service B spent 1800ms - specifically the getUserPreferences DB query". Case closed in 30 seconds.

OpenTelemetry is now the industry standard for generating this data. It's vendor-neutral (export to Jaeger, Grafana Tempo, Honeycomb, Datadog - your choice), has excellent Node.js support, and the auto-instrumentation means you can get traces from Express, Fastify, pg, redis, and most npm packages with zero manual code changes.

Core Concepts Before Code

A trace is a directed acyclic graph of spans with a shared traceId. Think of it as the full call tree for one request.

A span has: a name, start/end timestamps, a spanId, the parent spanId, a traceId, a status (OK/ERROR), and arbitrary key-value attributes. Attributes are where you put business context: user.id, order.id, http.route, db.statement.

Context propagation is how the traceId travels from service to service. Over HTTP it goes in headers (traceparent: 00-<traceId>-<spanId>-01). Over message queues you embed it in message metadata. Without propagation, you get separate disconnected traces instead of one unified trace.

Baggage is key-value data that propagates with the trace context - useful for carrying tenantId or userId through every service without threading it through every function signature. Use sparingly: baggage is sent on every outgoing call.

Project Setup

We'll instrument two services: an HTTP API (order-service) and a worker (notification-service). Start with the SDK setup, which MUST be the first thing that runs before any other imports.

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

The Instrumentation File (Load This First)

// src/instrumentation.ts
// This file must be loaded via --require before anything else
// node --require ./dist/instrumentation.js ./dist/server.js

import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION, SEMRESATTRS_DEPLOYMENT_ENVIRONMENT } from '@opentelemetry/semantic-conventions';
import { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBased } from '@opentelemetry/sdk-trace-base';

const exporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces',
  headers: {
    // For Grafana Cloud, add auth header here:
    // Authorization: `Basic ${Buffer.from(`${instanceId}:${apiKey}`).toString('base64')}`,
  },
});

const sdk = new NodeSDK({
  resource: new Resource({
    [SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME ?? 'order-service',
    [SEMRESATTRS_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
    [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV ?? 'development',
  }),

  // BatchSpanProcessor buffers spans and sends in batches - much more efficient
  // than SimpleSpanProcessor which sends every span immediately
  spanProcessor: new BatchSpanProcessor(exporter, {
    maxQueueSize: 2048,
    maxExportBatchSize: 512,
    scheduledDelayMillis: 5000,
    exportTimeoutMillis: 30000,
  }),

  // Production sampling: trace 10% of requests, but always trace errors
  // ParentBasedSampler respects the sampling decision from upstream services
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBased(
      process.env.NODE_ENV === 'production' ? 0.1 : 1.0
    ),
  }),

  instrumentations: [
    getNodeAutoInstrumentations({
      // Disable noisy instrumentations you don't need
      '@opentelemetry/instrumentation-fs': { enabled: false },
      '@opentelemetry/instrumentation-http': {
        // Don't trace health check endpoints
        ignoreIncomingRequestHook: (req) => {
          return req.url === '/health' || req.url === '/metrics';
        },
      },
      '@opentelemetry/instrumentation-pg': {
        // Capture the actual SQL - useful but be careful with PII in WHERE clauses
        enhancedDatabaseReporting: true,
        addSqlCommenterCommentToQueries: true, // adds trace context to slow query logs!
      },
    }),
  ],
});

sdk.start();

// Flush spans before process exits - critical, don't skip this
process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('Tracing terminated'))
    .catch(console.error)
    .finally(() => process.exit(0));
});

export default sdk;

The addSqlCommenterCommentToQueries: true option is underrated: it appends /* traceparent='...' */ to your SQL. When that query shows up in pg_stat_statements or your slow query log, you can directly correlate it to a trace in Jaeger. We found this invaluable during an incident where a slow query was triggered by a specific code path that only activated for enterprise customers.

Manual Spans for Business Logic

Auto-instrumentation captures infrastructure calls. For your actual business logic, you need manual spans.

// src/services/orderService.ts
import { trace, SpanStatusCode, context, propagation } from '@opentelemetry/api';

const tracer = trace.getTracer('order-service', '1.0.0');

export async function processOrder(orderId: string, userId: string) {
  // Start a span for the entire business operation
  return tracer.startActiveSpan('order.process', async (span) => {
    try {
      // Attributes give context to the span - use semantic conventions where they exist
      span.setAttributes({
        'order.id': orderId,
        'user.id': userId,
        'app.component': 'order-service',
      });

      // Nested spans for sub-operations
      const inventory = await tracer.startActiveSpan('order.checkInventory', async (inventorySpan) => {
        inventorySpan.setAttribute('order.id', orderId);
        try {
          const result = await checkInventory(orderId);
          inventorySpan.setAttribute('inventory.available', result.available);
          inventorySpan.setStatus({ code: SpanStatusCode.OK });
          return result;
        } catch (err) {
          inventorySpan.setStatus({
            code: SpanStatusCode.ERROR,
            message: (err as Error).message,
          });
          inventorySpan.recordException(err as Error);
          throw err;
        } finally {
          inventorySpan.end();
        }
      });

      if (!inventory.available) {
        span.setAttribute('order.outcome', 'out_of_stock');
        span.setStatus({ code: SpanStatusCode.OK }); // Not an error, just a business outcome
        return { success: false, reason: 'out_of_stock' };
      }

      const payment = await chargePayment(orderId, userId);
      span.setAttribute('payment.method', payment.method);
      span.setAttribute('order.outcome', 'completed');
      span.setStatus({ code: SpanStatusCode.OK });

      return { success: true, paymentId: payment.id };
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (err as Error).message,
      });
      throw err;
    } finally {
      span.end(); // Always end the span, even on error
    }
  });
}

The recordException call is important: it attaches the stack trace and error message as span events, so when you're looking at a failed span in Jaeger you can see exactly what went wrong without correlating to a separate log.

Context Propagation Across HTTP

Auto-instrumentation handles this for Express/Fastify/fetch automatically. But you need to understand it for message queues and background jobs.

// Service A: Injecting trace context into an HTTP request manually
import { context, propagation } from '@opentelemetry/api';

async function callNotificationService(payload: NotificationPayload) {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  };

  // Inject current trace context into outgoing headers
  propagation.inject(context.active(), headers);
  // headers now contains 'traceparent' and optionally 'tracestate'

  const response = await fetch('http://notification-service/notify', {
    method: 'POST',
    headers,
    body: JSON.stringify(payload),
  });

  return response.json();
}
// Service B: Extracting trace context from incoming message queue message
import { context, propagation, trace, SpanKind } from '@opentelemetry/api';

// For a BullMQ/RabbitMQ message processor
async function processQueueMessage(message: QueueMessage) {
  // The producer embedded trace context in message.headers
  const parentContext = propagation.extract(context.active(), message.headers ?? {});

  // Run our processing inside the extracted context
  // so spans will be children of the original trace
  return context.with(parentContext, async () => {
    const tracer = trace.getTracer('notification-service');
    return tracer.startActiveSpan('notification.process', {
      kind: SpanKind.CONSUMER, // Semantic convention for queue consumers
    }, async (span) => {
      try {
        span.setAttributes({
          'messaging.system': 'bullmq',
          'messaging.destination': 'notifications',
          'user.id': message.data.userId,
        });

        await sendNotification(message.data);
        span.setStatus({ code: SpanStatusCode.OK });
      } catch (err) {
        span.recordException(err as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        throw err;
      } finally {
        span.end();
      }
    });
  });
}

Without the propagation.extract + context.with pattern, your notification service spans will be orphaned - separate traces with no connection to the original HTTP request that triggered the message. This is the gotcha that bites almost every team doing their first distributed tracing setup.

Correlating Logs with Traces

Your logs are useless in isolation during an incident if you can't connect them to the trace that was active when they were written. Here's how to inject trace context into your Winston logs:

// src/lib/logger.ts
import winston from 'winston';
import { trace, context } from '@opentelemetry/api';

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format((info) => {
      // Inject trace context into every log entry
      const span = trace.getActiveSpan();
      if (span) {
        const ctx = span.spanContext();
        info['trace_id'] = ctx.traceId;
        info['span_id'] = ctx.spanId;
        info['trace_flags'] = `0${ctx.traceFlags.toString(16)}`;
      }
      return info;
    })(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()],
});

export default logger;

Now every log line contains trace_id. In Grafana, you can configure Loki to correlate with Tempo using trace_id as the link field. Click a slow trace span in Tempo → click "Logs for this span" → see every log line emitted during that span's execution. This workflow cuts incident investigation time dramatically.

Sampling in Production: Don't Trace Everything

Tracing 100% of production traffic is expensive and often unnecessary. A 10% tail sample means you see statistical patterns but miss individual slow requests. The right approach is tail-based sampling: always keep traces that had errors or exceeded a latency threshold.

// For tail-based sampling, you need a collector (OpenTelemetry Collector)
// and configure the tailsampling processor there. In your collector config:
# otel-collector-config.yaml
processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 50000
    expected_new_traces_per_sec: 500
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow-requests
        type: latency
        latency: { threshold_ms: 2000 }
      - name: probabilistic-sample
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

This configuration keeps 100% of error traces, 100% of traces slower than 2 seconds, and 5% of everything else. You'll capture all the interesting events and sample down the noise.

Running Locally with Jaeger

docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 4318:4318 \
  jaegertracing/all-in-one:latest

Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces, start your services, make a request, open http://localhost:16686. You'll see the full trace within seconds.


Building microservices that need to be observable in production, not just testable in dev? Aunimeda engineers have wired OpenTelemetry, Grafana Tempo, and structured logging into production stacks. Learn more about our AI and infrastructure solutions or contact us to discuss your observability setup.

Read Also

How to Deploy Node.js with Nginx + PM2 on Ubuntu (2015)aunimeda
DevOps & Infrastructure

How to Deploy Node.js with Nginx + PM2 on Ubuntu (2015)

PM2 keeps Node.js alive after crashes and reboots. Nginx handles SSL, static files, and proxies API requests. Together they form the deployment stack that replaced Apache for Node.js apps in 2015. Full setup: PM2 config, Nginx virtual host, zero-downtime deploys.

Beyond Zero-Downtime: Mastering State Persistence in Distributed Deploymentsaunimeda
DevOps & Infrastructure

Beyond Zero-Downtime: Mastering State Persistence in Distributed Deployments

Zero-downtime deployment was the goal in 2018. In 2026, the challenge is 'State Continuity.' We explore how to manage database migrations and persistent WebSocket connections without dropping a single user session.

Cloud Hosting Comparison 2026: AWS vs GCP vs Azure vs Hetzner vs Vercelaunimeda
DevOps & Infrastructure

Cloud Hosting Comparison 2026: AWS vs GCP vs Azure vs Hetzner vs Vercel

Which cloud provider to choose for your startup in 2026. Real pricing comparison, performance benchmarks, and the hosting stack that makes sense at each stage.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

DevOps Services

Get Consultation All articles