Distributed Tracing #
In traditional monolithic architecture, tracking request latency bottlenecks is relatively easy because the entire code execution runs inside one memory process on a single host machine. We just attach a code profiler or read chronological logs to find which function is running slowly. However, in microservices architectures running on Kubernetes, one user request can cross a dozen different services, moving from the API Gateway to gRPC authentication, triggering asynchronous processing in message brokers (Kafka/RabbitMQ), and finally touching various distributed databases. Without a Distributed Tracing system, diagnosing why a request runs slowly becomes nearly impossible. We need a unified tracking mechanism mapping the request’s entire journey end-to-end across container network process boundaries.
Basic Concepts: Trace and Span Anatomy #
Distributed tracing works by inserting unique identifiers into every request flow. There are two fundamental terms we must understand:
- Trace: The end-to-end representation of one complete request’s journey from start to finish. One trace is identified by one unique
Trace IDshared across all services. - Span: The smallest work unit representing one specific step or operation inside a trace (e.g. a SQL query, an HTTP client call, or token validation). Every span has a name, start/end timestamps, attributes (metadata), and a unique
Span ID.
Sequence Diagram and Context Propagation #
To unify spans from various application containers into one complete trace timeline, we must forward trace metadata across network boundaries. This metadata forwarding process is called Context Propagation.
sequenceDiagram
autonumber
actor User as Client (Web Browser)
participant Gateway as API Gateway (Ingress)
participant Auth as Auth-Service (gRPC)
participant Order as Order-Service (HTTP)
participant DB as Postgres Database
User->>Gateway: HTTP GET /api/orders (No Trace ID)
Note over Gateway: "Initialize Trace:<br/>Trace ID: 4bf92f3577b34da6<br/>Parent Span ID: 00f067aa0ba902b7"
Gateway->>Auth: gRPC validateToken() + W3C Metadata
Note over Auth: "Extract gRPC Context<br/>Child Span 1.1"
Auth-->>Gateway: HTTP 200 (Token Valid)
Gateway->>Order: HTTP GET /order/details + traceparent Header
Note over Order: "Extract HTTP Header<br/>Child Span 1.2"
Order->>DB: SQL SELECT * FROM orders
Note over DB: "Database Span (Child Span 1.2.1)"
DB-->>Order: Return the Query Data
Order-->>Gateway: Return the Order Detail Payload
Gateway-->>User: HTTP 200 (Success)The most commonly used contextual propagation standard today is W3C Trace Context. This standard defines the traceparent HTTP header with the following format:
W3C traceparent Header Format:
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ └─ Span ID (8-byte hex)
│ └─ Trace ID (16-byte hex)
└─ Specification version (00)
Application Instrumentation Using OpenTelemetry #
OpenTelemetry (OTel) is an open standard under the Cloud Native Computing Foundation (CNCF) providing uniform SDKs and APIs for collecting telemetry data (traces, metrics, logs). With OTel, we instrument the code once, and can send that data to any visualization backend (like Jaeger, Grafana Tempo, or Datadog) without modifying application code.
Here’s a comparison between asynchronous handling that breaks the trace context and safe context propagation implementation in Go:
// ANTI-PATTERN: Running an async goroutine without forwarding the context.
// This breaks the Trace ID, so the goroutine's internal operations aren't recorded in the parent trace.
func HandleOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
go func() {
// DON'T: The goroutine loses the parent context
db.Query("SELECT * FROM inventory") // Exec query runs without a Trace ID
}()
}
// ==============================================================================
// CORRECT: Forwarding the Context to Async Goroutines to Keep the Trace Flow.
func HandleOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Pull the current active span from the request context
parentSpan := trace.SpanFromContext(ctx)
// Safely copy the trace context to the async goroutine
go func(asyncCtx context.Context) {
// Create a new span inside the async goroutine with the correct parent span
tracer := otel.Tracer("order-service")
childCtx, span := tracer.Start(asyncCtx, "async_inventory_check")
defer span.End()
// The database operation is now bound to the same Trace ID
db.QueryContext(childCtx, "SELECT * FROM inventory")
}(trace.ContextWithSpan(context.Background(), parentSpan))
}
Python Instrumentation Code Example (FastAPI & OTel SDK) #
Here’s a complete example of initializing the OTel Tracer Provider and automatically exporting spans via the OTLP/gRPC protocol in Python:
import time
from fastapi import FastAPI
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.instrumentation.fastapi import FastAPIInstrumentor
# 1. Initialize the Tracing SDK Provider
provider = TracerProvider()
# 2. Send trace data to the local OTel Collector via gRPC port 4317
otlp_exporter = OTLPSpanExporter(endpoint="http://otel-collector.monitoring.svc:4317", insecure=True)
# 3. Use BatchSpanProcessor for performance efficiency (don't send spans one by one synchronously)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
trace.set_tracer_provider(provider)
# 4. Initialize the Tracer for manual instrumentation
tracer = trace.get_tracer("order-service")
app = FastAPI()
@app.get("/process-order/{order_id}")
def process_order(order_id: str):
# Create a manual span for critical business logic
with tracer.start_as_current_span("calculate_payment") as span:
span.set_attribute("order.id", order_id)
time.sleep(0.5) # Simulate delay
span.set_attribute("payment.status", "approved")
return {"status": "success"}
# 5. Install FastAPI auto-instrumentation
FastAPIInstrumentor.instrument_app(app)
OpenTelemetry Collector Architecture and Deployment #
The OpenTelemetry Collector is an intermediary component (proxy agent) tasked with receiving, processing, filtering, and exporting telemetry data. We must not send trace data directly from applications to storage backends (like Jaeger/Tempo) because:
- Applications get burdened with log sending queue and retry logic.
- It’s hard to do centralized filtering or metadata enrichment.
In Kubernetes, the OTel Collector is deployed as a DaemonSet on every worker node (for low-latency local processing) or as a centralized Deployment (for mass aggregation).
OTel Collector Deployment Manifest #
apiVersion: apps/v1
kind: Deployment
metadata:
name: otel-collector
namespace: monitoring
spec:
replicas: 2
selector:
matchLabels:
app: otel-collector
template:
metadata:
labels:
app: otel-collector
spec:
containers:
- name: otel-collector
image: otel/opentelemetry-collector-contrib:0.95.0
ports:
- containerPort: 4317 # OTLP/gRPC receiver port
- containerPort: 4318 # OTLP/HTTP receiver port
volumeMounts:
- name: config-volume
mountPath: /etc/otelcol
resources:
limits:
cpu: "500m"
memory: "1Gi"
requests:
cpu: "200m"
memory: "512Mi"
volumes:
- name: config-volume
configMap:
name: otel-collector-config
OTel Collector Pipeline Configuration (ConfigMap)
#
The OTel Collector configuration uses a pipeline structure defined in three main parts: receivers (receivers), processors (processors/filters), and exporters (senders).
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-collector-config
namespace: monitoring
data:
otel-collector-config.yaml: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# 1. Limit the collector memory so it doesn't trigger OOMKilled in Kubernetes
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
# 2. Combine spans into batches for network efficiency
batch:
send_batch_size: 1024
timeout: 5s
# 3. Insert cluster metadata into every span
resourcedetection:
detectors: [env, system]
exporters:
# Send traces to the Grafana Tempo backend
otlp:
endpoint: "tempo-distributor.monitoring.svc.cluster.local:4317"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, resourcedetection]
exporters: [otlp]
Sampling Strategies #
In high-traffic systems (e.g. processing 20,000 requests per second), recording 100% of trace data burdens the cluster’s internal network bandwidth and requires very expensive storage capacity. Therefore, we must apply Sampling techniques.
1. Head-Based Sampling #
The decision to record or ignore a trace is taken at the start of the request cycle (usually at the API Gateway or client SDK).
- Advantages: Very CPU and bandwidth efficient because unselected traces are immediately ignored upstream.
- Disadvantages: We can randomly lose valuable data. If an error occurs mid-request, that request might not be recorded because it didn’t pass sampling at the start.
# Head-Based Sampler Configuration in the SDK: Save only 10% of requests randomly
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
sampler = ParentBased(root=TraceIdRatioBased(0.10))
2. Tail-Based Sampling #
The decision is taken after the request is fully processed. The OTel Collector first gathers all spans of one trace in a memory buffer, analyzes the results, then makes the decision.
- Advantages: Very precise. We can create policies like: “Always save 100% of traces producing 5xx error statuses or taking > 2 seconds of latency, and only save 1% of successful requests.”
- Disadvantages: Requires fairly large RAM memory allocation on the OTel Collector because it must hold temporary trace data before deciding.
# Add the tail_sampling configuration to the processors in the OTel Collector
processors:
tail_sampling:
decision_wait: 10s # Wait 10 seconds for all spans to collect
num_traces: 10000
expected_new_traces_per_sec: 2000
policies:
# Rule A: Always save if the span status is ERROR
- name: error-policy
type: status_code
status_code: {status_codes: [ERROR]}
# Rule B: Always save if the request duration > 1.5 seconds
- name: latency-policy
type: latency
latency: {threshold_ms: 1500}
# Rule C: Save 5% of the remaining normal requests randomly
- name: probabilistic-policy
type: probabilistic
probabilistic: {sampling_percentage: 5.0}
Tracing Integration with Message Brokers (Kafka/RabbitMQ) #
Another common mistake is the Trace ID breaking when requests enter message broker queue systems (message queues). Distributed tracing across message brokers requires injecting context into the message header metadata.
Here’s an example of injecting trace context into RabbitMQ (AMQP) messages using Go:
// Sending RabbitMQ messages with Trace Context
func PublishOrderEvent(ctx context.Context, ch *amqp.Channel, event OrderEvent) {
// Create a special span for the event publishing activity
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(ctx, "publish_order_event")
defer span.End()
// The RabbitMQ header container map
headers := amqp.Table{}
// Inject the traceparent context from context.Context into the headers map
otel.GetTextMapPropagator().Inject(ctx, propagation.MapCarrier(headers))
body, _ := json.Marshal(event)
// Send the message with headers enriched with the Trace ID
ch.Publish(
"",
"order-queue",
false,
false,
amqp.Publishing{
Headers: headers, // Must include the propagation header
ContentType: "application/json",
Body: body,
},
)
}
On the consumer side, the receiving application must read those headers and extract them using otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(headers)) before starting new processing so the Trace ID stays linearly connected.
Distributed Tracing Practice Anti-Patterns #
Avoid the following bad habits when designing cluster tracing:
1. Manual Span Pollution #
Creating manual spans for every small internal function (like string formatting functions or mathematical multiplications). This floods application memory, produces very dense unreadable trace graphs in Jaeger, and needlessly burdens CPU. Limit manual spans to only network operations, database queries, or critical business logic flows.
2. Letting Silent Exceptions Go Unmarked #
Catching errors in business code using try-catch blocks but not registering that error status into the active span. As a result, the trace visualization on dashboards still looks green (success), making it hard for triage teams to detect code failure locations.
# ANTI-PATTERN: Catching errors without marking the span status.
with tracer.start_as_current_span("process_payment") as span:
try:
charge_credit_card()
except Exception as e:
# DON'T: Tracing will consider this transaction successful (green)
handle_error_gracefully(e)
# ==============================================================================
# CORRECT: Register the exception and set the span status to ERROR.
with tracer.start_as_current_span("process_payment") as span:
try:
charge_credit_card()
except Exception as e:
span.set_status(trace.StatusCode.ERROR, str(e))
span.record_exception(e) # Record the error stacktrace in the span metadata
raise e
Distributed Tracing Implementation Checklist #
Make sure all the following check items are fulfilled before launching the distributed tracing system to production:
CODE INSTRUMENTATION & PROPAGATION:
□ Applications are instrumented using the OpenTelemetry API & SDK standard.
□ Autoinstrumentation is applied for HTTP frameworks (FastAPI, Gin, Express) and DB drivers (SQLAlchemy, Gorm).
□ All goroutines/async threads safely forward the parent context (no lost context).
□ The W3C 'traceparent' header is inserted on every outgoing HTTP client call.
□ Context credentials are inserted into message broker record headers (Kafka/RabbitMQ).
□ All error handling blocks register exceptions and mark the span status as ERROR.
OTEL COLLECTOR & BACKEND CONFIGURATION:
□ The OTel Collector is deployed using the proper memory_limiter resource limits.
□ The OTel Collector uses a Batch Processor before sending spans to the database.
□ A tail-based sampling strategy is applied to save 100% of errors and 5% of normal requests.
□ Logs-to-traces and traces-to-metrics jump exploration is configured at the Grafana dashboard level.
Summary #
- OpenTelemetry Is the Industry Standard — OTel separates instrumentation code from backend vendors; we can migrate visualization backends without touching application code.
- Context Propagation Connects Spans — Make sure the
traceparentheader is sent across HTTP, gRPC, and message queue network boundaries to keep the Trace ID connected.- Limit Manual Span Creation — Focus span tracking on network I/O operations, database queries, and message queues; manual span pollution makes visualizations unreadable.
- Use Tail-Based Sampling in the Collector — Save only important data (errors and slow latency) in full to save storage capacity without losing failure visibility.
- Register Exceptions to Spans — Always call the
record_exceptionfunction and set the span status toERRORwhen catching code failures so trace visualizations turn red on dashboards.- Use the OTel Collector as a Buffer — Deploy the OTel Collector inside the cluster to do batching and data cleansing before traces are stored in permanent databases.