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.

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


