Essential 04

Distributed Tracing & Correlation IDs

A correlation ID lets you gather the log lines for one request. A trace lets you say which hop consumed 400 of the 500 milliseconds, and which call caused which. Those are different capabilities built on different data structures, and the second one cannot be recovered from the first, no matter how disciplined your logging is.

Level Intermediate Stack Java 17 · Spring · OpenTelemetry Verified against production code (partial)

The claim under test

"We have correlation IDs, so we have tracing." It is said in design reviews constantly, usually by someone who has done real work to make it true: the filter exists, the header is honoured, the log pattern includes the ID. The claim is worth taking seriously precisely because the thing behind it is not a fake. It is a real capability. It is simply a different capability from the one the word "tracing" names.

Here is what the real capability looks like. One identifier, eleven log files, one grep:

textcorrelated log output for a single request
$ grep -h "$CID" services/*.log | sort
api-gateway    12:04:31.108  INFO  [4f1c9a2e]  POST /orders
order-svc      12:04:31.121  INFO  [4f1c9a2e]  creating order
pricing-svc    12:04:31.147  INFO  [4f1c9a2e]  quote requested
pricing-svc    12:04:31.178  INFO  [4f1c9a2e]  quote returned
inventory-svc  12:04:31.195  INFO  [4f1c9a2e]  reserving stock
inventory-svc  12:04:31.601  INFO  [4f1c9a2e]  stock reserved
order-svc      12:04:31.607  INFO  [4f1c9a2e]  order accepted
api-gateway    12:04:31.608  INFO  [4f1c9a2e]  201 Created

Look at the two highlighted lines. They appear to show a 406 ms gap inside the inventory service, and an engineer will reach that conclusion in about four seconds. It happens to be right here, and the reasoning that produced it is not sound. Those two stamps share a host, so subtracting them is legitimate. The moment you subtract across services you are comparing two independent wall clocks, and NTP-disciplined clocks in a datacentre still routinely disagree by single-digit to low-double-digit milliseconds. On a request where the interesting difference is 20 ms rather than 400 ms, that skew is the whole answer.

There is a second, quieter problem. Nothing in that output says who called whom. Did the gateway call pricing, or did the order service? Was the inventory call issued inside the pricing call or after it? The lines are ordered by timestamp, which is a presentation choice, not a causal fact. Correlated logs are a flat set. Causality is a graph, and the graph is not in there to be recovered.

What a correlation ID genuinely gives you

It is worth being fair about this, because the pattern is undervalued by people who have just discovered tracing. A correlation ID solves a real and previously miserable problem: before it, a production incident meant opening eleven log files and eyeballing timestamps for lines that looked related. After it, one identifier collects them exactly.

What it answers
  • Which log lines belong to this request, across every service it touched.
  • Which services were involved at all.
  • What the error message was, with its full surrounding context.
  • Whether a customer-reported failure and a stack trace are the same event: the ID is echoed to the client, so a support ticket carries it.
What it cannot answer
  • How long each hop took: there is no duration, only stamps from disagreeing clocks.
  • Which call was nested inside which: no parent, no child, no tree.
  • Whether two calls ran in parallel or in sequence.
  • Which single operation is on the critical path of a slow request.

The right way to hold this: a correlation ID is a join key. It makes a previously unjoinable dataset joinable. What it does not do is add the columns that the interesting queries need: duration, parent and kind. Adding those columns is what tracing is.

This is not a new observation

The field has a founding document, and it is worth citing because it frames tracing as an infrastructure problem rather than a logging one. Google's Dapper paper (2010) named the three constraints every tracing system since has had to satisfy.

Here we introduce the design of Dapper, Google's production distributed systems tracing infrastructure, and describe how our design goals of low overhead, application-level transparency, and ubiquitous deployment on a very large scale system were met.

Sigelman et al., Google research.google: Dapper (2010)

Ubiquitous deployment is the one that matters for this page. Dapper's authors understood from the start that a tracing system with a gap in it is not a slightly worse tracing system. The trace simply stops at the gap. That is the same failure mode that makes context propagation, further down, the part that actually breaks.

What a trace adds

A trace is not a better log line. It is a different data structure: a directed acyclic graph of spans, each of which is a timed unit of work that knows its own parent. OpenTelemetry's definition is the one the whole ecosystem now shares.

A span represents a unit of work or operation. Spans are the building blocks of Traces.

The documentation lists what each span carries: Name, Parent span ID (empty for root spans), Start and End Timestamps, Span Context, Attributes, Span Events, Span Links, Span Status. Four of those entries are the whole difference from a log line. Parent span ID gives you causality. Start and end timestamps , measured locally on one clock by the process doing the work, give you a duration that is trustworthy without any cross-host clock agreement at all. Attributes give you the dimensions to slice by. Span context is the part that travels.

Span events deserve a note, because they are the field most often skipped and they are the one that replaces scattered log.debug calls. An event is a timestamped annotation attached inside a span, so "cache miss, 340 ms into this 400 ms call" has a position on the timeline rather than merely a wall-clock stamp in a file. That is the difference between knowing a cache miss happened during the request and being able to see it land in the gap that the waterfall is asking you about. Span links serve the related purpose of joining a span to causes in other traces: a batch job processing forty messages links to forty producers, which a strict parent-child tree cannot express.

Put those together across four services and you get a picture that correlated logs cannot produce at any level of logging discipline:

0ms 100 200 300 400 500 api-gateway SERVER · root span 500ms order-svc SERVER 486ms GET /pricing CLIENT 42ms pricing-svc SERVER 32ms GET /inventory CLIENT 416ms inventory-svc SERVER 406ms db.query CLIENT 382ms events.publish PRODUCER 8ms 382 ms, 76% of the request, inside one database call
One request, eight spans. Bar position is start time and bar width is duration, both drawn to the axis. The rails on the left are parent → child edges, so nesting is a fact in the data rather than an inference from timestamps. Red marks the critical path: the inventory hop costs 416 ms of 500, and 382 ms of that is a single query. The amber PRODUCER span at the far right is an event publish, and its CONSUMER counterpart runs later, in another process, and joins the same trace only if the context rides in the message.

Notice what the picture answers instantly and the log dump could not. The pricing call and the inventory call ran in sequence, not in parallel, visible from the bars, invisible in the log. The 382 ms is not "in the inventory service", it is in one query the inventory service issued; the service itself added 24 ms of its own. And the whole judgement rests on durations each process measured against its own monotonic clock, so no amount of NTP drift between hosts changes the conclusion.

Span kind

One span field deserves its own paragraph because it is the one most often left at its default. Span kind tells the backend what shape of relationship the span represents, which is how a UI knows to draw a queue hop differently from a function call. OpenTelemetry's definitions:

KindOpenTelemetry's definitionTypical source
SERVER "A server span represents a synchronous incoming remote call such as an incoming HTTP request or remote procedure call." Spring MVC / WebFlux entry point
CLIENT "A client span represents a synchronous outgoing remote call such as an outgoing HTTP request or database call." RestTemplate, Feign, JDBC
PRODUCER "Producer spans represent the creation of a job which may be asynchronously processed later." Publishing to a bus or queue
CONSUMER "Consumer spans represent the processing of a job created by a producer and may start long after the producer span has already ended." Message listener
INTERNAL "Internal spans represent operations which do not cross a process boundary." An @Observed method

The CONSUMER definition contains the sentence that breaks naive tooling: a consumer span "may start long after the producer span has already ended". A trace is therefore not guaranteed to be a contiguous interval on a timeline. Systems that assume it is will silently drop your async work, which is one more reason the kind field is worth setting explicitly rather than leaving to a default.

The traceparent header, dissected

Every one of those spans needs to know its parent, and the parent usually lives in another process. The wire format for that hand-off is standardised. W3C Trace Context is a Recommendation, which is why an OpenTelemetry-instrumented Java service, a Go sidecar and a third-party API you do not control can all participate in one trace without agreeing on anything else.

The traceparent HTTP header field identifies the incoming request in a tracing system. It has four fields: version, trace-id, parent-id, trace-flags.

W3C Trace Context w3.org/TR/trace-context/

Serialised, those four fields are hyphen-separated in the order version-trace-id-parent-id-trace-flags. This is the specification's own worked example, reproduced with its field decomposition:

textW3C Trace Context · worked example
Value = 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
base16(version)     = 00
base16(trace-id)    = 4bf92f3577b34da6a3ce929d0e0e4736
base16(parent-id)   = 00f067aa0ba902b7
base16(trace-flags) = 01  // sampled
FieldSizeExampleWhat it means
version 2HEXDIGLC
2 hex chars
00 Format version. Currently the only defined value; a receiver that does not recognise a higher version must still tolerate the header rather than reject the request.
trace-id 32HEXDIGLC
32 hex chars, 16 bytes
4bf92f35…0e4736 "the ID of the whole trace forest and is used to uniquely identify a distributed trace through a system". Constant for every span in the trace, and this is the field a correlation ID is the closest analogue of.
parent-id 16HEXDIGLC
16 hex chars, 8 bytes
00f067aa0ba902b7 "the ID of this request as known by the caller (in some tracing systems, this is known as the span-id…)". Changes on every hop. This is the field that has no correlation-ID equivalent, and it is the one that builds the tree.
trace-flags 2HEXDIGLC
2 hex chars, 8 bits
01 "an 8-bit field that controls tracing flags such as sampling, trace level, etc." The least significant bit is the sampled flag; propagating it is what stops a trace being recorded on some hops and dropped on others.

Two consequences follow directly from the table, and both are worth saying out loud. First, trace-id is genuinely the same idea as a correlation ID, so a service that already has a correlation filter has built one quarter of the header, in a private format nobody else parses. Second, parent-id is not a variation on the same idea; it is a structurally different piece of information that changes at every hop and that no flat identifier can encode. That is the whole thesis of this page compressed into one row of a table.

A custom header is a private protocol

X-Correlation-ID is a perfectly reasonable convention, and it is understood by exactly the services you wrote. A managed API gateway, a service mesh sidecar, a database proxy, a SaaS provider's ingress, none of them will see it, forward it, or add spans to it. Emitting traceparent costs the same engineering and buys interoperability with software you have not written yet.

Context propagation: where it actually breaks

This is the practical core of the page. Instrumenting a service to create spans is a dependency and a line of configuration. Getting the context to survive every hop is where real systems fail, and they fail silently: a broken hop does not error, it just produces two unrelated traces where there should have been one.

Propagation is the mechanism that moves context between services and processes. It serializes or deserializes the context object and provides the relevant information to be propagated from one service to another.

The same page notes that "The default propagator uses the headers specified by the W3C TraceContext specification", so on the easy path you configure nothing. The hard paths are the three below, in ascending order of how often they are missed.

1 · Outbound HTTP clients

An incoming request populates the context; an outgoing request has to carry it forward. With Spring Boot and Micrometer Tracing on the classpath, an auto-configured RestTemplate or WebClient gets this for free. The failure cases are specific and predictable: a client built with new RestTemplate() instead of the injected builder bypasses the instrumentation entirely; a declarative client such as Feign needs its own interceptor; and a raw HttpClient gets nothing at all. One un-instrumented client anywhere in the estate truncates every trace that passes through it.

2 · Thread pools and @Async

Both SLF4J's MDC and OpenTelemetry's Context are held in thread-locals. The instant work is handed to a pool, it runs on a thread that has neither. This is the failure that most often gets diagnosed as "logging is flaky", because the effect is that the ID is present at the start of a request and absent halfway through. Every scheduled task, every @Async method, every CompletableFuture.supplyAsync with an executor, every parallel stream is an instance of it.

The fix is to decorate the task rather than to remember at every call site: capture on the submitting thread, install on the worker, and clear afterwards so a pooled thread never leaks one request's identity into the next:

javaAsyncContextConfig.java
/**
 * @Async, and every other pooled hand-off, starts on a thread whose MDC
 * and ThreadLocal context are empty. Nothing warns you: the log lines
 * simply stop carrying the id halfway through the request.
 */
