
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Debugging latency spikes or silent failures in asynchronous Node.js applications is nearly impossible without correlated telemetry data. Observability for Node.js with OpenTelemetry solves this by unifying traces, metrics, and logs into a single vendor-neutral standard that works across any backend. This guide walks you through a production-grade implementation, moving beyond basic tutorials to cover auto-instrumentation, context propagation, and safe exporter configuration.
@opentelemetry/sdk-node package, configuring auto-instrumentation libraries for frameworks like Express or NestJS, and registering an exporter (OTLP/Console) before your application code runs. This setup captures distributed traces, runtime metrics, and correlated logs without modifying business logic.How does observability for Node.js with OpenTelemetry actually work?
At its core, OpenTelemetry (OTel) intercepts Node.js runtime primitives and popular library calls to generate telemetry signals automatically. When you initialize the SDK, it patches modules like http, express, pg, and ioredis at require-time. This means incoming HTTP requests automatically become trace spans, database queries are captured as child spans, and context propagates across async boundaries via the AsyncLocalStorage API.
Understanding this architecture prevents common mistakes. The SDK must be initialized before any other module loads; otherwise, patches fail silently. For teams familiar with the broader OpenTelemetry ecosystem, the Node.js implementation follows the same semantic conventions but relies heavily on the event loop's async context tracking. If your application uses worker threads or complex promise chains, verifying context propagation early saves hours of debugging missing spans later.
How do you configure auto-instrumentation in Node.js?
Auto-instrumentation is where most teams should start. It provides immediate visibility with zero code changes to business logic. Create a separate tracing.ts (or .js) file that initializes the SDK before anything else imports.
// tracing.ts — MUST be imported before all other modules
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 { 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',
'deployment.environment': process.env.NODE_ENV || 'development',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-express': { enabled: true },
'@opentelemetry/instrumentation-pg': { enabled: true },
'@opentelemetry/instrumentation-ioredis': { enabled: true },
'@opentelemetry/instrumentation-http': { enabled: true },
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().then(
() => console.log('OTel SDK shut down successfully'),
(err) => console.error('Error shutting down OTel SDK', err)
);
}); Then modify your start script to load this file first:
# package.json scripts
{
"start": "node --require ./dist/tracing.js dist/server.js",
"dev": "tsx --require ./src/tracing.ts src/server.ts"
} A common mistake in 2026 is forgetting graceful shutdown. Without calling sdk.shutdown(), buffered spans are lost during deployments or restarts. Always register SIGTERM and SIGINT handlers, especially in containerized environments where pod termination windows are short.
When should you add manual instrumentation to Node.js services?
Auto-instrumentation covers infrastructure boundaries, but it cannot understand business semantics. You need manual spans when tracking multi-step workflows, external API calls not covered by plugins, or internal processing stages that matter for debugging. Refer to general instrumentation patterns for deeper context on span design.
import { trace, SpanStatusCode, context } from '@opentelemetry/api';
const tracer = trace.getTracer('payment-processing');
async function processPayment(orderId: string, amount: number) {
return tracer.startActiveSpan('process-payment', async (span) => {
span.setAttribute('order.id', orderId);
span.setAttribute('payment.amount', amount);
try {
await validateOrder(orderId);
// Create child span for gateway call
const gatewayResult = await tracer.startActiveSpan(
'call-payment-gateway',
async (childSpan) => {
const result = await paymentGateway.charge(amount);
childSpan.setAttribute('gateway.transaction_id', result.txnId);
childSpan.end();
return result;
}
);
span.setStatus({ code: SpanStatusCode.OK });
return gatewayResult;
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
});
throw error;
} finally {
span.end();
}
});
} Key rules for manual instrumentation: always end spans (use try/finally), record exceptions explicitly, and avoid creating spans for trivial operations. High-cardinality attributes like user IDs or email addresses should be avoided unless you have confirmed your backend handles them efficiently. For teams building microservices, understanding how these manual spans connect across services is critical—see distributed tracing with OpenTelemetry and Jaeger for cross-service correlation strategies.
Which OpenTelemetry exporter and backend should you choose for Node.js?
The OTLP protocol is the universal standard in 2026, but your deployment target determines the optimal exporter configuration. Below is a practical comparison based on real production deployments across AWS, GCP, and self-hosted stacks.
| Backend | Exporter Package | Best For | Production Consideration |
|---|---|---|---|
| Grafana Tempo / Cloud | @opentelemetry/exporter-trace-otlp-http | Teams already using Grafana stack | Use HTTP/protobuf over gRPC for browser-compatible proxies |
| Jaeger | @opentelemetry/exporter-trace-otlp-grpc | Pure tracing focus, CNCF-native shops | gRPC offers lower overhead; ensure collector supports v1.62+ |
| AWS X-Ray / ADOT | @opentelemetry/exporter-aws-xray | AWS-native teams needing service map | Requires AWS SDK credentials; limited attribute support |
| Datadog / New Relic | Vendor-specific OTLP endpoint | Existing vendor investment | Verify sampling compatibility; some vendors drop custom attrs |
| Local Dev / Debug | @opentelemetry/exporter-trace-console | Development only | Never enable in production; massive stdout overhead |
In practice, I recommend deploying an OpenTelemetry Collector as a sidecar or DaemonSet rather than exporting directly from Node.js to backends. The Collector buffers during network blips, handles batching, and lets you swap backends without redeploying applications. For teams managing multiple services, this indirection pays for itself within weeks. See Prometheus and Grafana full monitoring stack for integrating OTel metrics alongside traces in the same observability pipeline.
What are common pitfalls when implementing observability for Node.js with OpenTelemetry?
After helping dozens of teams adopt OTel in production, these issues surface repeatedly:
- SDK loaded too late: If your ORM or HTTP framework initializes before the SDK, auto-instrumentation fails silently. Always use
--requireor ESM loader hooks. - Missing context in async callbacks: Legacy callback-based APIs sometimes lose AsyncLocalStorage context. Wrap them with
context.bind(context.active(), callback)or migrate to promises. - High-cardinality attributes: Adding user emails, request bodies, or UUIDs as span attributes explodes backend costs. Use span events or log correlation instead.
- No sampling strategy: Default AlwaysOnSampler generates enormous volume in high-traffic services. Configure ParentBasedTraceIdRatio sampler with 0.1–0.01 ratio for production.
- Ignoring log correlation: Traces without correlated logs force engineers to jump between tools. Inject trace_id and span_id into your structured logger (Pino/Winston) using the OTel log bridge or manual injection.
For teams serving Nepal-based users or operating under bandwidth constraints, aggressive sampling and local Collector buffering are non-negotiable. International backends add latency; batch exports every 5 seconds with max queue size limits to prevent memory pressure during outages.
Getting Started with Observability for Node.js with OpenTelemetry
Start with auto-instrumentation and a local Collector today. Get traces flowing end-to-end before adding manual spans or tuning sampling rates. Validate context propagation with a simple test endpoint that makes a DB call and returns the trace ID. Once your team trusts the data, expand to metrics and log correlation incrementally.
If your Node.js services handle sensitive data or operate under compliance requirements like SOC 2 or ISO 27001, ensure your OTel configuration excludes PII from attributes and encrypts export channels. Need help designing an audit-ready observability stack or troubleshooting a broken instrumentation setup? Reach out to discuss your specific architecture.