When a Node.js API slows down, a log line can tell you that something failed, but it rarely shows where the request spent its time. A distributed trace does: one trace follows a request through Express middleware, database calls, outbound HTTP requests, queues, and your own business logic.

This tutorial adds OpenTelemetry tracing to an existing Node.js API, sends traces over OTLP, and adds a manual span around the operation that matters to the business. It uses vendor-neutral OpenTelemetry packages, so the same instrumentation can feed an OpenTelemetry Collector or any compatible backend.

As of September 2026, the OpenTelemetry site publishes specification 1.60.0, OTLP 1.11.0, and semantic conventions 1.44.0. Package releases move independently, so install a compatible set together and commit your lockfile rather than copying version numbers from an old tutorial.

Understand the Trace You Are Building

A trace represents one end-to-end operation. It contains spans, and each span measures one part of that operation. An incoming POST /orders request might create this tree:

POST /orders
├── reserve_inventory
│   └── SELECT inventory
├── charge_payment
│   └── POST payment-provider.example
└── INSERT orders

Automatic instrumentation can create spans for supported libraries such as Express, the Node HTTP client, and database drivers. Manual instrumentation fills the important gaps: “reserve inventory” is more useful to an operator than another generic function name.

OpenTelemetry is the instrumentation and transport layer, not the trace viewer. You still need a Collector or observability backend to receive and query the spans.

Install the Node.js SDK and OTLP Exporter

This example assumes a JavaScript project using Node.js 20 or newer. The official Node.js getting-started guide uses the --import flag in this setup.

Install the SDK, automatic instrumentations, OTLP HTTP/protobuf exporter, and semantic-convention constants:

npm install \
  @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-proto \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Do not initialize the SDK inside app.js after importing Express. Instrumentation must run before application modules load, otherwise the hooks can miss libraries that are already in memory.

Configure Tracing Before the Application Starts

Create instrumentation.mjs next to your application entry point:

import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from
  '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from
  '@opentelemetry/exporter-trace-otlp-proto';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
  ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
} from '@opentelemetry/semantic-conventions';

const resource = resourceFromAttributes({
  [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'orders-api',
  [ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? 'dev',
  [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]:
    process.env.DEPLOYMENT_ENVIRONMENT ?? 'local',
});

const traceExporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ??
    'http://localhost:4318/v1/traces',
});

const sdk = new NodeSDK({
  resource,
  traceExporter,
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

async function shutdown() {
  try {
    await sdk.shutdown();
  } finally {
    process.exit(0);
  }
}

process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);

The resource attributes answer three basic questions in every backend: which service produced the span, which release was running, and in which environment. Keep service names stable across replicas; use other resource attributes for instance identity.

Start the API with instrumentation preloaded:

OTEL_SERVICE_NAME=orders-api \
APP_VERSION=2026.09.04 \
DEPLOYMENT_ENVIRONMENT=development \
node --import ./instrumentation.mjs ./app.js

If the application is compiled to ECMAScript modules, review OpenTelemetry's current ESM loader-hook guidance. The JavaScript documentation explicitly notes that ESM needs an additional loader hook for automatic instrumentation; test this against your exact Node.js and package versions.

Run a Local Collector

A Collector gives applications one nearby OTLP destination and lets operations teams change exporters, batching, filtering, and authentication without changing application code.

Create collector-config.yaml for a local verification loop:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug]

Run the official Collector image and mount the configuration:

docker run --rm \
  -p 4317:4317 \
  -p 4318:4318 \
  -v "$PWD/collector-config.yaml:/etc/otelcol/config.yaml:ro" \
  otel/opentelemetry-collector

Send a request to the API. The Collector should print a server span plus spans for supported database or HTTP client calls. The OTLP HTTP endpoint for traces ends in /v1/traces; port 4318 by itself is not the complete signal endpoint.

Add a Manual Business Span

Automatic spans show network and framework boundaries. Add manual spans only where they provide a useful operational boundary.

import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('orders-domain');

export async function reserveInventory({ sku, quantity }) {
  return tracer.startActiveSpan(
    'reserve_inventory',
    {
      attributes: {
        'inventory.sku': sku,
        'inventory.quantity': quantity,
      },
    },
    async (span) => {
      try {
        const reservation = await inventory.reserve(sku, quantity);
        span.setAttribute('inventory.result', 'reserved');
        return reservation;
      } catch (error) {
        span.recordException(error);
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: 'Inventory reservation failed',
        });
        throw error;
      } finally {
        span.end();
      }
    },
  );
}

startActiveSpan() makes the new span active during the callback, so supported database calls become children. The finally block matters: an unended span may never be exported and reports a misleading duration.

Use low-cardinality attributes that help filtering. Product SKU may be acceptable if its range is controlled. Email addresses, access tokens, full request bodies, payment data, and arbitrary exception payloads are not. Treat telemetry as production data with its own access and retention policy.

Keep Trace Context Across Service Boundaries

Automatic HTTP instrumentation injects and extracts W3C trace context for supported clients and servers. That is what allows service B to attach its spans to the trace started by service A.

Verify propagation with a two-service test:

  1. Send one request to the public API.
  2. Make that API call a second instrumented service through a supported HTTP client.
  3. Query the backend for the first service's trace ID.
  4. Confirm both services appear in one trace with the expected parent-child relationship.

If a custom transport, queue, or home-grown RPC layer carries the work, you may need explicit context injection and extraction. Do not invent your own trace headers; use the OpenTelemetry propagation API and a standard propagator.

Control Volume Before It Controls Cost

Tracing every request is useful in development but can be expensive at scale. Sampling decides which traces are recorded and exported.

Start with a documented head-sampling policy and configure it through environment variables:

OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.10

This example samples roughly ten percent of new root traces while respecting the parent decision for downstream spans. Validate the exact behavior in your current SDK. For rare errors or latency outliers, tail sampling in a Collector can make a decision after more of the trace is available, but it requires enough Collector capacity to buffer trace data.

Never use sampling as a privacy control. Sensitive attributes must not enter spans in the first place.

Troubleshoot Missing or Broken Traces

No spans appear

Confirm that instrumentation.mjs loads before app.js. Then check the full endpoint, scheme, port, and /v1/traces path. From the application container, localhost points to that same container, not to a Collector in another container.

Server spans appear, but library spans do not

The library may have loaded before instrumentation, may not have a compatible instrumentation package, or may be running through an ESM path that needs the documented loader hook. Enable OpenTelemetry diagnostic logging temporarily and compare the package against the registry.

Every service creates a separate trace

Inspect outgoing and incoming propagation at the boundary between the two services. A proxy, custom client, message serializer, or header allowlist may be removing trace context.

The process hangs during shutdown

Call sdk.shutdown() once on termination, await it, and ensure another signal handler is not fighting the tracing handler. Give orchestrators enough termination grace time for pending batches to flush.

Telemetry is too large or too expensive

Reduce high-cardinality attributes, remove redundant manual spans, and define a sampling policy. A span around every helper function creates noise; instrument meaningful boundaries and business operations.

Production Checklist

  • Load instrumentation before all application code and test the exact ESM/CommonJS startup path.
  • Set stable service.name, release version, and deployment environment resource attributes.
  • Send OTLP to a nearby Collector or supported backend over an authenticated, encrypted connection.
  • Flush the SDK during graceful shutdown and allow sufficient termination time.
  • Verify context propagation through every HTTP, RPC, and messaging boundary.
  • Review span names and attributes for secrets, personal data, and uncontrolled cardinality.
  • Define sampling, retention, and access policies before full production traffic.
  • Monitor Collector queue, export failures, memory, and dropped telemetry.
  • Pin compatible package versions with a lockfile and test upgrades in staging.
  • Add one trace smoke test to deployment verification.

Official Sources

With automatic spans, one carefully chosen business span, and an OTLP Collector path, a Node.js API gains an end-to-end view without becoming tied to one observability vendor. Start with a single critical request, verify the trace tree, and expand only where the additional detail answers a real operational question.