Trace requests across microservices to find performance bottlenecks and debug failures. Covers OpenTelemetry, trace propagation, span attributes, sampling strategies, trace analysis, and the patterns that make distributed systems debuggable.
Distributed tracing tracks requests as they flow through systems composed of multiple services, allowing engineers to understand request behavior, find bottlenecks, and debug performance issues across service boundaries. It matters when requests span more than one process, especially when latency is unexpected or when debugging failures that cross service boundaries.
Traces begin with a root span that carries a trace ID. When services receive a request, they must extract the trace context from incoming headers and continue the trace. The standard header format is:
traceparent: 00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01
tracestate: rojo=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01
The traceparent header contains version, trace ID, span ID, and trace flags. The tracestate header carries vendor-specific baggage. When propagating traces, services must preserve both headers exactly as received.
To create a new span in code:
// Example in Go using OpenTelemetry SDK
span := tracer.Start(ctx, "operation-name")
defer span.End()
// Add attributes to the span
span.SetAttributes(
attribute.String("http.method", "GET"),
attribute.String("http.url", req.URL.String()),
)
The span ID must be unique within a trace. When creating child spans, the new span's ID becomes the parent's span ID in the next span's context.
Traces are exported to backend systems using exporters. The most common exporter is the OTLP (OpenTelemetry Protocol) exporter, which sends data over gRPC or HTTP.
For HTTP-based export:
# Configuration for OpenTelemetry collector
exporters:
otlp:
endpoint: "http://otel-collector:4318"
tls:
insecure: true
For gRPC-based export:
exporters:
otlp:
endpoint: "otel-collector:4317"
tls:
insecure: false
The OTLP exporter sends spans as protobuf-encoded data. The protocol uses a fixed schema that must be maintained across versions.
When making outbound requests, services must inject the trace context into outgoing headers:
// Example in Go
func makeRequest(ctx context.Context, client *http.Client, url string) error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
// Inject trace context
carrier := propagation.HeaderCarrier(req.Header)
propagator := propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
propagator.Inject(ctx, carrier)
_, err := client.Do(req)
return err
}
When receiving requests, extract the trace context:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract trace context
propagator := propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
ctx = propagator.Extract(ctx, propagation.HeaderCarrier(r.Header))
// Start a new span with extracted context
span := tracer.Start(ctx, "handler")
defer span.End()
}
Trace sampling controls how many traces are recorded. The default is often 100% sampling, but this can be expensive.
Sample configuration:
# OpenTelemetry collector configuration
processors:
probabilistic:
sampling_percentage: 10
Sampling decisions happen at the root span level. If a trace is sampled, all spans in that trace are exported. If not sampled, no spans from that trace are exported.
Each span contains these key fields:
trace_id: 16-byte identifier for the entire tracespan_id: 8-byte identifier for this specific spanparent_span_id: 8-byte identifier of the parent span (0 for root spans)name: Human-readable operation namekind: Type of span (client, server, producer, consumer)start_time: When the span startedend_time: When the span endedattributes: Key-value pairs describing the spanevents: Timestamped events during the span's lifetimeTrace context can be lost when services don't properly propagate headers. This commonly happens when:
When trace context is missing, a new trace is started. The system should log these occurrences:
WARN: Trace context missing in request to /api/users
To correlate traces across services, ensure that trace IDs are preserved and propagated through all services. A trace ID must be 16 bytes (32 hex characters) for compatibility with W3C Trace Context.
When a service receives a request with an invalid trace ID, it should log the error and continue with a new trace:
ERROR: Invalid trace ID format in header "traceparent"
To debug tracing issues, check these common failure modes:
Use the following query in your tracing backend to find incomplete traces:
SELECT trace_id, count(*) as span_count
FROM spans
WHERE end_time IS NULL
GROUP BY trace_id
HAVING count(*) < 5
Traces are stored in backends with different retention policies. Common retention settings include:
Storage systems may have limits on trace duration, span count, or attribute size. Traces that exceed these limits are truncated.
When exporting traces fails, the system should log the error:
ERROR: Failed to export trace to collector: connection refused
The exporter should retry failed exports with exponential backoff. If retries fail after a configured number of attempts, the trace data should be dropped.
span.End() to ensure proper cleanupWhen using message queues, trace context must be preserved across the message lifecycle:
// Example: Sending a message with trace context
func sendMessage(ctx context.Context, queue string, message []byte) error {
carrier := propagation.HeaderCarrier{}
propagator := propagation.TraceContext{}
propagator.Inject(ctx, carrier)
// Add trace headers to message metadata
msg := &amqp.Publishing{
Body: message,
Headers: amqp.Table{},
}
for key, values := range carrier {
for _, value := range values {
msg.Headers[key] = value
}
}
return channel.Publish(queue, "", false, false, msg)
}
Asynchronous operations require careful handling of trace context:
// Example: Continuing trace in background goroutine
func processAsync(ctx context.Context) {
// Extract context from original request
propagator := propagation.TraceContext{}
ctx = propagator.Extract(ctx, propagation.HeaderCarrier(r.Header))
go func() {
// Create new span for async work
span := tracer.Start(ctx, "async-operation")
defer span.End()
// Do async work...
}()
}
Common attributes to add to spans include:
http.method: HTTP method (GET, POST, etc.)http.url: Full URL of the requesthttp.status_code: HTTP response codeerror.type: Exception type if error occurrederror.message: Error messagedb.statement: SQL statement executedservice.name: Name of the service processing the requestTrace systems often compress spans to reduce network overhead. The compression algorithm must be consistent across all services. Most tracing systems limit:
Some backends support filtering traces by attributes or sampling. The filter expression is typically a string that follows the backend's query language.
Example filter:
http.status_code = 500 AND service.name = "payment-service"
When spans from the same request show different trace IDs, check for:
If span durations seem wrong, verify:
When traces don't appear in the backend:
Valid trace data should follow these rules:
Monitor exporter health by:
Different systems may use different header names for trace context:
traceparent, tracestatex-cloud-trace-contextuber-trace-idEnsure consistent header handling across services to prevent trace fragmentation.
This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy