| |

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

PackagePurpose
@opentelemetry/sdk-nodeSDK initialization
@opentelemetry/apiTracer and context API
@opentelemetry/auto-instrumentations-nodeAll supported instrumentations
@opentelemetry/exporter-trace-otlp-httpOTLP exporter
@opentelemetry/instrumentation-httpHTTP client/server instrumentation
@opentelemetry/instrumentation-expressExpress instrumentation

Initialization Order

StepAction
1Create instrumentation.js with NodeSDK
2Call sdk.start()
3Run application with --require ./instrumentation.js
4Application code runs after SDK is ready

Manual Span API

MethodPurpose
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

KindUse Case
SERVERInbound request
CLIENTOutbound call
PRODUCERQueue send
CONSUMERQueue receive
INTERNALIn-process work

Exporter Configuration

OptionDefaultPurpose
urlhttp://localhost:4318/v1/tracesOTLP HTTP endpoint
headers—Auth headers
BatchSpanProcessorAutomaticBatched 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

PitfallWhy It HappensFix
Traces missingSDK initialized after libraries loadedUse --require or --import
No trace in backendExporter URL wrong or collector not runningCheck http://localhost:4318/v1/traces
Spans not connectedContext not propagatedUse HTTP instrumentation or propagation.inject
Manual span never appearsspan.end() not calledUse startActiveSpan or finally
Logs lack trace IDsPino formatter not configuredExtract from trace.getSpan(context.active())
Auto-instrumentation too broadFilesystem spans floodDisable 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

ItemValue
SDK@opentelemetry/sdk-node
Auto-instrumentation@opentelemetry/auto-instrumentations-node
Preload flag--require (CJS) or --import (ESM)
ExporterOTLPTraceExporter (OTLP HTTP)
Default endpointhttp://localhost:4318/v1/traces
Manual spantracer.startActiveSpan
Error capturespan.recordException, setStatus
PropagationAutomatic via HTTP instrumentation
Log correlationExtract traceId from active span

Key takeaways:

  • OpenTelemetry must load before application code. Use --require or --import to 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.startActiveSpan creates a span, makes it active in the context, and ends it when the callback completes. Use setAttribute for context, addEvent for timestamps, and recordException for errors .
  • Context propagation is automatic for HTTP. The HTTP instrumentation injects traceparent and tracestate into 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()) adds trace_id and span_id to 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!