Overview
No single log line tells the story of a request that crossed three services and a queue — correlation is what makes the pile of per-process logs navigable. The mechanic is small: at the edge of every service, one filter/middleware extracts or generates a request id, stores it in a request-scoped context, and from there it is stamped on every log line, echoed on the response, and forwarded on every outgoing call. The design questions worth getting right are the carrier (per platform), the header contract, and the boundary between hand-rolled business correlation ids and the W3C Trace Context / baggage standards that OpenTelemetry propagates automatically.
Key points
- The edge algorithm: accept an inbound
X-Correlation-Idheader if present (trust boundary permitting), otherwise generate one (UUIDv4 is fine); store it in the request context; stamp it into the logging context (MDC); set it on the response so clients can quote it in bug reports; inject it into headers of every outgoing HTTP call and message published. One middleware does all of this — the id logic is never scattered through handlers. - Echo on the response is the underrated half: a client-visible id turns "it failed around noon" into a single log query.
- Business correlation id ≠ trace id. W3C Trace Context defines
traceparent(version-traceid-parentspanid-flags: a 16-byte trace-id, the caller's 8-byte span-id, sampling flags) andtracestate(vendor key-values). OTel propagators inject/extract it automatically on instrumented clients/servers — you get cross-service causality without writing propagation code. A business correlation id is different: user-meaningful, quotable, survives systems that have no tracing, and stable across a whole business transaction even where the trace is sampled away or split. Mature setups carry both and log both. baggage(its own W3C header,key=valuepairs) is the standards-track home for arbitrary cross-service context — tenant-id, channel, experiment flags. OTel propagates it alongsidetraceparent; entries must be explicitly copied onto spans/logs if you want them recorded (and they travel to every downstream service — never put secrets or PII in baggage).- Design an extensible request-context type, not a bare string:
correlationIdat minimum, extended with e.g.tenantIdfor multi-tenant scenarios — each parsed from its custom header by the same single filter/middleware at the edge, never re-parsed downstream. The context object is application-layer vocabulary; handlers receive it, the domain at most sees values it needs as ordinary parameters. - Carriers per stack: JVM —
ThreadLocal+ MDC set by a filter (Jakarta/JAX-RSContainerRequestFilter, SpringOncePerRequestFilter, MicronautHttpServerFilter); Go —context.Contextvalues threaded explicitly; Rust — atower/axum middleware (Layer) inserting a request extension and opening atracingspan with the id as a field; Node —AsyncLocalStorageentered by the first middleware. - Propagation is edge-to-edge: extract at the server edge, inject at the client edge. Every outgoing channel counts — HTTP clients, gRPC metadata, message headers (Kafka headers, AMQP properties); a queue that drops the id breaks the chain silently.
Details
The header contract
| Header | Standard | Content | Who propagates |
|---|---|---|---|
traceparent |
W3C Trace Context | trace-id, parent-span-id, flags | OTel propagators, automatically |
tracestate |
W3C Trace Context | vendor-scoped key-values | OTel propagators |
baggage |
W3C Baggage | arbitrary key-values (tenant-id, …) | OTel propagators; opt-in to record |
X-Correlation-Id (or X-Request-Id) |
convention | one opaque business id | your middleware, explicitly |
Older conventions (X-B3-TraceId from Zipkin) still appear; OTel's composite propagator
can speak several at once during migrations
(OTEL_PROPAGATORS=tracecontext,baggage,b3multi).
Per-stack wiring
| Stack | Edge filter | Context carrier | Outgoing injection |
|---|---|---|---|
| Quarkus / Jakarta | @Provider ContainerRequestFilter + ContainerResponseFilter |
MDC / ThreadLocal (mind async hops) |
ClientRequestFilter on the REST client |
| Spring Boot | OncePerRequestFilter (register early, e.g. highest precedence) |
MDC | ClientHttpRequestInterceptor / WebClient filter |
| Micronaut | @Filter HttpServerFilter |
MDC (ServerRequestContext for reactive) |
HttpClientFilter |
| Go net/http | func(http.Handler) http.Handler wrapper |
context.WithValue with unexported key type |
http.RoundTripper decorator |
| Rust axum | tower::Layer middleware |
request extension + tracing span field |
reqwest middleware / manual header |
| Node http | first middleware, als.run(store, next) |
AsyncLocalStorage |
fetch/undici wrapper reading the store |
Two invariants across all six: the filter is registered at the application layer's driving adapter, and the wiring choice is made once at the assembly point — the hexagonal placement from the parent note.
Trust and validation
Accepting inbound ids is a trust decision: at a public edge, validate shape (length, charset) or replace outright — an attacker-controlled id lands verbatim in your logs (log-injection) and dashboards. Internal edges behind a gateway usually accept freely; the gateway generates.
Related
- Observability & SRE practice — the strategic map; "correlation IDs tie the signals together" is this note, expanded.
- Structured logging & MDC — where the id lands on every log line, and the per-platform context carriers in depth.
- OpenTelemetry — the propagators that move
traceparent/baggageautomatically; business ids ride your own middleware. - Health checks: liveness, readiness, startup — sibling concern in the same middleware stack (probes typically bypass correlation).
- Frameworks × the stdlib contracts —
context.Contextas Go's propagation contract; middleware interop is what makes the wrapper composable. - Frameworks × Jakarta EE — who implements what —
the filter contracts (
ContainerRequestFilteret al.) each JVM stack offers. - Microservice architecture — the setting that makes propagation mandatory rather than nice-to-have.
Citations
[1] W3C Trace Context [2] W3C Baggage [3] OpenTelemetry — context propagation