@Configuration
public class AsyncContextConfig {

    @Bean
    public ThreadPoolTaskExecutor applicationTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setTaskDecorator(runnable -> {
            // Captured on the SUBMITTING thread…
            Map<String, String> submitter = MDC.getCopyOfContextMap();
            return () -> {
                // …and installed on the worker for this task only.
                if (submitter != null) MDC.setContextMap(submitter);
                try {
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        });
        executor.initialize();
        return executor;
    }
}

The OpenTelemetry side has a direct equivalent: Context.taskWrapping(Executor) returns an executor that propagates the active context across the hand-off. Use both, because they carry different things, and fixing one does not fix the other.

3 · Message buses

This is the boundary where the problem stops being a missing interceptor and becomes a contract change. A Redis pub/sub channel, a plain queue, a websocket frame, none of them have a header section you can slip traceparent into. The context has to travel inside the payload, which means the message envelope must have a slot for it, which means both publisher and consumer have to agree on that slot. You cannot add this unilaterally on one side.

Envelope carries context

The message is { type, payload, context }. The producer injects the active context into context; the consumer extracts it and starts a CONSUMER span parented to it. The async hop joins the trace.

Payload only

The consumer starts a brand-new root span with a brand-new trace id. Nothing is broken, nothing logs a warning, and the two halves of the workflow become two unrelated traces that no query will ever join.

Propagation cannot be assumed: it has to be tested

A correlation ID is only as good as its weakest hop, and there is no compiler error, no failing test and no log warning when a hop drops it. The only reliable check is an assertion: an integration test that fires a request with a known ID and asserts the same ID appears in the downstream service's captured logs, one test per boundary type (HTTP client, async executor, bus consumer). Without those tests, "we propagate the correlation ID" is a belief, not a property of the system.

Sampling: head, tail, and why 100% is not the goal

A trace is one to two orders of magnitude more data than a log line, because it is emitted per operation rather than per interesting event. At any real volume, retaining everything is a storage and egress bill nobody signed up for. Sampling is how the field made tracing affordable, and it is one of the design choices Dapper singled out: "the use of sampling and restricting the instrumentation to a rather small number of common libraries".

[I]f the large majority of your requests are successful and finish with acceptable latency and no errors, you do not need 100% of your traces to meaningfully observe your applications and systems.

There are two families, and the difference between them is when the decision is taken relative to knowing how the request turned out.

Head-basedTail-based
Decision point At the root span, before anything has happened. "a sampling technique used to make a sampling decision as early as possible." After the trace is assembled. "considering all or most of the spans within the trace."
Where it runs In the application, one config property In a stateful collector that buffers spans
Cost profile Cheap: unsampled traces are never produced Every span is emitted and buffered, then most are discarded
Catches rare errors Only by luck. At 1% you keep 1 in 100 of your failures too By policy: "Always sampling traces that contain an error"
Main drawback "it is not possible to make a sampling decision based on data in the entire trace." Stateful, resource-hungry, and often vendor-specific

In general numeric terms: at a few hundred requests per second, head-based sampling in the 1–10% range keeps the bill sane and gives you a statistically fine picture of normal latency, and it is exactly the wrong tool for the incident you will actually be paged for, because a failure mode occurring in 1 in 10,000 requests is, at 1% sampling, retained 1 time in a million. Tail-based sampling inverts that: keep 100% of traces containing an error, 100% above a latency threshold, and a small percentage of everything else. You pay for it by emitting every span to a collector that must hold a whole trace in memory long enough to decide.

The sampled flag is why the decision must be propagated

Whichever family you choose, the decision is taken once, at the root, and travels in trace-flags. If each service decided independently you would get traces with holes in them, which are worse than no trace at all because they look complete. This is the practical reason the last two hex characters of traceparent exist.

What this looked like in production

Verified in one service of an eleven-service estate, with one question left open

One service in the estate contains a genuine, correctly written correlation filter. The following was read directly from source, not inferred:

