---
title: OpenTelemetry Collector + Node.js Tracing Tutorial (2026)
description: Instrument a Node.js app with OpenTelemetry, run the Collector and Jaeger v2 in Docker, and view distributed traces. Every version pinned and verified, 2026.
url: https://nerdleveltech.com/opentelemetry-collector-nodejs-tracing-tutorial
language: en
date: '2026-06-13'
category: cloud-devops
tags:
  - opentelemetry
  - opentelemetry collector
  - node.js
  - distributed tracing
  - jaeger
  - observability
  - otlp
  - devops
markdown_url: https://nerdleveltech.com/opentelemetry-collector-nodejs-tracing-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/opentelemetry-collector-nodejs-tracing-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# OpenTelemetry Collector + Node.js Tracing Tutorial (2026)

## TL;DR

You will instrument a Node.js + Express API with OpenTelemetry, send its traces over OTLP to an **OpenTelemetry Collector**, fan them out to **Jaeger v2**, and read the resulting distributed traces in the Jaeger UI. The whole stack runs locally with one `docker compose up`. Every package version and image tag below was pinned and verified on 2026-06-13, and the instrumentation code was executed to confirm the spans it produces. Budget about 30 minutes.

## What you'll learn

- How to send Node.js OpenTelemetry traces to the Collector and then to Jaeger, and why the Collector sits between them
- How to auto-instrument an Express app so HTTP and route spans appear with zero per-handler code
- How to write a production-shaped Collector config with the OTLP receiver, `memory_limiter` + `batch` processors, and `otlp` + `debug` exporters
- How to run Jaeger v2 in Docker and view distributed traces at `http://localhost:16686`
- How to add a custom span and resource attributes with the current SDK 2.x API (`resourceFromAttributes`)
- How to debug the classic "my spans never show up in Jaeger" failure

## How the pieces fit together

A direct answer first: your app exports spans over **OTLP** (the OpenTelemetry wire protocol) to a Collector; the Collector batches and forwards them to Jaeger v2, which stores and renders them. The Collector is optional for a hello-world but standard in production because it decouples your app from the backend — you can add sampling, batching, retries, and extra destinations without redeploying the app.

Here is the data flow you are about to build:

```mermaid
flowchart LR
  A["Node.js app<br/>(OTLP/HTTP :4318)"] -->|OTLP| C["OTel Collector<br/>memory_limiter, batch"]
  C -->|OTLP gRPC :4317| J["Jaeger v2<br/>storage + query"]
  C -->|debug exporter| L["Collector logs<br/>(stdout)"]
  J --> U["Jaeger UI<br/>:16686"]
```

OTLP travels over two transports: gRPC on port **4317** and HTTP on port **4318**.[^otlp] The app in this guide uses OTLP/HTTP to reach the Collector; the Collector uses OTLP/gRPC to reach Jaeger. Both are first-class — the choice is about dependencies and firewalls, not correctness.

Why bother with a Collector at all when Jaeger v2 can receive OTLP directly? Because the Collector is where operational concerns live. Running one as an **agent** next to each app (or as a shared **gateway** for a cluster) means you can change sampling rates, add a second backend, scrub sensitive attributes, or absorb a backend outage with retries and queueing — all without redeploying a single application. The app stays dumb: it emits OTLP and forgets. That decoupling is the entire reason the Collector exists, and it is why production setups put one in the path even though the protocol would let you skip it.[^collector-config]

## Prerequisites

- **Node.js 24.16.0** (the current Active LTS line) or newer. The `node --import` loader flag this guide relies on is stable since Node 20.6.[^node]
- **Docker** with the Compose plugin (`docker compose`, not the legacy `docker-compose`).
- A terminal and a browser. No Jaeger or Collector knowledge assumed.

Everything else is installed below. The pinned versions used throughout:

| Component | Version | Source |
|-----------|---------|--------|
| `@opentelemetry/sdk-node` | 0.219.0 | npm `latest`[^otel-js] |
| `@opentelemetry/auto-instrumentations-node` | 0.77.0 | npm `latest`[^otel-js] |
| `@opentelemetry/exporter-trace-otlp-proto` | 0.219.0 | npm `latest`[^otel-js] |
| `@opentelemetry/resources` | 2.8.0 | npm `latest`[^otel-js] |
| `@opentelemetry/semantic-conventions` | 1.41.1 | npm `latest`[^otel-js] |
| `@opentelemetry/api` | 1.9.1 | npm `latest`[^otel-js] |
| `express` | 5.2.1 | npm `latest` |
| OTel Collector (contrib) | 0.153.0 | collector-releases[^collector-release] |
| Jaeger | 2.19.0 | jaegertracing.io/download[^jaeger-v2] |

One quirk worth knowing up front: OpenTelemetry JS ships its **stable** packages on 1.x/2.x version lines (`api` 1.9.1, `resources` 2.8.0) and its still-**experimental** packages on a shared 0.x line (`sdk-node` 0.219.0, the exporters, the auto-instrumentations).[^otel-js] A mismatch like "sdk-node 2.x" does not exist — 0.219.0 is the current experimental release, and that is expected.

## Step 1 — Scaffold the project and install OpenTelemetry

Create a project directory and install the SDK, the auto-instrumentation bundle, the OTLP exporter, and Express:

```bash
mkdir orders-api && cd orders-api
npm init -y
npm pkg set type=module
npm install \
  @opentelemetry/api@1.9.1 \
  @opentelemetry/sdk-node@0.219.0 \
  @opentelemetry/auto-instrumentations-node@0.77.0 \
  @opentelemetry/exporter-trace-otlp-proto@0.219.0 \
  @opentelemetry/resources@2.8.0 \
  @opentelemetry/semantic-conventions@1.41.1 \
  express@5.2.1
```

`npm pkg set type=module` makes the project ES modules, which is what the bootstrap file below assumes. After the install completes, `npm ls --depth=0` should list each package at the version you requested with no `ERESOLVE` warnings.

## Step 2 — Write the instrumentation bootstrap

OpenTelemetry must initialize **before** any instrumented library (`http`, `express`) is `import`ed, so it lives in its own file that you load first. Create `instrumentation.js`:

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

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'orders-api',
    [ATTR_SERVICE_VERSION]: '1.0.0',
    [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: 'development',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

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

Three details matter here, and each is a place older tutorials go wrong:

- **`resourceFromAttributes` is the current API.** The `new Resource()` constructor was removed in the SDK 2.x `@opentelemetry/resources` line; calling it now throws. The `service.name` you set here is what labels your service in the Jaeger UI.[^otel-js]
- **The semantic-convention keys are imported constants, not raw strings.** `ATTR_SERVICE_NAME` resolves to `'service.name'`, `ATTR_DEPLOYMENT_ENVIRONMENT_NAME` to `'deployment.environment.name'`.[^semconv] Importing them keeps you aligned with the spec instead of hand-typing attribute names.
- **`sdk.start()` is synchronous; `sdk.shutdown()` returns a Promise.** The default span processor batches spans, so a process that exits abruptly can drop whatever has not been flushed. Wiring `shutdown()` into `SIGTERM` flushes the batch on a clean stop.

## Step 3 — Write the Express app

Now the application itself. Create `server.js` with one route that does a little internal work, plus a manual span so you can see custom instrumentation alongside the automatic kind:

```javascript
// server.js
import express from 'express';
import { trace } from '@opentelemetry/api';

const app = express();
const tracer = trace.getTracer('orders-api');

app.get('/orders/:id', async (req, res) => {
  const span = tracer.startSpan('load-order-from-db');
  await new Promise((resolve) => setTimeout(resolve, 25)); // pretend DB call
  span.setAttribute('order.id', req.params.id);
  span.end();
  res.json({ id: req.params.id, status: 'shipped' });
});

app.listen(3000, () => console.log('orders-api listening on :3000'));
```

You never imported anything OpenTelemetry-specific to get HTTP and route spans — `getNodeAutoInstrumentations()` patches the `http` and Express layers for you. The only manual telemetry is the `load-order-from-db` span, which nests inside the automatic request span because it is created while that request is on the active context.

## Step 4 — Configure the Collector and Jaeger

Create `otel-collector.yaml`. This is a production-shaped trace pipeline: receive OTLP, guard memory, batch, then export to Jaeger and to the Collector's own logs.

```yaml
# otel-collector.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128
  batch:
    timeout: 5s
    send_batch_size: 1024

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/jaeger, debug]
```

Why each component is here:

- **`memory_limiter` is listed first.** It checks heap usage every `check_interval` and refuses data when the process approaches `limit_mib`, so a traffic spike sheds load instead of OOM-killing the Collector. The official guidance is to place it before any other processor so it protects the whole pipeline.[^memlimiter]
- **`batch` groups spans before export**, flushing after `timeout` or once `send_batch_size` spans accumulate, whichever comes first. Batching cuts the number of outbound requests dramatically.[^collector-config]
- **`otlp/jaeger` exports over OTLP gRPC to `jaeger:4317`.** `tls.insecure: true` disables TLS for the local network — appropriate inside a private Compose network, not for the public internet.[^collector-config] The `type/name` syntax (`otlp/jaeger`) lets you define more than one exporter of the same type later.
- **`debug` prints spans to the Collector's stdout** at `verbosity: detailed`, which is invaluable while wiring things up. Note the name: this exporter was called `logging` before Collector v0.86.0 and renamed to `debug`. Tutorials that still say `logging` will fail on a current Collector.[^collector-config]

Now the Compose file that runs both services. Create `docker-compose.yaml`:

```yaml
# docker-compose.yaml
services:
  jaeger:
    image: cr.jaegertracing.io/jaegertracing/jaeger:2.19.0
    container_name: jaeger
    ports:
      - "16686:16686"
    networks: [otel]

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.153.0
    container_name: otel-collector
    command: ["--config=/etc/otelcol-contrib/config.yaml"]
    volumes:
      - ./otel-collector.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"
    depends_on: [jaeger]
    networks: [otel]

networks:
  otel:
```

A few things this file gets right that stale guides do not:

- **Jaeger v2, not v1.** Jaeger v1 reached end-of-life on 2025-12-31; the `jaeger` image (major v2) is the supported line and is itself built on the OpenTelemetry Collector framework.[^jaeger-v2] Jaeger v2 accepts OTLP out of the box — there is no `COLLECTOR_OTLP_ENABLED=true` flag to set anymore, because OTLP is on by default.[^jaeger-v2]
- **The Collector exports to Jaeger with the `otlp` exporter, not a `jaeger` exporter.** The native `jaeger` exporter was removed from Collector distributions after v0.85.0; the project's own migration guidance is to send OTLP, which Jaeger now ingests directly.[^jaeger-exporter]
- **Only the Collector publishes 4317/4318 to your host.** Jaeger only exposes its UI port 16686; the Collector reaches Jaeger over the private `otel` network, so your app talks to `localhost:4318` and never to Jaeger directly.

## Step 5 — Run the stack and generate a trace

Bring up Jaeger and the Collector:

```bash
docker compose up -d
docker compose ps
```

Both containers should report a running state. In a second terminal, start the app with the instrumentation file loaded **before** the server via `--import`:

```bash
node --import ./instrumentation.js server.js
```

Expected output:

```text
orders-api listening on :3000
```

Now send a request so there is something to trace:

```bash
curl http://localhost:3000/orders/42
```

Expected response:

```json
{"id":"42","status":"shipped"}
```

Within a second or two, the Collector's `debug` exporter will log the received spans, and the same spans will land in Jaeger. You can watch the Collector chew on them with `docker compose logs -f otel-collector`.

If you want a backend-free sanity check first, temporarily swap the OTLP exporter for a console one. Running the same app with a `ConsoleSpanExporter` prints each finished span; a single `curl` to `/orders/42` produces one connected trace whose spans share a `traceId`:

```text
{
  name: 'GET',
  kind: 1,                       // SERVER span (the HTTP request)
  attributes: { 'http.method': 'GET', 'http.status_code': 200 },
  resource: { 'service.name': 'orders-api' }
}
{
  name: 'request handler - /orders/:id',
  attributes: { 'http.route': '/orders/:id' }   // the Express route span
}
{
  name: 'load-order-from-db',
  kind: 0,                       // your custom span, nested in the request
  attributes: { 'order.id': '42' }
}
```

That output above is abbreviated — the real console exporter prints trace/span IDs, timestamps, and more attributes — but the span **names**, the SERVER **kind**, the route's `http.route`, and your `order.id` are exactly what the instrumentation emits.

## Step 6 — Read the trace in Jaeger

Open `http://localhost:16686`. In the **Service** dropdown, choose `orders-api` (the `service.name` from your resource), leave the operation as "all", and click **Find Traces**.

You will see one trace per request. Open it and you get a waterfall: the root `GET /orders/:id` server span, a child span for the Express route handler, and your `load-order-from-db` span underneath, roughly 25 ms wide — the artificial delay you added. Click any span to inspect its tags, including `service.name`, `http.route`, and `order.id`.

Read the waterfall the way you would read a flame graph. The width of each bar is wall-clock time, so the `load-order-from-db` span visibly dominating the request is your signal that the "database" step — not framework overhead — owns the latency. When a request is slow in production, this view answers "slow *where*" in seconds instead of forcing you to reason about it from scattered logs. The gap between a span ending and its parent ending is unaccounted time, which usually points at synchronous work that isn't yet instrumented.

This is the whole point of distributed tracing: a single request becomes a tree of timed operations you can drill into. Add a second service that this one calls over HTTP, propagate context (the auto-instrumentation does it for you), and Jaeger stitches both services into one trace.

## Step 7 — Production hardening

The pipeline you built is already shaped for production, but three settings deserve deliberate tuning before you ship it.

**Sample, don't store everything.** At real traffic, storing 100% of traces is expensive and rarely useful. Set the SDK's sampler with an environment variable so you keep, say, 10% of traces head-based — no code change required:

```bash
OTEL_TRACES_SAMPLER=traceidratio \
OTEL_TRACES_SAMPLER_ARG=0.1 \
node --import ./instrumentation.js server.js
```

`traceidratio` makes the keep/drop decision deterministically from the trace ID, so — as long as every service uses the same ratio — a trace is either fully kept or fully dropped across all of them and you never get half a trace.[^otel-js]

**Point the exporter with environment variables, not hard-coded URLs.** In the bootstrap you set the URL inline for clarity, but the standard `OTEL_EXPORTER_OTLP_ENDPOINT` variable lets ops repoint the app per environment without touching code. The OTLP exporters read it as the base endpoint and append the signal path (`/v1/traces`):[^otel-js]

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
node --import ./instrumentation.js server.js
```

**Mind the batch and memory limits.** The `batch` and `memory_limiter` values in `otel-collector.yaml` are starting points, not gospel. Raise `send_batch_size` for higher throughput, and set `limit_mib` to roughly 80% of the memory you actually give the Collector container so the limiter engages before the kernel does.[^memlimiter]

## Verification

A green-path check end to end:

```bash
# 1. Stack is up
docker compose ps          # jaeger + otel-collector both running

# 2. App responds
curl -s http://localhost:3000/orders/42
# => {"id":"42","status":"shipped"}

# 3. Collector received spans (debug exporter)
docker compose logs otel-collector | grep -i "TracesExporter\|spans"

# 4. Jaeger has the trace
#    Browser: http://localhost:16686 -> Service: orders-api -> Find Traces
```

If step 3 shows the Collector logging received spans and step 4 lists a trace under `orders-api`, the pipeline works.

## Troubleshooting

**No spans in Jaeger, but the app runs fine.** The most common cause is a process that exits before the batch flushes. The default `BatchSpanProcessor` holds spans in memory and exports on a timer; a short-lived script or a `Ctrl+C` without a clean shutdown drops them. Confirm `sdk.shutdown()` runs on exit (as in Step 2), or temporarily switch to a console exporter to prove spans are being produced at all.

**`Cannot find module` or the app starts with zero instrumentation.** The instrumentation file must load before your app code. Use `node --import ./instrumentation.js server.js`; if you are on CommonJS instead of ES modules, the equivalent flag is `--require ./instrumentation.js`. Loading it *after* `import`ing Express means the libraries were already loaded unpatched.

**Collector won't start: `unknown type: "logging"`.** You are on a current Collector with an old config. The `logging` exporter was renamed to `debug` in v0.86.0. Rename the exporter and the pipeline reference.[^collector-config]

**Collector won't start: `unknown type: "jaeger"` in exporters.** Same era of staleness on the export side: the native `jaeger` exporter was removed after v0.85.0. Send OTLP to `jaeger:4317` with the `otlp` exporter instead, exactly as in Step 4.[^jaeger-exporter]

**Connection refused from the app to `localhost:4318`.** The Collector container isn't publishing the OTLP HTTP port, or it crashed on a bad config. Check `docker compose ps` and `docker compose logs otel-collector`; a config typo makes the Collector exit immediately rather than run with defaults.

**`bind: address already in use` on 4317 or 4318.** Something else on your machine already owns the OTLP ports — often a previously orphaned Collector or another telemetry agent. Find it with `lsof -i :4318`, stop it, and `docker compose up -d` again. Changing the published host port (for example `"14318:4318"`) also works, as long as you point the app's exporter at the new host port.

**Every request shows up as two duplicate traces.** This means the SDK started twice — usually because `instrumentation.js` is both passed to `--import` *and* imported again from inside `server.js`. Load it exactly once, through the loader flag, and remove any `import './instrumentation.js'` from your application code.

## Next steps and further reading

- Once traces flow, the harder questions are organizational: retention, cardinality, and cost. The principles in our guide on [designing a modern observability platform](https://nerdleveltech.com/designing-a-modern-observability-platform-principles-patterns-pitfalls) cover the trade-offs a single tutorial can't.
- Tracing is one signal; if your workloads run on Kubernetes, pair this with [zero-downtime Kubernetes deployments](https://nerdleveltech.com/kubernetes-zero-downtime-deployments-tutorial) so a rollout never blackholes the spans mid-deploy.
- For an edge-runtime take on the same problem, see how we wire up [Cloudflare Workers observability](https://nerdleveltech.com/cloudflare-workers-observability-workers-logs-sentry-tutorial) with Workers Logs and Sentry.
- If the spans flowing through this pipeline come from an LLM or agent, check [OpenTelemetry gen_ai attributes: 60 deprecated in 2026](https://nerdleveltech.com/opentelemetry-gen-ai-attributes-deprecated) before writing collector processors or dashboard queries against those attribute names.

Authoritative references:

[^otel-js]: OpenTelemetry JavaScript — Getting Started (Node.js), SDK, exporters, and sampler configuration: https://opentelemetry.io/docs/languages/js/getting-started/nodejs/ and https://opentelemetry.io/docs/languages/sdk-configuration/general/
[^collector-config]: OpenTelemetry Collector — Configuration (receivers, processors, exporters incl. the `debug` exporter and the note that it was `logging` prior to v0.86.0; last modified 2026-05-29): https://opentelemetry.io/docs/collector/configuration/
[^collector-release]: OpenTelemetry Collector Releases (Collector image tags; pin a current `0.15x` release): https://github.com/open-telemetry/opentelemetry-collector-releases/releases
[^memlimiter]: Memory Limiter Processor README (`check_interval`, `limit_mib`, `spike_limit_mib`; place first in the pipeline): https://github.com/open-telemetry/opentelemetry-collector/blob/main/processor/memorylimiterprocessor/README.md
[^jaeger-v2]: Jaeger downloads and v2 notes (latest 2.19.0; v1 EOL 2025-12-31; v2 built on the OTel Collector with OTLP enabled by default): https://www.jaegertracing.io/download/
[^jaeger-exporter]: OpenTelemetry blog — Migrating away from the Jaeger exporter in the Collector (native `jaeger` exporter removed; send OTLP instead): https://opentelemetry.io/blog/2023/jaeger-exporter-collector-migration/
[^semconv]: OpenTelemetry semantic conventions for JS (`ATTR_SERVICE_NAME`, `ATTR_SERVICE_VERSION`, `ATTR_DEPLOYMENT_ENVIRONMENT_NAME`): https://opentelemetry.io/docs/specs/semconv/
[^otlp]: OTLP specification — default ports 4317 (gRPC) and 4318 (HTTP): https://opentelemetry.io/docs/specs/otlp/
[^node]: Node.js releases (24 Active LTS) and the `--import` flag (stable since 20.6): https://nodejs.org/en/about/previous-releases
