Back to Articles

Observability · Tracing

Why Every Microservice Request Needs a Correlation ID

2026-09-176 min read
Spring BootLoggingMDC

A request comes into an API gateway, hits an order service, calls a payment service, and triggers a notification service. Something fails. You open the logs and find fifty thousand interleaved lines from a dozen threads across three services, none of them telling you which lines belong to the request that actually failed.

The problem

Under load, a single Spring Boot instance interleaves log lines from many concurrent requests on many threads:

app.log (no correlation id)
110:42:01 [http-nio-8080-exec-3] Order created
210:42:01 [http-nio-8080-exec-7] Order created
310:42:01 [http-nio-8080-exec-3] Calling payment service
410:42:02 [http-nio-8080-exec-9] Order created
510:42:02 [http-nio-8080-exec-7] Payment authorized
610:42:02 [http-nio-8080-exec-3] Payment declined

Three "Order created" lines, no way to tell which order's payment got declined. Multiply this across services and the problem stops being "hard to read" and becomes "impossible to debug without guessing."

Why it breaks

Threads are reused across requests, timestamps collide under load, and nothing in a bare log line says which request it belongs to. The fix people reach for first, adding the order ID to every log statement by hand, only works for logs inside the code path that already has the order ID in scope, and does nothing for infrastructure-level logs (the filter, the client, the exception handler) that run before or after the business logic.

The production solution

A correlation ID is generated once per request, at the edge, and attached to every log line for that request automatically via SLF4J's MDC (Mapped Diagnostic Context), a thread-local map the logging framework reads on every log call:

CorrelationIdFilter.java
1@Component
2public class CorrelationIdFilter extends OncePerRequestFilter {
3
4 private static final String HEADER = "X-Request-ID";
5
6 @Override
7 protected void doFilterInternal(HttpServletRequest request,
8 HttpServletResponse response,
9 FilterChain chain) throws ServletException, IOException {
10 String traceId = Optional.ofNullable(request.getHeader(HEADER))
11 .filter(id -> !id.isBlank())
12 .orElse(UUID.randomUUID().toString());
13
14 MDC.put("traceId", traceId);
15 response.setHeader(HEADER, traceId);
16 try {
17 chain.doFilter(request, response);
18 } finally {
19 MDC.remove("traceId");
20 }
21 }
22}

The filter reads an incoming X-Request-ID header if the caller already set one (useful when the caller is another one of your services, or an API gateway that generates it first), otherwise generates a new one. It writes the same ID back on the response header, so a client that hits an error can hand you the ID directly.

Logback's pattern picks it up with no code changes at the call site:

logback-spring.xml
1<pattern>%d{HH:mm:ss} [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>

For it to survive a downstream call, the outgoing HTTP client needs to forward it, the same interceptor pattern this series uses for the HTTP client setup:

PaymentClientConfig.java
1RestClient paymentClient = RestClient.builder()
2 .baseUrl("https://payments.internal")
3 .requestInterceptor((request, body, execution) -> {
4 String traceId = MDC.get("traceId");
5 if (traceId != null) {
6 request.getHeaders().add("X-Request-ID", traceId);
7 }
8 return execution.execute(request, body);
9 })
10 .build();

The same request across three services now reads as one story:

app.log (with correlation id)
110:42:01 [7f2c1a9e] Order created
210:42:01 [7f2c1a9e] Calling payment service
310:42:02 [7f2c1a9e] Payment declined: insufficient funds

Making it reusable

CorrelationIdFilter has no dependency on any domain object, it drops into any Spring Boot service unchanged. The one thing worth standardizing across services is the header name itself; pick X-Request-ID or X-Correlation-ID once and use it everywhere, otherwise the propagation breaks silently the first time two teams pick different names.

Testing it

A single @WebMvcTest asserting that a request without the header gets one generated and echoed back, and a request with the header gets the same value echoed back unchanged, covers the entire filter. The MDC cleanup in the finally block is worth its own test too: leaking a trace ID into the next request processed by the same thread is a subtle, hard-to-reproduce bug.

What I learned

This is one of the highest-leverage, lowest-effort changes available in a Spring Boot service. It costs one filter and one logback pattern change, and it turns "search fifty thousand log lines" into "search one trace ID" for every incident afterward.