| name | distributed-tracing |
| description | Instrument microservices with distributed tracing using OpenTelemetry, Jaeger, and Tempo. Covers span creation, context propagation, sampling strategies, trace-to-log correlation, and building service dependency maps from trace data. |
| version | 1.0.0 |
| tags | ["distributed-tracing","opentelemetry","jaeger","tempo","spans","context-propagation","observability","python","nodejs"] |
Distributed Tracing
Overview
Distributed tracing tracks a request across multiple services by attaching a trace ID that propagates through HTTP headers, message queues, and async calls. Each service creates spans — timed segments with metadata — that are collected into a trace timeline showing exactly where latency comes from. OpenTelemetry (OTel) is the vendor-neutral standard for instrumentation; Jaeger and Grafana Tempo are common backends for storage and visualization.
When to Use
- Debugging high latency in microservices where logs alone don't reveal which service is slow
- Understanding service dependency graphs and call chains
- Finding N+1 query problems and slow database calls in production
- Correlating a specific user's failing request across 5+ services
- SLA violation investigation: which service broke the 200ms budget
- Capacity planning: identify the slowest percentile paths in your architecture
Step-by-Step Workflow
1. OpenTelemetry Setup (Python)
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
def setup_tracing(service_name: str, otlp_endpoint: str = "http://localhost:4317"):
"""Initialize OpenTelemetry tracing with OTLP export to Jaeger/Tempo."""
resource = Resource.create({SERVICE_NAME: service_name})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
return trace.get_tracer(service_name)
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
tracer = setup_tracing("order-service")
from fastapi import FastAPI
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument()
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer(__name__)
async def process_order(order_id: str, user_id: str):
with tracer.start_as_current_span("process_order") as span:
span.set_attribute("order.id", order_id)
span.set_attribute("user.id", user_id)
span.set_attribute("order.service", "order-processor")
try:
with tracer.start_as_current_span("validate_inventory") as inv_span:
result = await check_inventory(order_id)
inv_span.set_attribute("inventory.available", result["available"])
with tracer.start_as_current_span("charge_payment") as pay_span:
payment = await charge(order_id, result["total"])
pay_span.set_attribute("payment.id", payment["id"])
pay_span.set_attribute("payment.amount", result["total"])
span.set_status(Status(StatusCode.OK))
return payment
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
2. Context Propagation Across Services
from opentelemetry import trace, propagate
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
def inject_trace_to_headers(headers: dict) -> dict:
"""Add W3C trace context headers for propagation."""
propagator = TraceContextTextMapPropagator()
propagator.inject(headers)
return headers
def extract_trace_from_headers(headers: dict):
"""Resume the trace from an incoming message."""
propagator = TraceContextTextMapPropagator()
ctx = propagator.extract(headers)
return ctx
from confluent_kafka import Consumer
def process_kafka_message(msg):
headers = dict(msg.headers() or [])
ctx = extract_trace_from_headers(headers)
with tracer.start_as_current_span("process_order_event", context=ctx) as span:
span.set_attribute("kafka.topic", msg.topic())
span.set_attribute("kafka.partition", msg.partition())
payload = json.loads(msg.value())
span.set_attribute("order.id", payload[])
():
headers = {}
inject_trace_to_headers(headers)
producer.produce(topic, json.dumps(payload).encode(), headers=(headers.items()))
3. Node.js Auto-Instrumentation
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
import { Resource } from "@opentelemetry/resources";
import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
const exporter = new OTLPTraceExporter({
url: process.env.OTLP_ENDPOINT ?? "http://localhost:4317",
});
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME ?? "my-service",
}),
traceExporter: exporter,
instrumentations: [getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
})],
});
sdk.start();
process.(, sdk.());
{ trace, context, } ;
tracer = trace.();
() {
tracer.(, (span) => {
span.(, cartId);
span.(, userId);
{
order = (cartId, userId);
span.(, order.);
span.({ : . });
order;
} (err) {
span.(err );
span.({ : . });
err;
} {
span.();
}
});
}
4. Jaeger + Tempo Docker Setup
version: "3.8"
services:
jaeger:
image: jaegertracing/all-in-one:1.55
ports:
- "16686:16686"
- "4317:4317"
- "4318:4318"
environment:
COLLECTOR_OTLP_ENABLED: "true"
tempo:
image: grafana/tempo:latest
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./tempo.yaml:/etc/tempo.yaml
ports:
- "3200:3200"
- "4317:4317"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
volumes:
- ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 48h
storage:
trace:
backend: local
local:
path: /tmp/tempo/blocks
5. Sampling Strategies
from opentelemetry.sdk.trace.sampling import (
TraceIdRatioBased,
ParentBased,
ALWAYS_ON,
ALWAYS_OFF,
)
from opentelemetry.sdk.trace import TracerProvider
ratio_sampler = ParentBased(root=TraceIdRatioBased(0.1))
from opentelemetry.sdk.trace.sampling import Sampler, Decision, SamplingResult
from opentelemetry.trace import SpanKind
from opentelemetry.util.types import Attributes
class SmartSampler(Sampler):
"""Always sample: errors, slow endpoints, admin routes. Sample 1% of normal traffic."""
def should_sample(self, parent_context, trace_id, name, kind, attributes, links) -> SamplingResult:
attrs = attributes or {}
if (attrs.get("http.route", "").startswith("/admin") or
attrs.get("http.route", "") in ["/checkout", "/payment"]):
return SamplingResult(Decision.RECORD_AND_SAMPLE)
if (trace_id & 0xFFFF) < 0x028F:
return SamplingResult(Decision.RECORD_AND_SAMPLE)
SamplingResult(Decision.DROP)
() -> :
provider = TracerProvider(sampler=SmartSampler())
6. Trace-to-Log Correlation
import logging
import structlog
from opentelemetry import trace
class TraceContextProcessor:
"""Add OpenTelemetry trace/span IDs to every log record."""
def __call__(self, logger, method, event_dict):
span = trace.get_current_span()
if span.is_recording():
ctx = span.get_span_context()
event_dict["trace_id"] = format(ctx.trace_id, "032x")
event_dict["span_id"] = format(ctx.span_id, "016x")
event_dict["trace_flags"] = ctx.trace_flags
return event_dict
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
TraceContextProcessor(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
)
log = structlog.get_logger()
async def process_order(order_id: str):
with tracer.start_as_current_span("process_order") as span:
span.set_attribute("order.id", order_id)
log.info("processing_order", order_id=order_id)
Key Commands Reference
pip install opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi
pip install opentelemetry-instrumentation-httpx opentelemetry-instrumentation-sqlalchemy opentelemetry-instrumentation-redis
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-grpc
docker run -d --name jaeger -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:1.55
curl -X POST http://localhost:4318/v1/traces \
-H "Content-Type: application/json" \
-d @test-trace.json
docker run otel/opentelemetry-collector-contrib --config /etc/otel-config.yaml
curl "http://localhost:3200/api/traces/{traceId}"
Common Patterns
Pattern 1: Service Graph from Traces
from collections import defaultdict
import httpx
async def get_service_graph(tempo_url: str, time_range_hours: int = 1) -> dict:
"""Query Tempo for service-to-service call counts."""
response = await httpx.get(
f"{tempo_url}/api/services",
params={"start": int(__import__("time").time()) - time_range_hours * 3600}
)
return response.json()
Pattern 2: Span Events for State Transitions
with tracer.start_as_current_span("order_fulfillment") as span:
span.add_event("inventory_reserved", {"warehouse.id": "wh-1"})
await reserve_inventory(order_id)
span.add_event("payment_charged", {"payment.method": "card"})
await charge_payment(order_id)
span.add_event("shipping_scheduled", {"carrier": "fedex", "days": 3})
await schedule_shipping(order_id)
Pattern 3: Baggage for Cross-Service Metadata
from opentelemetry.baggage import set_baggage, get_baggage
from opentelemetry import context
ctx = set_baggage("user.tier", "gold")
ctx = set_baggage("experiment.id", "ab-test-checkout-v2", context=ctx)
token = context.attach(ctx)
tier = get_baggage("user.tier")
Pitfalls to Avoid
-
Head-based sampling loses error traces: If you sample 1% at the start of a request, you'll miss 99% of errors since you don't know if a request will fail until it's complete. Use tail-based sampling (OpenTelemetry Collector's tail_sampling processor) which buffers spans and decides after the fact — keeping all errors.
-
Propagating trace context through async queues manually: Forgetting to inject and extract W3C trace context headers in Kafka/SQS messages breaks trace continuity. Every span from the consumer will appear as a new root trace with no connection to the producer. Always use OTel's inject/extract APIs — never manually copy trace IDs.
-
High cardinality attributes causing storage explosion: Attributes like user.id (millions of unique values) or order.id cause exponential storage growth in metric backends like Prometheus when used as labels. These are fine as span attributes in Jaeger/Tempo (stored once per span), but never use high-cardinality values as Prometheus metric labels.
Related Skills
observability-engineer — Metrics (Prometheus), logs (Loki), and the full observability stack
opentelemetry-instrumentation — Deep OTel SDK configuration and custom exporters
sentry-and-otel-setup — Error tracking + distributed tracing in one setup
chaos-engineering — Using traces to verify resilience and measure degradation
GitNexus Index
{
"skill": "distributed-tracing",
"category": "devops",
"triggers": ["distributed tracing", "opentelemetry", "jaeger", "grafana tempo", "spans", "trace propagation", "otel instrumentation", "service mesh tracing"],
"outputs": ["TracerProvider setup", "tracer.start_as_current_span", "inject_trace_to_headers", "SmartSampler", "trace-log correlation"],
"complexity": "medium",
"tools": ["opentelemetry", "jaeger", "tempo",