What a trace looks like
A trace is a tree of spans. Each span represents a unit of work:
[HTTP GET /api/orders/123] total: 145ms
├── [auth middleware] 12ms
├── [db: SELECT order] 45ms
├── [redis: GET cache] 2ms
└── [HTTP: payment-service] 85ms
├── [db: SELECT payment] 30ms
└── [stripe API call] 52ms
Without tracing, you know the request took 145ms. With tracing, you know the payment service's Stripe call is the bottleneck.
Setting up auto-instrumentation
OpenTelemetry's Node.js SDK can instrument Express, pg, ioredis, and HTTP calls automatically:
// tracing.ts — import this BEFORE anything else
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: 'order-service',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://otel-collector:4318/v1/traces',
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
That is it. Every HTTP request, database query, and Redis command is traced automatically. No code changes in your routes or services.
Custom spans for business logic
Auto-instrumentation covers infrastructure. For business logic, create custom spans:
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('order-service');
async function processOrder(orderId: string) {
return tracer.startActiveSpan('processOrder', async (span) => {
span.setAttribute('order.id', orderId);
const order = await getOrder(orderId);
span.setAttribute('order.total', order.total);
const result = await chargePayment(order);
span.setAttribute('payment.status', result.status);
span.end();
return result;
});
}
Context propagation
The magic of distributed tracing is that the trace ID propagates across service boundaries. When service A calls service B, the trace context is injected into HTTP headers automatically:
GET /api/payments/pay_123
traceparent: 00-abc123-def456-01
Service B picks up the context and continues the trace. No manual header passing required — the auto-instrumentation handles it.
The collector pattern
Do not export traces directly from your app to Jaeger or Datadog. Use an OpenTelemetry Collector as a buffer:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1000
exporters:
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [jaeger]
The collector batches, retries, and routes telemetry. If Jaeger is down for 30 seconds, your app does not notice — the collector buffers.
Sampling in production
Tracing every request in production generates too much data. Use tail-based sampling to keep only interesting traces:
- All error traces
- All traces slower than P95
- 10 percent of normal traces
This keeps your storage costs manageable while ensuring you never miss a problematic request.
What tracing is not
Tracing is not a replacement for logs or metrics. It answers "where did the time go?" not "what happened?" or "how much?" Use all three — and OpenTelemetry is the framework that unifies them.
