Why Request Correlation Matters
In a monolith, a single request produces a single log line. In a microservices architecture, a single user action can fan out across five services, each logging independently. When something goes wrong, you need to reconstruct the full request path from scattered log fragments.
Request correlation solves this by attaching a unique identifier to every request at entry and propagating it through every downstream call. Every log line, every metric, and every span shares this identifier — giving you a single key to search with.
Two standards cover this space:
- X-Request-ID — a simple, widely-supported header for basic correlation
- W3C Trace Context — a formal standard for distributed tracing with full causal relationships
They complement each other. X-Request-ID is cheap and works everywhere. W3C Trace Context enables full distributed tracing with Jaeger, Zipkin, and AWS X-Ray.
X-Request-ID Pattern
Header Generation
The entry point (load balancer, API gateway, or edge proxy) generates a UUID for each request. If the client already sent an X-Request-ID, validate and reuse it; if not, generate one.
# Nginx: generate or pass through X-Request-ID
map $http_x_request_id $request_id_value {
default $http_x_request_id;
'' $request_id; # $request_id is Nginx's built-in unique ID
}
server {
location / {
proxy_set_header X-Request-ID $request_id_value;
add_header X-Request-ID $request_id_value always;
proxy_pass http://app_servers;
}
}
Application Middleware
Each service reads the incoming X-Request-ID, stores it in thread-local or context-var storage, and includes it in every outbound call and log line:
# Django middleware
import uuid
from contextvars import ContextVar
request_id_var: ContextVar[str] = ContextVar('request_id', default='')
class RequestIdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request_id = (
request.META.get('HTTP_X_REQUEST_ID')
or str(uuid.uuid4())
)
request_id_var.set(request_id)
request.request_id = request_id
response = self.get_response(request)
response['X-Request-ID'] = request_id
return response
# Express (Node.js) middleware
const { v4: uuidv4 } = require('uuid');
app.use((req, res, next) => {
req.requestId = req.headers['x-request-id'] || uuidv4();
res.setHeader('X-Request-ID', req.requestId);
next();
});
Logging Integration
Bind the request ID to the logger so every log line in the request lifecycle carries it automatically:
# structlog binding (Python)
import structlog
logger = structlog.get_logger()
class RequestIdMiddleware:
def __call__(self, request):
request_id = request.META.get('HTTP_X_REQUEST_ID') or str(uuid.uuid4())
# Bind to structlog context for this request
structlog.contextvars.bind_contextvars(request_id=request_id)
response = self.get_response(request)
structlog.contextvars.clear_contextvars()
return response
Now every logger.info(...) call in any code path triggered by this request will automatically include request_id in the JSON output.
W3C Trace Context
W3C Trace Context (defined in the W3C Recommendation) uses two headers:
traceparent— carries the trace ID, span ID, and trace flagstracestate— vendor-specific trace metadata
traceparent Format
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^^ version
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ trace-id (128-bit hex)
^^^^^^^^^^^^^^^^ parent-span-id (64-bit hex)
^^ flags (01 = sampled)
The trace ID is globally unique and stable across the entire request tree. The parent span ID identifies the specific span making the downstream call.
OpenTelemetry Integration
OpenTelemetry (OTel) is the CNCF-standard implementation of W3C Trace Context:
# pip install opentelemetry-sdk opentelemetry-instrumentation-django
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.django import DjangoInstrumentor
# Configure exporter (sends to Jaeger/Tempo/etc.)
provider = TracerProvider()
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint='http://jaeger:4317'))
)
trace.set_tracer_provider(provider)
# Auto-instrument Django (injects traceparent headers automatically)
DjangoInstrumentor().instrument()
With auto-instrumentation, OTel automatically:
- Reads
traceparentfrom incoming requests - Creates a child span for each request
- Injects
traceparentinto all outbound HTTP calls viarequests - Exports spans to your configured backend
Proxy Forwarding
Nginx must forward both headers to preserve trace context:
location / {
proxy_pass http://app_servers;
proxy_set_header traceparent $http_traceparent;
proxy_set_header tracestate $http_tracestate;
proxy_set_header X-Request-ID $http_x_request_id;
}
Tools: Jaeger, Zipkin, Grafana Tempo
| Tool | Deployment | Query Language | Best For |
|---|---|---|---|
| Jaeger | Self-hosted | Jaeger UI + API | Large-scale microservices |
| Zipkin | Self-hosted | Zipkin UI + API | Simpler setups |
| Grafana Tempo | Cloud or self-hosted | TraceQL | Grafana ecosystem |
| AWS X-Ray | Managed | X-Ray console | AWS-native apps |
Searching Traces by Request ID
In Jaeger UI, use the search panel:
Service: api-gateway
Tags: request_id=<uuid>
With Grafana Tempo's TraceQL:
{ span.request_id = "4bf92f35-77b3-4da6-a3ce-929d0e0e4736" }
Correlating Traces with Logs
The most powerful pattern is linking logs to traces. Add the trace ID to every log line, then create Grafana data source links between Loki and Tempo:
# Extract OTel trace ID for log binding
from opentelemetry import trace as otel_trace
def get_trace_id() -> str:
span = otel_trace.get_current_span()
if span and span.get_span_context().is_valid:
return format(span.get_span_context().trace_id, '032x')
return ''
# In your middleware, bind to structlog:
structlog.contextvars.bind_contextvars(
request_id=request_id,
trace_id=get_trace_id(),
)
Now a support ticket with a request_id lets you:
- Search logs by
request_id→ find thetrace_id - Open the trace in Jaeger/Tempo → see full cross-service path
- Identify exactly which service and code path returned the error