Back to all posts
Guide
8 min read

OpenTelemetry Node.js Setup Guide 2026: Traces, Metrics, and Logs

DevToolLab Team

DevToolLab Team

June 19, 2026

OpenTelemetry Node.js Setup Guide 2026: Traces, Metrics, and Logs

On May 21, 2026, the CNCF announced that OpenTelemetry reached Graduated status - the same maturity tier as Kubernetes and Prometheus. The JavaScript API package now logs 1.36 billion downloads per month, with all three core signals (traces, metrics, logs) stable across every major language SDK.

If you have been putting off adding observability to your Node.js service, the graduation removes the last excuse. This guide takes you from a vanilla Express app to a fully instrumented service with traces, metrics, and logs flowing into a local Jaeger + Prometheus stack. All code runs locally. No API keys required.

What Is OpenTelemetry?

OpenTelemetry (OTel) is a vendor-neutral open standard for generating, collecting, and exporting telemetry data. One set of APIs, one wire protocol (OTLP), one SDK - and every major observability backend (Datadog, Grafana, Honeycomb, New Relic, Jaeger, Prometheus) can receive it. Before OTel you had to wire up separate libraries for traces, metrics, and logs with no shared context between them. OTel gives them a common vocabulary: the same trace ID appears in your spans and your logs for the same request.

The Four Signals

SignalWhat it isUse case
TracesTree of spans for a single requestDebug slow endpoints, visualize call chains
MetricsAggregated numeric measurements over timeAlerting, SLOs, dashboards
LogsStructured event records linked to tracesCorrelate log lines to a specific trace
ProfilingContinuous CPU/memory flame graphs (alpha)Correlate latency spikes to hot functions

How the Pieces Fit Together

Bash
Your Node.js App
  |
  | (OTLP over HTTP/gRPC)
  v
OpenTelemetry Collector
  |---> Jaeger (traces)
  |---> Prometheus (metrics)
  |---> Any log backend (Loki, etc.)

The SDK lives in your app, instruments your code, and sends OTLP to the Collector. The Collector decouples your app from your backend - swap Jaeger for Tempo or add a second export destination by changing one config file, no application code changes.

Installation

Bash
npm install \
  @opentelemetry/api@^1.9.0 \
  @opentelemetry/sdk-node@^0.219.0 \
  @opentelemetry/auto-instrumentations-node@^0.60.0 \
  @opentelemetry/exporter-trace-otlp-http@^0.219.0 \
  @opentelemetry/exporter-metrics-otlp-http@^0.219.0 \
  @opentelemetry/exporter-logs-otlp-http@^0.219.0 \
  @opentelemetry/sdk-metrics@^1.30.0 \
  @opentelemetry/sdk-logs@^0.219.0 \
  @opentelemetry/resources@^1.30.0 \
  @opentelemetry/semantic-conventions@^1.28.0

auto-instrumentations-node is a meta-package that automatically patches http, express, pg, mysql2, redis, mongoose, and a dozen other libraries - spans for all of them with no manual instrumentation code.

instrumentation.js

This file must load before any other module. It patches Node.js module loading so instrumentation hooks can intercept library imports.

JavaScript
// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const { BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
const { Resource } = require('@opentelemetry/resources');

const COLLECTOR_URL = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';

const sdk = new NodeSDK({
  resource: new Resource({
    'service.name': process.env.OTEL_SERVICE_NAME || 'my-api',
    'service.version': process.env.npm_package_version || '0.0.0',
    'deployment.environment': process.env.NODE_ENV || 'development',
  }),
  traceExporter: new OTLPTraceExporter({ url: `${COLLECTOR_URL}/v1/traces` }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({ url: `${COLLECTOR_URL}/v1/metrics` }),
    exportIntervalMillis: 15000,
  }),
  logRecordProcessor: new BatchLogRecordProcessor(
    new OTLPLogExporter({ url: `${COLLECTOR_URL}/v1/logs` })
  ),
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { enabled: false },
      '@opentelemetry/instrumentation-dns': { enabled: false },
    }),
  ],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0));
});

fs and dns instrumentation is disabled - they generate enormous trace noise with little useful signal.

Wire it in

JSON
{
  "scripts": {
    "start": "node --require ./instrumentation.js server.js",
    "dev": "node --watch --require ./instrumentation.js server.js"
  }
}

The --require flag loads instrumentation.js before any other module. For native ESM use --import instead.

Custom Spans

Auto-instrumentation handles HTTP and database boundaries. For your own business logic, add spans manually:

JavaScript
const { trace, SpanStatusCode } = require('@opentelemetry/api');

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

async function processOrder(orderId, userId) {
  return tracer.startActiveSpan('order.process', async (span) => {
    try {
      span.setAttributes({ 'order.id': orderId, 'user.id': userId });

      const order = await fetchOrder(orderId);
      span.setAttribute('order.total_usd', order.totalUsd);
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      throw err;
    } finally {
      span.end();
    }
  });
}

The finally { span.end() } pattern is critical - a span that never ends does not appear in your trace UI. span.recordException(err) serializes the full stack trace as a span event, visible in Jaeger without needing to check logs separately.

Custom Metrics

JavaScript
const { metrics } = require('@opentelemetry/api');

const meter = metrics.getMeter('order-service', '1.0.0');

const ordersProcessed = meter.createCounter('orders_processed_total', {
  description: 'Number of orders processed',
});

const orderProcessingTime = meter.createHistogram('order_processing_duration_ms', {
  description: 'Order processing time in milliseconds',
  unit: 'ms',
  advice: { explicitBucketBoundaries: [10, 50, 100, 250, 500, 1000, 2500, 5000] },
});

async function processOrderWithMetrics(orderId) {
  const start = Date.now();
  const labels = { env: process.env.NODE_ENV };
  try {
    const result = await processOrder(orderId);
    ordersProcessed.add(1, { ...labels, status: 'success' });
    return result;
  } catch (err) {
    ordersProcessed.add(1, { ...labels, status: 'error' });
    throw err;
  } finally {
    orderProcessingTime.record(Date.now() - start, labels);
  }
}

Keep metric label cardinality low. Every unique label combination creates a new Prometheus time series - never label with user IDs or request IDs.

Log Correlation with Winston

Bash
npm install winston @opentelemetry/instrumentation-winston@^0.53.0

Add WinstonInstrumentation to your instrumentation setup:

JavaScript
const { WinstonInstrumentation } = require('@opentelemetry/instrumentation-winston');

// In the instrumentations array:
new WinstonInstrumentation({
  logHook: (span, record) => {
    record['service'] = process.env.OTEL_SERVICE_NAME || 'my-api';
  },
}),

Winston logs emitted during a traced request automatically include trace_id and span_id fields. Do not add OpenTelemetryTransportV3 as a Winston transport if you use this approach - logs will be duplicated.

Collector Config

yaml
# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 10s
    send_batch_size: 1024
  filter/drop_healthchecks:
    error_mode: ignore
    traces:
      span:
        - 'attributes["http.route"] == "/health"'

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  prometheus:
    endpoint: "0.0.0.0:8889"
    namespace: myapi
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, filter/drop_healthchecks]
      exporters: [otlp/jaeger, debug]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheus, debug]
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]

The filter/drop_healthchecks processor removes /health spans before they reach Jaeger. Without it, load balancer health checks flood your trace UI.

Docker Compose Dev Stack

yaml
# docker-compose.yaml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.143.0
    command: ["--config=/etc/otelcol-contrib/config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"
      - "8889:8889"
    depends_on: [jaeger]

  jaeger:
    image: jaegertracing/all-in-one:1.58
    ports:
      - "16686:16686"

  prometheus:
    image: prom/prometheus:v2.51.0
    volumes:
      - ./prometheus.yaml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"

prometheus.yaml just needs a scrape config pointing at otel-collector:8889. Run docker compose up -d, start your app, and open Jaeger at http://localhost:16686. Your service should appear in the dropdown within seconds of the first request.

Choosing a Backend

BackendStrengthsWeaknessesFree Tier
Jaeger (self-hosted)Easy to run, great trace UINo metrics, no logs, no alertingN/A (self-host)
Grafana CloudTraces + Metrics + Logs in one dashboardComplex initial config50GB logs, 10k series, 50GB traces
HoneycombBest trace query UIExpensive at scale20M events/month
DatadogEverything in one place, great alertingExpensive, prefers proprietary agent1-day retention
SigNoz (self-hosted)OTel-native, ClickHouse backendRequires ClickHouse opsN/A (self-host)

Recommended starting point: Jaeger locally for development, Grafana Cloud free tier for production. You get traces, metrics, and logs without running infrastructure.

Key Environment Variables

Bash
OTEL_SERVICE_NAME=my-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64-key>  # cloud backends
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1   # 10% sampling in production
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,team=backend

Set OTEL_TRACES_SAMPLER_ARG=0.1 in production. A high-traffic service tracing 100% of requests will overwhelm your Collector and backend.

Known Limitations

  • Native ESM is partially supported. Auto-instrumentation relies on require() patching. Check individual package docs for --import compatibility.
  • Profiling is not production-ready. The Node.js continuous profiling SDK is in alpha.
  • Winston duplicate logs. Do not use both WinstonInstrumentation and OpenTelemetryTransportV3 - pick one.
  • Cardinality in Prometheus. OTel's auto-instrumentation adds http.route, http.method, and http.status_code to HTTP metrics by default. Verify your routes are parameterized (e.g., /users/:id not /users/123) to avoid time-series explosion.
  • YAML Formatter - Validate otel-collector-config.yaml indentation before starting the Collector
  • JSON Formatter - Pretty-print OTLP trace payloads to inspect span attributes
  • JSON Viewer - Explore nested OTLP trace JSON as a tree
  • cURL Command Generator - Build a test request to verify the Collector is receiving data on port 4318
  • Port Checker - Verify ports 4317 and 4318 are open and reachable
  • HTTP Status Checker - Check the Collector health endpoint at http://localhost:13133
  • JSON to .env Converter - Convert OTel environment variable JSON to .env format
  • Diff Checker - Compare two versions of your Collector config when debugging a change
  • Regex Tester - Test span filter patterns for the filter processor before deploying

Conclusion

OTel's CNCF graduation means the APIs are stable, the vendor ecosystem is fully committed, and the "wait and see" argument has expired.

For a Node.js developer starting today: add instrumentation.js, wire it in with --require, and point it at the Docker Compose stack above. You will have working traces in Jaeger within 20 minutes. The Collector config will grow as your needs do - tail sampling, attribute redaction, multiple export destinations - but the application-side SDK stays the same.

Related Posts

6 Best Opsgenie Alternatives (2026)

Opsgenie shuts down April 5, 2027. PagerDuty, incident.io, Rootly, FireHydrant, Jira Service Management and open-source Keep compared on price and migration.

By DevToolLab Team

Best Uptime Monitoring Tools in 2026

UptimeRobot, Better Stack, Checkly and Cronitor priced from their own pages, plus the open-source options worth self-hosting: Uptime Kuma, Gatus and Upptime.

By DevToolLab Team

Best Workflow Orchestration Tools in 2026

Temporal, Inngest and Trigger.dev priced on one workload, days after Temporal's $550M raise at a $12.55B valuation, plus Hatchet, the open-source pick.

By DevToolLab Team