A customer reports that a payment failed at "around 3:40 pm." The request passed through an API gateway, three Spring Boot microservices and a Kafka topic. Each service logs hundreds of lines a second. Without a shared identifier, finding that one request means grepping by timestamp and hoping.

On the development side, I used to think of logging as something for me. On the support side, I've learned the truth: logs are written for a stranger at 2am — someone who has never seen your code and needs to trace one failed request in minutes. The single most useful thing you can give that stranger is a correlation ID.

Think of it like this
A courier parcel passes through a dozen warehouses and vans. Nobody tracks it by "the brown box that left Nagpur on Tuesday." It has a tracking number, printed at every step. A correlation ID is a tracking number for a request.

First: do you already have one?

If your services use Micrometer Tracing (with the OpenTelemetry or Brave bridge), Spring Boot already puts a traceId and spanId into the logging context and propagates them across HTTP calls. In recent Spring Boot 3 versions these IDs appear in log lines automatically. If that's you, use the trace ID as your correlation ID and skip to the section on async code.

If you don't run tracing — common in smaller teams and monoliths — a small filter gets you most of the value.

Step 1: tag every request with a filter

The filter reuses an incoming ID if the caller sent one (so the ID survives across services), generates one if not, stores it in SLF4J's MDC (Mapped Diagnostic Context), and echoes it back in the response header.

CorrelationIdFilter.java
@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 id = Optional.ofNullable(request.getHeader(HEADER)) .filter(h -> h.matches("[A-Za-z0-9-]{8,64}")) // never trust raw input in logs .orElseGet(() -> UUID.randomUUID().toString()); MDC.put(MDC_KEY, id); response.setHeader(HEADER, id); try { chain.doFilter(request, response); } finally { MDC.remove(MDC_KEY); // threads are pooled — always clean up } } }

Two details matter here. The regex check stops a caller from injecting newlines or junk into your logs through the header. And the finally block matters because servlet threads are reused: forget it, and the next request on that thread inherits a stale ID.

Step 2: print it on every log line

Add the MDC value to your log pattern. %X{correlationId:-} prints the ID, or nothing if there isn't one.

application.properties
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%X{correlationId:-}] %logger{36} - %msg%n

Better still, log in JSON. From Spring Boot 3.4, structured logging is built in — one property, and every log line becomes a JSON object that includes your MDC values as fields, ready for Elasticsearch, Loki, Splunk or CloudWatch to filter on.

application.properties
# Built-in structured logging (Spring Boot 3.4+). Other formats: logstash, gelf logging.structured.format.console=ecs

Step 3: pass it to the next service

A correlation ID that stops at the service boundary is only half useful. Add it to outgoing HTTP calls so downstream services pick it up in their own filter:

HttpClientConfig.java
@Bean RestClient restClient(RestClient.Builder builder) { return builder .requestInterceptor((request, body, execution) -> { String id = MDC.get(CorrelationIdFilter.MDC_KEY); if (id != null) { request.getHeaders().set(CorrelationIdFilter.HEADER, id); } return execution.execute(request, body); }) .build(); }

For Kafka, the same idea applies: put the ID in a record header when producing, and read it back into the MDC at the start of your listener.

Step 4: don't lose it in async code

MDC is backed by a ThreadLocal. The moment work moves to another thread — @Async, a CompletableFuture, a thread pool — the ID silently disappears, and those are often the exact log lines you need. A TaskDecorator copies the context across:

AsyncConfig.java
@Bean TaskDecorator mdcTaskDecorator() { return runnable -> { Map<String, String> context = MDC.getCopyOfContextMap(); return () -> { if (context != null) MDC.setContextMap(context); try { runnable.run(); } finally { MDC.clear(); } }; }; }

Spring Boot applies a TaskDecorator bean to its auto-configured task executor automatically. If you build your own executors, call setTaskDecorator(...) on them yourself.

Step 5: close the loop with the user

Return the ID in every error response and show it in the UI as a short reference. When a user says "it failed, reference 7f3c9a1e," support goes straight to the right log lines. I show how to include it in API errors in Spring Boot error handling done right.

What a good log line contains
The correlation ID. The business identifiers involved (loan ID, order ID, tenant) — never just "processing request." The decision the code made and why ("rejected: KYC status PENDING"). And nothing that shouldn't be there: no passwords, tokens, card numbers, PAN or Aadhaar numbers. In fintech especially, a log file is a data store, and it's covered by the same compliance rules as your database.
A correlation ID turns "search by timestamp and hope" into a single, exact filter.
Use Micrometer Tracing if you have it; otherwise a OncePerRequestFilter + MDC is enough.
Validate incoming IDs and always clear the MDC in a finally block.
Propagate the ID to downstream HTTP calls, Kafka messages and async threads.
Log in structured JSON and return the ID to users, so every support conversation starts with it.