OpenTelemetry Collector + Node.js Tracing Tutorial (2026)
June 13, 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+batchprocessors, andotlp+debugexporters - 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:
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.1 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.2
Prerequisites
- Node.js 24.16.0 (the current Active LTS line) or newer. The
node --importloader flag this guide relies on is stable since Node 20.6.3 - Docker with the Compose plugin (
docker compose, not the legacydocker-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 latest4 |
@opentelemetry/auto-instrumentations-node | 0.77.0 | npm latest4 |
@opentelemetry/exporter-trace-otlp-proto | 0.219.0 | npm latest4 |
@opentelemetry/resources | 2.8.0 | npm latest4 |
@opentelemetry/semantic-conventions | 1.41.1 | npm latest4 |
@opentelemetry/api | 1.9.1 | npm latest4 |
express | 5.2.1 | npm latest |
| OTel Collector (contrib) | 0.153.0 | collector-releases5 |
| Jaeger | 2.19.0 | jaegertracing.io/download6 |
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).4 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:
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 imported, so it lives in its own file that you load first. Create instrumentation.js:
// 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:
resourceFromAttributesis the current API. Thenew Resource()constructor was removed in the SDK 2.x@opentelemetry/resourcesline; calling it now throws. Theservice.nameyou set here is what labels your service in the Jaeger UI.4- The semantic-convention keys are imported constants, not raw strings.
ATTR_SERVICE_NAMEresolves to'service.name',ATTR_DEPLOYMENT_ENVIRONMENT_NAMEto'deployment.environment.name'.7 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. Wiringshutdown()intoSIGTERMflushes 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:
// 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.
# 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_limiteris listed first. It checks heap usage everycheck_intervaland refuses data when the process approacheslimit_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.8batchgroups spans before export, flushing aftertimeoutor oncesend_batch_sizespans accumulate, whichever comes first. Batching cuts the number of outbound requests dramatically.2otlp/jaegerexports over OTLP gRPC tojaeger:4317.tls.insecure: truedisables TLS for the local network — appropriate inside a private Compose network, not for the public internet.2 Thetype/namesyntax (otlp/jaeger) lets you define more than one exporter of the same type later.debugprints spans to the Collector's stdout atverbosity: detailed, which is invaluable while wiring things up. Note the name: this exporter was calledloggingbefore Collector v0.86.0 and renamed todebug. Tutorials that still sayloggingwill fail on a current Collector.2
Now the Compose file that runs both services. Create docker-compose.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
jaegerimage (major v2) is the supported line and is itself built on the OpenTelemetry Collector framework.6 Jaeger v2 accepts OTLP out of the box — there is noCOLLECTOR_OTLP_ENABLED=trueflag to set anymore, because OTLP is on by default.6 - The Collector exports to Jaeger with the
otlpexporter, not ajaegerexporter. The nativejaegerexporter was removed from Collector distributions after v0.85.0; the project's own migration guidance is to send OTLP, which Jaeger now ingests directly.9 - Only the Collector publishes 4317/4318 to your host. Jaeger only exposes its UI port 16686; the Collector reaches Jaeger over the private
otelnetwork, so your app talks tolocalhost:4318and never to Jaeger directly.
Step 5 — Run the stack and generate a trace
Bring up Jaeger and the Collector:
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:
node --import ./instrumentation.js server.js
Expected output:
orders-api listening on :3000
Now send a request so there is something to trace:
curl http://localhost:3000/orders/42
Expected response:
{"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:
{
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:
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.4
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):4
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.8
Verification
A green-path check end to end:
# 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 importing 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.2
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.9
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 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 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 with Workers Logs and Sentry.
Authoritative references:
Footnotes
-
OTLP specification — default ports 4317 (gRPC) and 4318 (HTTP): https://opentelemetry.io/docs/specs/otlp/ ↩
-
OpenTelemetry Collector — Configuration (receivers, processors, exporters incl. the
debugexporter and the note that it wasloggingprior to v0.86.0; last modified 2026-05-29): https://opentelemetry.io/docs/collector/configuration/ ↩ ↩2 ↩3 ↩4 ↩5 -
Node.js releases (24 Active LTS) and the
--importflag (stable since 20.6): https://nodejs.org/en/about/previous-releases ↩ -
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/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
OpenTelemetry Collector Releases (Collector image tags; pin a current
0.15xrelease): https://github.com/open-telemetry/opentelemetry-collector-releases/releases ↩ -
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/ ↩ ↩2 ↩3
-
OpenTelemetry semantic conventions for JS (
ATTR_SERVICE_NAME,ATTR_SERVICE_VERSION,ATTR_DEPLOYMENT_ENVIRONMENT_NAME): https://opentelemetry.io/docs/specs/semconv/ ↩ -
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 ↩ ↩2 -
OpenTelemetry blog — Migrating away from the Jaeger exporter in the Collector (native
jaegerexporter removed; send OTLP instead): https://opentelemetry.io/blog/2023/jaeger-exporter-collector-migration/ ↩ ↩2