Production Infrastructure

Request ID and Distributed Tracing: Correlating Logs Across Services

How to propagate request IDs and trace context across microservices — X-Request-ID header generation, W3C Trace Context (traceparent), OpenTelemetry integration, middleware implementations, and searching traces in Jaeger and Zipkin.

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 flags
  • tracestate — 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 traceparent from incoming requests
  • Creates a child span for each request
  • Injects traceparent into all outbound HTTP calls via requests
  • 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

ToolDeploymentQuery LanguageBest For
JaegerSelf-hostedJaeger UI + APILarge-scale microservices
ZipkinSelf-hostedZipkin UI + APISimpler setups
Grafana TempoCloud or self-hostedTraceQLGrafana ecosystem
AWS X-RayManagedX-Ray consoleAWS-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 the trace_id
  • Open the trace in Jaeger/Tempo → see full cross-service path
  • Identify exactly which service and code path returned the error

Related Protocols

Related Glossary Terms

More in Production Infrastructure