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
| Signal | What it is | Use case |
|---|---|---|
| Traces | Tree of spans for a single request | Debug slow endpoints, visualize call chains |
| Metrics | Aggregated numeric measurements over time | Alerting, SLOs, dashboards |
| Logs | Structured event records linked to traces | Correlate log lines to a specific trace |
| Profiling | Continuous CPU/memory flame graphs (alpha) | Correlate latency spikes to hot functions |
How the Pieces Fit Together
BashYour 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
Bashnpm 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:
JavaScriptconst { 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
JavaScriptconst { 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
Bashnpm install winston @opentelemetry/instrumentation-winston@^0.53.0
Add WinstonInstrumentation to your instrumentation setup:
JavaScriptconst { 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
| Backend | Strengths | Weaknesses | Free Tier |
|---|---|---|---|
| Jaeger (self-hosted) | Easy to run, great trace UI | No metrics, no logs, no alerting | N/A (self-host) |
| Grafana Cloud | Traces + Metrics + Logs in one dashboard | Complex initial config | 50GB logs, 10k series, 50GB traces |
| Honeycomb | Best trace query UI | Expensive at scale | 20M events/month |
| Datadog | Everything in one place, great alerting | Expensive, prefers proprietary agent | 1-day retention |
| SigNoz (self-hosted) | OTel-native, ClickHouse backend | Requires ClickHouse ops | N/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
BashOTEL_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--importcompatibility. - Profiling is not production-ready. The Node.js continuous profiling SDK is in alpha.
- Winston duplicate logs. Do not use both
WinstonInstrumentationandOpenTelemetryTransportV3- pick one. - Cardinality in Prometheus. OTel's auto-instrumentation adds
http.route,http.method, andhttp.status_codeto HTTP metrics by default. Verify your routes are parameterized (e.g.,/users/:idnot/users/123) to avoid time-series explosion.
Related DevToolLab Tools
- YAML Formatter - Validate
otel-collector-config.yamlindentation 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
.envformat - Diff Checker - Compare two versions of your Collector config when debugging a change
- Regex Tester - Test span filter patterns for the
filterprocessor 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.
