Observability · Tracing
Why Every Microservice Request Needs a Correlation ID
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:
110:42:01 [http-nio-8080-exec-3] Order created210:42:01 [http-nio-8080-exec-7] Order created310:42:01 [http-nio-8080-exec-3] Calling payment service410:42:02 [http-nio-8080-exec-9] Order created510:42:02 [http-nio-8080-exec-7] Payment authorized610:42:02 [http-nio-8080-exec-3] Payment declinedThree "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:
1@Component2public class CorrelationIdFilter extends OncePerRequestFilter {34 private static final String HEADER = "X-Request-ID";56 @Override7 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());1314 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:
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:
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:
110:42:01 [7f2c1a9e] Order created210:42:01 [7f2c1a9e] Calling payment service310:42:02 [7f2c1a9e] Payment declined: insufficient fundsMaking 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.