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"));
}