Skip to content

OpenTelemetry Instrumentation for Node.js

A hands-on guide to instrumenting Node.js with OpenTelemetry: traces, metrics and logs via auto-instrumentation, custom spans, context and exporters.

4 min read
OpenTelemetry trace waterfall view showing spans across multiple microservices with timing and metadata

OpenTelemetry is the open standard for collecting traces, metrics, and logs from applications. It replaces vendor-specific SDKs with a single instrumentation layer that can export to any backend — Jaeger, Grafana, Datadog, or New Relic. Instead of ripping out instrumentation when you switch observability vendors, you change an exporter configuration.

For Node.js, OpenTelemetry provides auto-instrumentation that captures HTTP requests, database queries, and framework operations with zero code changes. Custom spans add application-specific context: which user triggered the request, which feature flag path was taken, how long the business logic took separate from I/O.

Setting Up Auto-Instrumentation

Auto-instrumentation patches popular libraries (Express, Fastify, pg, Redis, HTTP) to create spans automatically. You initialize it before importing your application code.

tstypescript
// tracing.ts — load this BEFORE your application
import { NodeSDK } from "@opentelemetry/sdk-node";
import {
  getNodeAutoInstrumentations,
} from "@opentelemetry/auto-instrumentations-node";
import {
  OTLPTraceExporter,
} from "@opentelemetry/exporter-trace-otlp-http";
import {
  OTLPMetricExporter,
} from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: "payment-service",
    [ATTR_SERVICE_VERSION]: "2.4.1",
    environment: process.env.NODE_ENV ?? "development",
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + "/v1/traces",
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + "/v1/metrics",
    }),
    exportIntervalMillis: 15_000,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready"],
      },
    }),
  ],
});
 
sdk.start();
 
process.on("SIGTERM", async () => {
  await sdk.shutdown();
  process.exit(0);
});
jsonjson
// package.json — start with tracing loaded first
{
  "scripts": {
    "start": "node --require ./dist/tracing.js ./dist/index.js",
    "dev": "tsx --require ./src/tracing.ts ./src/index.ts"
  }
}

Adding Custom Spans

Auto-instrumentation captures infrastructure operations, but it cannot capture business logic. Custom spans add context like "processing payment for user X" or "applying discount rules."

tstypescript
import { trace, SpanStatusCode, context } from "@opentelemetry/api";
 
const tracer = trace.getTracer("payment-service", "2.4.1");
 
interface PaymentRequest {
  userId: string;
  amount: number;
  currency: string;
  method: "card" | "bank_transfer" | "wallet";
}
 
async function processPayment(request: PaymentRequest): Promise<string> {
  return tracer.startActiveSpan("processPayment", async (span) => {
    try {
      // Add attributes for filtering and grouping in your backend
      span.setAttributes({
        "payment.user_id": request.userId,
        "payment.amount": request.amount,
        "payment.currency": request.currency,
        "payment.method": request.method,
      });
 
      // Nested span for validation
      const validated = await tracer.startActiveSpan(
        "validatePayment",
        async (validationSpan) => {
          const result = await validatePaymentDetails(request);
          validationSpan.setAttribute("payment.valid", result.valid);
          validationSpan.end();
          return result;
        }
      );
 
      if (!validated.valid) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: validated.reason,
        });
        throw new Error(validated.reason);
      }
 
      // Nested span for provider call
      const chargeId = await tracer.startActiveSpan(
        "chargeProvider",
        async (providerSpan) => {
          providerSpan.setAttribute("payment.provider", "stripe");
          const id = await chargePaymentProvider(request);
          providerSpan.setAttribute("payment.charge_id", id);
          providerSpan.end();
          return id;
        }
      );
 
      span.setStatus({ code: SpanStatusCode.OK });
      return chargeId;
    } catch (error) {
      span.recordException(error as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (error as Error).message,
      });
      throw error;
    } finally {
      span.end();
    }
  });
}

Custom Metrics

Traces show individual requests. Metrics show aggregate behavior — request rates, error rates, latencies, and business metrics like revenue per minute.

tstypescript
import { metrics } from "@opentelemetry/api";
 
const meter = metrics.getMeter("payment-service", "2.4.1");
 
// Counter: monotonically increasing value
const paymentCounter = meter.createCounter("payments.total", {
  description: "Total number of payment attempts",
  unit: "1",
});
 
// Histogram: distribution of values (latencies, sizes)
const paymentDuration = meter.createHistogram("payments.duration", {
  description: "Payment processing duration",
  unit: "ms",
});
 
// Up/Down Counter: value that increases and decreases
const activePayments = meter.createUpDownCounter(
  "payments.active",
  {
    description: "Currently processing payments",
    unit: "1",
  }
);
 
// Usage in request handler
async function handlePayment(request: PaymentRequest): Promise<void> {
  const startTime = Date.now();
  activePayments.add(1, { method: request.method });
 
  try {
    await processPayment(request);
    paymentCounter.add(1, {
      method: request.method,
      status: "success",
    });
  } catch (error) {
    paymentCounter.add(1, {
      method: request.method,
      status: "failure",
      error_type: (error as Error).name,
    });
  } finally {
    const duration = Date.now() - startTime;
    paymentDuration.record(duration, { method: request.method });
    activePayments.add(-1, { method: request.method });
  }
}
tstypescript
// ❌ High-cardinality attributes cause metric explosion
paymentCounter.add(1, {
  user_id: request.userId,      // Millions of unique values
  request_id: request.id,       // Unique per request
  timestamp: Date.now().toString(), // Unique per call
});
// Result: millions of time series, backend crashes or bill explodes
 
// ✅ Low-cardinality attributes for metrics
paymentCounter.add(1, {
  method: request.method,       // 3-4 values: card, bank, wallet
  status: "success",            // 2 values: success, failure
  currency: request.currency,   // ~10 values: USD, EUR, GBP...
});
// Result: ~80 time series (4 × 2 × 10), manageable and useful

Context Propagation Across Services

When Service A calls Service B, the trace context must propagate so both services' spans appear in the same trace. OpenTelemetry handles this automatically for HTTP calls through W3C Trace Context headers.

tstypescript
// Service A: makes an outgoing HTTP call
// OpenTelemetry auto-instrumentation automatically injects
// traceparent and tracestate headers:
//
// traceparent: 00-<trace-id>-<span-id>-01
// tracestate: <vendor-specific data>
//
// You don't need to do anything — the HTTP instrumentation handles it.
 
import express from "express";
 
const app = express();
 
app.post("/api/orders", async (req, res) => {
  // This span is automatically created by Express instrumentation
  // The HTTP call below automatically propagates trace context
  const paymentResult = await fetch(
    "http://payment-service:3001/api/charge",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ amount: req.body.total }),
    }
  );
 
  // Both the Express span and the fetch span (and the
  // payment-service spans) appear in the same trace
  res.json({ orderId: "ord_123", paymentStatus: "charged" });
});
ymlyaml
# Docker Compose for local development with OpenTelemetry
version: "3.8"
 
services:
  payment-service:
    build: ./payment-service
    environment:
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_SERVICE_NAME: payment-service
 
  order-service:
    build: ./order-service
    environment:
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_SERVICE_NAME: order-service
 
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    volumes:
      - ./otel-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"   # gRPC receiver
      - "4318:4318"   # HTTP receiver
 
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # Jaeger UI

Key Takeaways

  1. Initialize OpenTelemetry before importing application code — auto-instrumentation patches libraries at import time; loading it after your app misses the instrumentation hooks
  2. Auto-instrumentation covers infrastructure, custom spans cover business logic — HTTP requests and database queries are captured automatically; add custom spans for domain-specific operations
  3. Use low-cardinality attributes on metrics — user IDs, request IDs, and timestamps create millions of time series; stick to method, status, and category dimensions
  4. Context propagation happens automatically for HTTP — W3C Trace Context headers are injected and extracted by the HTTP instrumentation; no manual header management needed
  5. Record exceptions and set span status on errors — span.recordException() captures the stack trace; span.setStatus(ERROR) marks the span red in trace visualizations
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX