Steven's Knowledge

Getting Started

Stand up Jaeger and the OpenTelemetry Collector with Docker, trace a Node.js app end-to-end

Getting Started

This page boots Jaeger (the trace backend) and the OTel Collector (the data pipeline), then traces a Node.js app. Same SDK works with any backend.

Stand Up the Stack

# docker-compose.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:1.55
    environment:
      COLLECTOR_OTLP_ENABLED: "true"
    ports:
      - "16686:16686"        # Jaeger UI
      - "4317:4317"          # OTLP gRPC
      - "4318:4318"          # OTLP HTTP

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.110.0
    command: ["--config=/etc/otelcol-contrib/config.yaml"]
    volumes:
      - ./otel-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4319:4318"          # apps push here; collector forwards to Jaeger
    depends_on: [jaeger]
# otel-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  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: [batch]
      exporters: [otlp/jaeger, debug]
docker compose up -d
open http://localhost:16686       # Jaeger UI

The collector sits between your apps and the backend. You can change backends by editing the collector config — your apps never know.

Why a Collector?

Apps could send directly to Jaeger, but the collector adds:

  • Batching / buffering so apps don't block on backend issues
  • Filtering / sampling — drop noisy spans before they hit storage
  • Multi-backend fan-out — send the same trace to Jaeger and an APM SaaS
  • Enrichment — add cluster / region tags automatically
  • Protocol translation — apps speak OTLP; backends might want their own format

Use the collector. It's a small ops cost for big flexibility.

Instrument a Node.js App

npm install @opentelemetry/sdk-node \
            @opentelemetry/auto-instrumentations-node \
            @opentelemetry/exporter-trace-otlp-http \
            @opentelemetry/resources \
            @opentelemetry/semantic-conventions
// tracing.js — load FIRST, before anything else
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 { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'demo-api',
    [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: 'dev',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4319/v1/traces',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

process.on('SIGTERM', () => sdk.shutdown());
// app.js — load tracing.js FIRST
require('./tracing');

const express = require('express');
const fetch = require('node-fetch');

const app = express();

app.get('/checkout', async (req, res) => {
  const user = await fetch('http://localhost:3001/user').then(r => r.json());
  const inventory = await fetch('http://localhost:3001/inventory').then(r => r.json());
  res.json({ user, inventory });
});

app.listen(3000);

Run it, hit the endpoint, open Jaeger UI — you see the trace with Express, HTTP client, and the downstream service all linked.

getNodeAutoInstrumentations() patches Express, Fastify, http/https, pg, mysql, redis, kafkajs, ioredis, grpc, AWS SDK, MongoDB, GraphQL, and more. You get free tracing for ~30+ popular libraries.

Same Pattern, Other Languages

LanguageSDK install
Pythonpip install opentelemetry-distro opentelemetry-exporter-otlp then opentelemetry-bootstrap -a install
JavaJava agent JAR + -javaagent:opentelemetry-javaagent.jar
Gogo get go.opentelemetry.io/otel — manual SDK setup; auto-instr via libraries
Rubygem install opentelemetry-sdk + per-library packages
.NETdotnet add package OpenTelemetry.Instrumentation.*
Rusttracing crate + tracing-opentelemetry
PHPOTel PHP SDK; per-framework integrations

OTel SDKs exist for every popular language. Use them; don't roll your own.

Add Manual Spans

Auto-instrumentation gets you 80% of the value. For business-logic boundaries you want manual spans:

const { trace } = require('@opentelemetry/api');

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

app.post('/checkout', async (req, res) => {
  const span = tracer.startSpan('process-checkout');
  try {
    span.setAttribute('user.id', req.user.id);
    span.setAttribute('cart.size', req.body.items.length);
    span.setAttribute('cart.total_cents', req.body.total);

    const result = await processOrder(req.body);

    span.setStatus({ code: 1 });   // OK
    res.json(result);
  } catch (err) {
    span.recordException(err);
    span.setStatus({ code: 2, message: err.message });
    res.status(500).json({ error: err.message });
  } finally {
    span.end();
  }
});

Attributes are key/value pairs on the span — visible in the trace UI, searchable, filterable. Use them generously for business context (user ID, account tier, request ID, A/B variant).

Context Propagation

When you call another service, the OTel HTTP instrumentation automatically injects the traceparent header. On the receiving side, the auto-instrumentation extracts it and links the new span to the parent.

Service A                              Service B
fetch(B) → HTTP                        Express middleware
  traceparent: 00-abc123-001            extracts traceparent
                                        creates span linked to 001

For message queues, you propagate manually:

// Producer
const ctx = context.active();
const headers = {};
propagation.inject(ctx, headers);   // populates traceparent into headers
producer.publish(message, { headers });

// Consumer
const parentCtx = propagation.extract(context.active(), msg.headers);
const span = tracer.startSpan('process-message', undefined, parentCtx);

Tear Down

docker compose down -v

What's Next

You have a working trace pipeline. Next:

  • Instrumentation — auto vs manual, attributes, errors, propagation across protocols
  • Best Practices — sampling, retention, correlating with logs, cost, pitfalls

On this page