Node.js 68 🟢 OpenTelemetry Integration for Distributed Tracing and Observability
A request enters your API, calls a database, invokes a payment service, and returns. Something is slow, but the logs tell you nothing useful—just timestamps and messages from different services with no way to connect them. Distributed tracing solves this by attaching a shared identifier to every operation across every service, so a single request can be followed from the moment it arrives until the response is sent. OpenTelemetry is the vendor-neutral standard for producing that data, and its Node.js SDK is the implementation you wire into your application.
The integration has two layers. Auto-instrumentation hooks into libraries you already use—Express, HTTP, pg, Redis—and creates spans automatically without changing your code . Manual instrumentation lets you create spans for the business logic that auto-instrumentation cannot see: the validation step, the pricing calculation, the order assembly. The SDK is initialized in a separate file that runs before any application code, using the --require or --import flag, because instrumentations must be registered before the libraries they instrument are loaded .
This chapter covers the SDK initialization, the auto-instrumentation packages, the manual span API, context propagation across services, the exporter configuration for sending traces to a backend, and the log correlation that ties traces to Pino or Winston output.
Key point: OpenTelemetry’s Node.js SDK must be initialized before any application code loads, using --require or --import; auto-instrumentation creates spans for supported libraries, manual spans cover business logic, and the trace context is propagated across services through HTTP headers automatically by the HTTP instrumentation.
Why OpenTelemetry requires a preload and a separate initialization file
The instrumentation ordering problem. OpenTelemetry works by wrapping library functions—http.request, express.Router.handle, pg.Client.query—with code that starts a span, runs the original function, and ends the span. This wrapping must happen before the library is imported by the application. If you require('express') before the instrumentation is registered, the Express module is cached with its original functions, and the instrumentation never sees it . The only reliable way to guarantee ordering is to load the instrumentation file before the application entry point.
The --require and --import mechanisms. Node.js provides two flags for preloading modules. --require works for CommonJS. --import works for ES modules and TypeScript (with tsx or a loader) . Both flags execute the specified file before the main script, which is exactly the ordering OpenTelemetry needs. The Google Cloud documentation shows this in a Dockerfile: CMD node --require ./build/src/instrumentation.js build/src/index.js . The OpenTelemetry getting-started guide uses node --import ./instrumentation.mjs app.js .
The no-op fallback problem. If you skip the preload and initialize the SDK inside your application code, any library that acquired a tracer before the SDK was initialized receives a no-op implementation. Spans from that library are silently discarded. The SDK documentation is explicit: “If you fail to initialize the SDK or initialize it too late, no-op implementations will be provided to any library which acquires a tracer or meter from the API” . The preload is not a convenience; it is a correctness requirement.
a. Initializing the SDK and enabling auto-instrumentation
The SDK initialization file creates a NodeSDK instance with three components: a trace exporter, a span processor, and a list of instrumentations. The @opentelemetry/sdk-node package bundles the tracing and metrics SDKs, and @opentelemetry/auto-instrumentations-node bundles the instrumentation libraries for the most common Node.js frameworks and libraries .
// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'api-service',
[ATTR_SERVICE_VERSION]: '1.2.3',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.log('Error terminating tracing', error))
.finally(() => process.exit(0));
});
The resource identifies the service that is producing the telemetry. service.name is the field that appears as the service label in Jaeger, Tempo, or any other backend . Without it, the traces are anonymous, and you cannot tell which service produced which span.
The OTLPTraceExporter sends spans to a collector or a backend that speaks the OpenTelemetry Protocol. The default endpoint is http://localhost:4318/v1/traces, which is the HTTP port of the OpenTelemetry Collector . The traceExporter is wrapped in a BatchSpanProcessor by default, which buffers spans and sends them in batches to avoid overwhelming the backend with individual network requests .
getNodeAutoInstrumentations() returns an array of instrumentation instances for every supported library that is present in the application. If you have Express, HTTP, and pg installed, the auto-instrumentations package registers instrumentations for all three . If you want to exclude a specific instrumentation—for example, to avoid instrumenting filesystem calls—you can pass a configuration object:
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
})
The auto-instrumentation package increases the dependency graph size, so for production applications with a known set of libraries, installing individual instrumentation packages (@opentelemetry/instrumentation-express, @opentelemetry/instrumentation-http) is often the better choice .
b. Manual spans for business logic
Auto-instrumentation creates spans for the boundaries it can see: inbound HTTP requests, outbound HTTP calls, database queries. It cannot see the validation logic inside a route handler, the pricing calculation that loops over line items, or the order assembly that queries three services and combines the results. Manual spans fill this gap.
The API is built around a tracer, obtained from the global provider:
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('order-service');
tracer.startActiveSpan(name, callback) creates a span, makes it the active span in the current context, and invokes the callback with the span object. The span is automatically ended when the callback returns or throws .
async function validateOrder(order) {
return tracer.startActiveSpan('validate-order', async (span) => {
try {
span.setAttribute('order.id', order.id);
span.addEvent('validation started');
for (const item of order.items) {
const product = await fetchProduct(item.productId);
if (product.stock < item.quantity) {
throw new Error(`Product ${item.productId} out of stock`);
}
}
span.setStatus({ code: SpanStatusCode.OK });
return true;
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
span.recordException(error);
throw error;
} finally {
span.end();
}
});
}
The setAttribute calls enrich the span with context that makes it searchable in the backend. addEvent records a timestamped event within the span’s lifetime. recordException captures the error and its stack trace as a span event, and setStatus marks the span as failed . The span.end() call in the finally block ensures the span is closed even if an error is thrown.
The SpanKind option tells the backend what role the span plays. SERVER is for inbound requests, CLIENT is for outbound calls, PRODUCER is for sending to a queue, CONSUMER is for receiving from a queue, and INTERNAL is for everything else . Auto-instrumentation sets the correct kind for the spans it creates. Manual spans should set it explicitly when the operation crosses a boundary:
const clientSpan = tracer.startSpan('inventory.api.get', {
kind: SpanKind.CLIENT,
attributes: { 'http.method': 'GET', 'http.url': 'http://inventory/products/123' },
});
c. Context propagation across services
Distributed tracing only works if the trace ID survives the hop from one service to another. OpenTelemetry handles this automatically when both services use the HTTP instrumentation. The sending service injects the trace context into the outbound request headers (typically traceparent and tracestate), and the receiving service extracts it and continues the trace .
The injection and extraction are performed by the @opentelemetry/instrumentation-http package. When you make an outbound HTTP call using http, https, fetch, or undici, the instrumentation adds the headers automatically. When your Express or Fastify server receives a request, the instrumentation reads the headers and sets the active context to the incoming trace . No manual code is required for HTTP-based services.
For protocols that the HTTP instrumentation does not cover—message queues, custom RPC, or non-standard transports—you use the propagation API manually. On the sending side, propagation.inject(context.active(), carrier) writes the trace context into a carrier object. On the receiving side, propagation.extract(context.active(), carrier) reads it back . The carrier is typically a plain object that you serialize into whatever format the transport requires.
const { context, propagation } = require('@opentelemetry/api');
// Sending side
const carrier = {};
propagation.inject(context.active(), carrier);
// carrier now contains traceparent and tracestate
// Receiving side
const extractedContext = propagation.extract(context.active(), carrier);
context.with(extractedContext, () => {
// spans created here are part of the same trace
});
The traceparent header contains the trace ID, the parent span ID, and the trace flags. The tracestate header carries vendor-specific data. The W3C Trace Context specification defines the format, and OpenTelemetry uses it by default .
Complete Example Session
The following session builds an Express application with OpenTelemetry, including auto-instrumentation for HTTP and Express, a manual span for a business operation, and log correlation with Pino.
// ============================================
// PART 1: THE INSTRUMENTATION FILE
// ============================================
// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const sdk = new NodeSDK({
resource: resourceFromAttributes({
'service.name': 'order-api',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});
// ============================================
// PART 2: THE APPLICATION
// ============================================
// app.js
require('./instrumentation');
const express = require('express');
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const pino = require('pino');
const logger = pino();
const tracer = trace.getTracer('order-api');
const app = express();
app.use(express.json());
// ============================================
// PART 3: A ROUTE WITH AUTO-INSTRUMENTATION
// ============================================
app.get('/orders/:id', async (req, res) => {
const { id } = req.params;
logger.info({ orderId: id }, 'Fetching order');
res.json({ id, status: 'pending' });
});
// ============================================
// PART 4: A ROUTE WITH A MANUAL SPAN
// ============================================
app.post('/orders', async (req, res) => {
const span = tracer.startSpan('create-order');
try {
span.setAttribute('order.items', req.body.items.length);
const order = await processOrder(req.body);
span.setStatus({ code: SpanStatusCode.OK });
res.json(order);
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
span.recordException(error);
res.status(500).json({ error: error.message });
} finally {
span.end();
}
});
// ============================================
// PART 5: THE BUSINESS LOGIC
// ============================================
async function processOrder(data) {
// Auto-instrumentation creates a CLIENT span for outbound HTTP
const inventory = await fetch(`http://inventory:3001/check`, {
method: 'POST',
body: JSON.stringify({ items: data.items }),
});
const result = await inventory.json();
if (!result.available) {
throw new Error('Items out of stock');
}
return { id: 'order-123', status: 'created' };
}
// ============================================
// PART 6: START THE SERVER
// ============================================
app.listen(3000, () => {
console.log('Order API listening on :3000');
});
// ============================================
// PART 7: LOG CORRELATION WITH PINO
// ============================================
// In the Pino configuration, extract trace context:
const { context, trace } = require('@opentelemetry/api');
const logger = pino({
formatters: {
log(object) {
const span = trace.getSpan(context.active());
if (!span) return object;
const { traceId, spanId, traceFlags } = span.spanContext();
return {
...object,
trace_id: traceId,
span_id: spanId,
trace_flags: traceFlags.toString(16).padStart(2, '0'),
};
},
},
});
# ============================================
# PART 8: RUN THE APPLICATION
# ============================================
node --require ./instrumentation.js app.js
# ============================================
# PART 9: RUN THE COLLECTOR AND BACKEND
# ============================================
# OpenTelemetry Collector (optional) and Jaeger
docker run -d --name jaeger \
-p 16686:16686 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
# ============================================
# PART 10: VERIFY THE TRACE
# ============================================
# Make a request:
curl -X POST http://localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"items": [{"productId": "p1", "quantity": 2}]}'
# Open http://localhost:16686 in a browser
# Select "order-api" from the service list
# The trace shows:
# - SERVER span for POST /orders
# - INTERNAL span for create-order
# - CLIENT span for GET http://inventory:3001/check
The ten parts cover the instrumentation file with OTLP exporter, the application entry point that preloads instrumentation, a route with auto-instrumentation, a route with a manual span, the business logic that triggers an outbound CLIENT span, the server startup, the Pino log correlation configuration, the run command with --require, the Jaeger container for viewing traces, and the verification steps. What the session demonstrates is that auto-instrumentation covers the boundaries, manual spans cover the business logic, and the trace context propagates through HTTP headers without explicit code.
Quick Reference
Required Packages
| Package | Purpose |
|---|---|
@opentelemetry/sdk-node | SDK initialization |
@opentelemetry/api | Tracer and context API |
@opentelemetry/auto-instrumentations-node | All supported instrumentations |
@opentelemetry/exporter-trace-otlp-http | OTLP exporter |
@opentelemetry/instrumentation-http | HTTP client/server instrumentation |
@opentelemetry/instrumentation-express | Express instrumentation |
Initialization Order
| Step | Action |
|---|---|
| 1 | Create instrumentation.js with NodeSDK |
| 2 | Call sdk.start() |
| 3 | Run application with --require ./instrumentation.js |
| 4 | Application code runs after SDK is ready |
Manual Span API
| Method | Purpose |
|---|---|
tracer.startSpan(name) | Create a span |
tracer.startActiveSpan(name, fn) | Create and activate |
span.setAttribute(key, value) | Add context |
span.addEvent(name) | Timestamped event |
span.recordException(err) | Capture error |
span.setStatus({ code }) | Mark success/error |
span.end() | Close the span |
Span Kinds
| Kind | Use Case |
|---|---|
SERVER | Inbound request |
CLIENT | Outbound call |
PRODUCER | Queue send |
CONSUMER | Queue receive |
INTERNAL | In-process work |
Exporter Configuration
| Option | Default | Purpose |
|---|---|---|
url | http://localhost:4318/v1/traces | OTLP HTTP endpoint |
headers | — | Auth headers |
BatchSpanProcessor | Automatic | Batched export |
Best Practices
✅ Do This:
require('./instrumentation'); // ✅ preload first
tracer.startActiveSpan('validate', async (span) => { ... }) // ✅ active span
span.recordException(error); span.setStatus({ code: ERROR }) // ✅ capture errors
getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false } }) // ✅ disable noisy
❌ Don’t Do This:
const express = require('express'); require('./instrumentation'); // ❌ wrong order
tracer.startSpan('manual'); // never ended // ❌ span leaks
// no service.name in resource // ❌ anonymous traces
// pino without trace context extraction // ❌ logs and traces disconnected
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Traces missing | SDK initialized after libraries loaded | Use --require or --import |
| No trace in backend | Exporter URL wrong or collector not running | Check http://localhost:4318/v1/traces |
| Spans not connected | Context not propagated | Use HTTP instrumentation or propagation.inject |
| Manual span never appears | span.end() not called | Use startActiveSpan or finally |
| Logs lack trace IDs | Pino formatter not configured | Extract from trace.getSpan(context.active()) |
| Auto-instrumentation too broad | Filesystem spans flood | Disable instrumentation-fs |
Real-World Examples
1. Basic SDK initialization
const sdk = new NodeSDK({ traceExporter: new OTLPTraceExporter(), instrumentations: [getNodeAutoInstrumentations()] });
sdk.start();
2. Manual span for business logic
tracer.startActiveSpan('calculate-price', (span) => { span.setAttribute('items', count); span.end(); });
3. Error recording
span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR });
4. Custom resource attributes
resourceFromAttributes({ 'service.name': 'api', 'deployment.environment': 'prod' })
5. Disable specific instrumentation
getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false } })
6. Manual propagation
propagation.inject(context.active(), carrier);
7. Pino trace correlation
const span = trace.getSpan(context.active()); trace_id: span?.spanContext().traceId
8. OTLP exporter endpoint
new OTLPTraceExporter({ url: 'http://collector:4318/v1/traces' })
9. Graceful shutdown
process.on('SIGTERM', () => sdk.shutdown().finally(() => process.exit(0)));
10. Span kind for outbound call
tracer.startSpan('fetch-inventory', { kind: SpanKind.CLIENT });
Visual
SDK Initialization Order
┌──────────────────────────────────────────────┐
│ node --require ./instrumentation.js app.js │
│ │ │
│ ▼ │
│ 1. instrumentation.js runs │
│ - NodeSDK created │
│ - Instrumentations registered │
│ - sdk.start() │
│ │ │
│ ▼ │
│ 2. app.js runs │
│ - require('express') │
│ - Express is already wrapped │
│ - Spans created automatically │
└──────────────────────────────────────────────┘
Span Hierarchy
┌──────────────────────────────────────────────┐
│ Trace: POST /orders │
│ ┌────────────────────────────────────────┐ │
│ │ SERVER span: POST /orders │ │
│ │ │ │ │
│ │ ├── INTERNAL span: create-order │ │
│ │ │ │ │ │
│ │ │ └── CLIENT span: GET inventory │ │
│ │ │ │ │
│ │ └── (auto-instrumented) │ │
│ └────────────────────────────────────────┘ │
│ All spans share the same trace ID │
└──────────────────────────────────────────────┘
Context Propagation
┌──────────────────────────────────────────────┐
│ SERVICE A │
│ traceparent: 00-abc123-def456-01 │
│ │ │
│ ▼ │
│ HTTP request with traceparent header │
│ │ │
│ ▼ │
│ SERVICE B │
│ instrumentation-http extracts context │
│ New spans use abc123 as trace ID │
└──────────────────────────────────────────────┘
Log Correlation
┌──────────────────────────────────────────────┐
│ Pino log entry │
│ { │
│ "level": 30, │
│ "msg": "Processing order", │
│ "trace_id": "abc123", ← from active span │
│ "span_id": "def456", │
│ "orderId": "order-789" │
│ } │
│ │
│ Backend can join log to trace by trace_id │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| SDK | @opentelemetry/sdk-node |
| Auto-instrumentation | @opentelemetry/auto-instrumentations-node |
| Preload flag | --require (CJS) or --import (ESM) |
| Exporter | OTLPTraceExporter (OTLP HTTP) |
| Default endpoint | http://localhost:4318/v1/traces |
| Manual span | tracer.startActiveSpan |
| Error capture | span.recordException, setStatus |
| Propagation | Automatic via HTTP instrumentation |
| Log correlation | Extract traceId from active span |
Key takeaways:
- OpenTelemetry must load before application code. Use
--requireor--importto preload the instrumentation file. If libraries load first, their functions are cached without instrumentation, and spans are lost . - Auto-instrumentation covers boundaries. HTTP servers, HTTP clients, Express, pg, Redis, and dozens of other libraries are instrumented automatically when you pass
getNodeAutoInstrumentations()to the SDK . - Manual spans cover business logic.
tracer.startActiveSpancreates a span, makes it active in the context, and ends it when the callback completes. UsesetAttributefor context,addEventfor timestamps, andrecordExceptionfor errors . - Context propagation is automatic for HTTP. The HTTP instrumentation injects
traceparentandtracestateinto outbound requests and extracts them from inbound requests, connecting spans across services . - The exporter sends traces to a backend. The OTLP exporter posts to a collector or a backend that speaks the protocol. The default endpoint is
http://localhost:4318/v1/traces. - Logs can carry trace context. A Pino formatter that reads
trace.getSpan(context.active())addstrace_idandspan_idto every log entry, making it possible to jump from a log line to the full trace .
Remember: OpenTelemetry is the plumbing that makes distributed tracing possible in Node.js. The SDK preload ensures the instrumentation is in place before the libraries load. Auto-instrumentation captures the boundaries. Manual spans capture the business logic. Context propagation connects the services. And the exporter sends it all to a backend where you can see the full picture. The setup is a single file and a flag, but the discipline is in the details: preload first, use startActiveSpan for manual work, record exceptions, and correlate logs. Without the preload, nothing works. With it, every request becomes a trace you can follow from entry to exit.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!