Correlation IDs and MDC
What Is a Correlation ID?
A correlation ID is one ID attached to a request at the edge and passed to every service it touches, so all logs for that request can be found with one search. With Micrometer Tracing, the trace ID already does this. Many teams still add a business-friendly X-Correlation-Id (or X-Request-Id) that the mobile app sends and that support staff can read from an error screen.
Logback stores per-request values in the MDC (Mapped Diagnostic Context), a ThreadLocal map that the log pattern can print with %X{correlationId}.
Example: Propagating a Correlation ID
Example
Each "service" copies the header into its MDC when a request arrives, prints it in every log line, and copies it into outgoing calls. The last part shows the classic bug: work handed to another thread loses the ThreadLocal unless you wrap the task (what Spring's TaskDecorator and context propagation do).
import java.util.*;
import java.util.concurrent.*;
public class Main {
static final ThreadLocal<String> MDC = new ThreadLocal<>();
static int counter = 0;
public static void main(String[] args) throws Exception {
gateway(new HashMap<>()); // app sent no id
gateway(new HashMap<>(Map.of("X-Correlation-Id", "android-7731"))); // app sent one
ExecutorService pool = Executors.newSingleThreadExecutor();
MDC.set("req-0042");
log("order-service", "queueing confirmation email");
pool.submit(() -> log("email-worker", "sending email (plain task)")).get();
pool.submit(wrap(() -> log("email-worker", "sending email (wrapped task)"))).get();
MDC.remove();
pool.shutdown();
}
static void gateway(Map<String, String> headers) {
headers.putIfAbsent("X-Correlation-Id", String.format("req-%04d", ++counter));
withMdc(headers, () -> {
log("api-gateway", "route POST /api/orders -> order-service");
orderService(Map.of("X-Correlation-Id", MDC.get())); // copy into the outgoing call
});
}
static void orderService(Map<String, String> headers) {
withMdc(headers, () -> {
log("order-service", "order 1001 created");
paymentService(Map.of("X-Correlation-Id", MDC.get()));
});
}
static void paymentService(Map<String, String> headers) {
withMdc(headers, () -> log("payment-service", "charged Rs.2998"));
}
// what a servlet filter does: read header -> MDC, always clean up afterwards
static void withMdc(Map<String, String> headers, Runnable work) {
String previous = MDC.get();
MDC.set(headers.get("X-Correlation-Id"));
try { work.run(); } finally { if (previous == null) MDC.remove(); else MDC.set(previous); }
}
// capture the caller's MDC, restore it on the worker thread
static Runnable wrap(Runnable task) {
String captured = MDC.get();
return () -> { MDC.set(captured); try { task.run(); } finally { MDC.remove(); } };
}
static void log(String service, String msg) {
System.out.printf("INFO [%-15s] [%-12s] %s%n", service, MDC.get() == null ? "-" : MDC.get(), msg);
}
}Output
INFO [api-gateway ] [req-0001 ] route POST /api/orders -> order-service INFO [order-service ] [req-0001 ] order 1001 created INFO [payment-service] [req-0001 ] charged Rs.2998 INFO [api-gateway ] [android-7731] route POST /api/orders -> order-service INFO [order-service ] [android-7731] order 1001 created INFO [payment-service] [android-7731] charged Rs.2998 INFO [order-service ] [req-0042 ] queueing confirmation email INFO [email-worker ] [- ] sending email (plain task) INFO [email-worker ] [req-0042 ] sending email (wrapped task)
A Correlation-ID Filter in Spring Boot
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Correlation-Id";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String id = Optional.ofNullable(request.getHeader(HEADER))
.filter(v -> v.matches("[A-Za-z0-9-]{1,64}")) // never trust raw header values
.orElse(UUID.randomUUID().toString());
MDC.put("correlationId", id);
response.setHeader(HEADER, id); // return it so the app can show it
try {
chain.doFilter(request, response);
} finally {
MDC.remove("correlationId"); // threads are reused by Tomcat!
}
}
}
@Configuration
public class ClientConfig {
@Bean
RestClient inventoryClient(RestClient.Builder builder) {
return builder.baseUrl("http://inventory-service")
.requestInterceptor((req, body, exec) -> {
String id = MDC.get("correlationId");
if (id != null) req.getHeaders().add(CorrelationIdFilter.HEADER, id);
return exec.execute(req, body);
})
.build();
}
}Log pattern
Print it in every log line:
application.yml
logging:
pattern:
correlation: "[%X{traceId:-}-%X{spanId:-}] [cid=%X{correlationId:-}] "Example output
2026-10-14T21:42:10.118+05:30 INFO 1 --- [order-service] [nio-8082-exec-3] [6650f1e2a1b3c4d5e6f708192a3b4c5d-e6f708192a3b4c5d] [cid=android-7731] c.s.order.web.OrderController : order 1001 created
Tip: Alternatively, Micrometer Tracing baggage can carry the value for you: set management.tracing.baggage.remote-fields: x-correlation-id and management.tracing.baggage.correlation.fields: x-correlation-id, and it is propagated over HTTP and Kafka and copied into the MDC automatically.
Exercise
Exercise: In the correlation example, add an inventoryService that order-service calls before payment-service, and make the gateway reject (print "400 bad correlation id") any incoming ID longer than 20 characters.
Show answer
static void gateway(Map<String, String> headers) {
String incoming = headers.get("X-Correlation-Id");
if (incoming != null && incoming.length() > 20) {
System.out.println("400 bad correlation id");
return;
}
headers.putIfAbsent("X-Correlation-Id", String.format("req-%04d", ++counter));
withMdc(headers, () -> {
log("api-gateway", "route POST /api/orders -> order-service");
orderService(Map.of("X-Correlation-Id", MDC.get()));
});
}
static void orderService(Map<String, String> headers) {
withMdc(headers, () -> {
log("order-service", "order 1001 created");
inventoryService(Map.of("X-Correlation-Id", MDC.get()));
paymentService(Map.of("X-Correlation-Id", MDC.get()));
});
}
static void inventoryService(Map<String, String> headers) {
withMdc(headers, () -> log("inventory-service", "reserved 2 units"));
}