# How to Instrument a Node.js Service with OpenTelemetry

> Step-by-step guide to adding OpenTelemetry instrumentation to a Node.js service — auto-instrumentation, custom spans, and exporting traces to your backend.

- **Published:** 2025-06-14
- **Author:** Clixo
- **Reading time:** 6 min read
- **Tags:** opentelemetry, nodejs, tracing, instrumentation, observability
- **Canonical URL:** https://clixo.sh/blog/how-to-instrument-nodejs-with-opentelemetry

You have a Node.js service running in production and you have no visibility into what it is doing at the request level. You know something is slow but you cannot tell whether it is the database, a downstream API, or your own application code. Adding distributed tracing with OpenTelemetry is the fastest way to answer that question — and because the instrumentation is portable, you are not locked into any specific observability vendor.

This guide walks through a complete OpenTelemetry setup for a Node.js service: installing the packages, writing the initialization code, adding custom spans, and exporting telemetry to a backend.

## What OpenTelemetry Gives You

OpenTelemetry is an open-source observability framework maintained by the CNCF. It standardizes how you instrument code to produce metrics, logs, and traces. The key benefit is vendor neutrality: you instrument once using OpenTelemetry's APIs, and you can export to Jaeger, Tempo, Datadog, Honeycomb, or any other OTLP-compatible backend without changing your application code.

For Node.js specifically, OpenTelemetry provides auto-instrumentation libraries that hook into popular frameworks and libraries — Express, Fastify, http, gRPC, pg, mongodb, redis — and generate spans automatically without you writing any code for those layers.

```mermaid
flowchart LR
  A["Node.js Service"] --> B["Auto-instrumentation"]
  A --> C["Custom Spans"]
  B --> D["OpenTelemetry SDK"]
  C --> D
  D --> E["OTLP Exporter"]
  E --> F["OTel Collector"]
  F --> G["Jaeger / Tempo / Datadog"]
```

## Step 1: Install the Required Packages

For most Node.js services, you need:

```bash
npm install \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions
```

The `sdk-node` package bundles the core SDK. `auto-instrumentations-node` covers most popular libraries automatically. The OTLP HTTP exporter sends traces to any OTLP-compatible backend.

## Step 2: Write the Initialization File

OpenTelemetry must be initialized before your application code loads. Create a dedicated file — `tracing.js` or `instrumentation.js` — and load it first using the `--require` Node flag.

```js
// tracing.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { Resource } = require('@opentelemetry/resources');
const { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');

const exporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
});

const sdk = new NodeSDK({
  resource: new Resource({
    [SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME || 'my-service',
    [SEMRESATTRS_SERVICE_VERSION]: process.env.SERVICE_VERSION || '0.0.0',
  }),
  traceExporter: exporter,
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { enabled: false },
    }),
  ],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0));
});
```

The `fs` instrumentation is disabled here because it generates excessive noise in most applications. Review the other auto-instrumentation options and disable any that produce low-value spans for your use case.

Launch your application with:

```bash
node --require ./tracing.js server.js
```

At this point, every Express/Fastify route, outbound HTTP call, and database query made through instrumented libraries will automatically generate spans.

## Step 3: Add Custom Spans for Your Business Logic

Auto-instrumentation captures the infrastructure layer. Custom spans capture the business logic layer — the parts that matter most for understanding what your application is actually doing.

```js
const { trace, context } = require('@opentelemetry/api');

const tracer = trace.getTracer('order-service');

async function processOrder(orderId, userId) {
  return tracer.startActiveSpan('order.process', async (span) => {
    span.setAttribute('order.id', orderId);
    span.setAttribute('user.id', userId);

    try {
      const order = await fetchOrder(orderId);
      span.setAttribute('order.amount_cents', order.amountCents);
      span.setAttribute('order.item_count', order.items.length);

      await chargePayment(order);
      await fulfillOrder(order);

      span.setStatus({ code: 1 }); // SpanStatusCode.OK
      return order;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: 2, message: err.message }); // SpanStatusCode.ERROR
      throw err;
    } finally {
      span.end();
    }
  });
}
```

A few conventions worth following:

- Use dot-notation for span names to create a logical hierarchy: `order.process`, `order.payment.charge`, `order.inventory.reserve`.
- Set attributes that answer the questions you will ask during debugging: entity IDs, amounts, counts, flags.
- Always record exceptions with `span.recordException(err)` — this captures the stack trace as a span event.
- Always call `span.end()` in a `finally` block. Unclosed spans are silently dropped by most exporters.

## Step 4: Propagate Trace Context Across Service Boundaries

Auto-instrumentation handles context propagation automatically for outbound HTTP calls made with the native `http`/`https` modules. If you use `axios` or `node-fetch`, verify that the instrumentation for those libraries is enabled and that the receiving service is also instrumented to read the incoming trace context headers.

For async operations like message queue consumers, you need to propagate context manually. When publishing a message, serialize the current span context into the message headers. When consuming, extract it before processing:

```js
const { propagation, context } = require('@opentelemetry/api');

// Publishing
const carrier = {};
propagation.inject(context.active(), carrier);
await queue.publish({ body: payload, headers: carrier });

// Consuming
const extractedContext = propagation.extract(context.active(), message.headers);
context.with(extractedContext, () => {
  processMessage(message);
});
```

Without this, async operations create orphaned traces that cannot be linked to the originating request.

## Step 5: Configure the Exporter for Your Backend

The OTLP HTTP exporter in the example above points to `localhost:4318`. In production:

- For **Grafana Tempo**: set the endpoint to your Tempo collector's OTLP endpoint. If you are using the OpenTelemetry Collector as an intermediate, point to the collector instead.
- For **Datadog**: use the Datadog Agent with OTLP ingestion enabled, or use Datadog's OTLP endpoint directly with an API key in the request headers.
- For **Jaeger**: Jaeger 1.35+ accepts OTLP directly. Set the endpoint to your Jaeger collector.
- For **Honeycomb or similar**: use the appropriate OTLP endpoint and configure API key auth via environment variables rather than hardcoding.

Regardless of backend, use environment variables for the endpoint URL and any authentication headers. Do not hardcode these in source.

## What to Expect After Setup

After instrumenting your first service:

1. Open your tracing backend and look for traces from the service. You should see spans for every HTTP request, with child spans for database queries and downstream calls.
2. Find the slowest requests in your latency histogram and drill into their traces. The span breakdown will show exactly where time is spent.
3. Look for spans with error status. Each should carry the exception event with the stack trace.

The first hour of looking at real traces from a previously uninstrumented service is usually eye-opening. Problems that were suspected but unconfirmed become obvious.

If you are setting up observability for a Node.js system and want the instrumentation done correctly across a multi-service architecture, [Clixo can help you get there efficiently](https://clixo.sh/#contact).

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