  • A class extending Spring's OncePerRequestFilter, registered as a @Component at highest precedence so it runs before anything that logs.
  • It reads an X-Correlation-ID request header and, if the header is absent or blank, generates a UUID.
  • It places the value into the SLF4J MDC under a correlationId key, so every log line emitted during the request carries it without any call site having to remember.
  • It echoes the value back on the response header, which is what makes a support ticket usable as a search key.
  • It removes the key in a finally block, the detail most implementations miss, and without which a pooled servlet thread leaks one request's ID into the next.

That is textbook correlation-ID logging and competent work. The lesson is not that it is wrong. The lesson is precisely what it is not:

  • No span model. There is no span id and no parent span id, so you cannot reconstruct per-hop timing and causality from the output at all: only the join key exists.
  • A custom header, not traceparent. No standard tool, sidecar, gateway or third-party system understands X-Correlation-ID, so the value stops at the edge of code the team wrote.
  • No trace backend. Nothing collects, assembles or renders a request tree, so even if spans existed there would be nowhere to draw them.

The propagation question was not verified, and this page will not assert it either way. A correlation ID only works if every hop forwards it: outbound HTTP clients need an interceptor to copy the MDC value onto the next request, and asynchronous hops, whether thread pools, scheduled tasks or the estate's message bus, drop the MDC entirely unless the value is carried explicitly in the message envelope. Whether that forwarding existed across all eleven services was outside what was checked. It is stated here as an open question rather than a finding, because asserting either "it propagates" or "it does not" would be a claim the evidence does not support.

The honest summary: a correct first step that solves "find the logs for this request" and stops short of "where did the latency go". Those are different problems, and only the first one had been solved.

…/exception/CorrelationIdFilter.java …/resources/logback-spring.xml

Doing it properly

Four tabs, in the order you would actually implement them. The first is the correlation filter, worth keeping even after you have traces, because a human-readable ID that a support agent can quote is a different affordance from a 32-character trace id. The last is where real spans come from.

javaCorrelationIdFilter.java
/**
 * Step one, and a genuinely correct one: every log line emitted while this
 * request is on the stack carries the same identifier, with no call site
 * having to pass it around.
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {

    public static final String HEADER = "X-Correlation-ID";
    public static final String MDC_KEY = "correlationId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String correlationId = request.getHeader(HEADER);
        if (correlationId == null || correlationId.isBlank()) {
            correlationId = UUID.randomUUID().toString();
        }
        MDC.put(MDC_KEY, correlationId);
        response.setHeader(HEADER, correlationId);
        try {
            chain.doFilter(request, response);
        } finally {
            // Servlet threads are pooled and reused. Without this removal the
            // id leaks into the next, unrelated request on the same thread.
            MDC.remove(MDC_KEY);
        }
    }
}
Keep the correlation filter

Tabs 1 and 4 are not alternatives. Once traceId and spanId are in the log pattern, the flat identifier is largely redundant for engineers, but it is still the value you echo to clients and quote in support tickets, and it still works when the trace backend is the thing that is down. Keeping it costs one filter.

The adoption checklist

The ticked items are the properties a system needs before "we have tracing" is a true statement. The unticked ones are the states that get mistaken for it.

Canonical sources