← Back to the section

When the logs have neither a requestId nor a userId, investigating an incident is almost impossible: it is unclear whose request failed, which trace a line belongs to, and what actually happened. The goal of context propagation is to make these fields appear in every log automatically, without passing them through the parameters of every method.

Both of the usual failures — the context never reached the asynchronous task, and the context surfaced in somebody else’s request — grow from one root: MDC lives in the thread, not in the request.

MDC is bound to thread exec-7, not to the request thread exec-7 · async- pool (core 10)exec-7 · requestId=a1f3MdcFilterlog processing order → a1f3async-3 · MDC emptyidleno task submitted to the pool yetrequest A: one thread — one context step 1 · MdcFilter at the start of the requestno X-Request-Id → UUID a1f3MDC.put(requestId, a1f3)then Security, controller, servicelogs on thread exec-7 carry a1f3the context lives in thread exec-7async-3 has no task yetwhile the request stays on one thread everything adds up thread exec-7 · async- pool (core 10)exec-7 · requestId=a1f3MdcFilterlog processing order → a1f3async-3 · MDC empty@Asynclog sending email → no requestIdthe trace breaks: 1 log of 2 without a1f3 step 2 · @Async went to thread async-3TaskDecorator is not configuredMDC is thread-local → no copyin async-3 the context is emptytask log without requestId and traceIdin the tracer the branch is detacheda search by a1f3 will not find itthe context did not travel with the task Tomcat pool: exec-7 is handed out in turnexec-7 · exception in the handlerMDC not cleared: a1f3, orderId=77request B got the same exec-7log B: requestId=b7c2, orderId=77orderId=77 — from another request, A step 3 · clear() after chain.doFilterthe handler threw an exceptionthe clear() line never ranthe thread returned to the pool dirtyMdcFilter overwrote requestIdbut orderId=77 stayed from request AB’s logs are stamped with a foreign orderone user’s data in another user’s logs thread exec-7 · async- pool (core 10)exec-7 · finally → MDC.clear()request B: only its own b7c2cleanasync-3 · MDC snapshot: a1f3log sending email → a1f3both logs carry a1f3, no foreign keys step 4 · finally + TaskDecoratorMDC.clear() in finally on exec-7getCopyOfContextMap() — a snapshotsetContextMap() in async-3every log carries its own requestIdafter the task async-3 is clean againnothing leaks into the next requesttwo fixes: finally on the way in, an MDC snapshot on the way out

MDC is bound to the thread, not to the request. Without TaskDecorator the context never reaches thread async-3, and without MDC.clear() in finally it outlives the failed request and surfaces as a foreign orderId=77 in the next user’s logs.

What MDC is

MDC (Mapped Diagnostic Context) is a store of "key → value" pairs that Slf4j/Logback binds to the current thread. Everything you put into MDC automatically shows up in every log line on that thread.

Without MDC you would have to write it like this:

log.info("Processing order orderId={} requestId={} userId={}", orderId, requestId, userId);

With MDC this is enough:

log.info("Processing order orderId={}", orderId);
// requestId and userId are pulled in automatically from MDC

This keeps the logs cleaner and protects against cases where a developer forgot to pass requestId into a method — the context is already in the thread.

The main limitation of MDC: it is thread-local. When a task moves to another thread (asynchronous tasks, @Async, CompletableFuture), MDC in the new thread is empty. More on this in the TaskDecorator section.

MdcFilter — one filter for the whole request

The most important component is the filter that puts requestId into MDC at the very beginning of every HTTP request and clears it at the end:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                     FilterChain chain) throws ServletException, IOException {
        var requestId = Optional.ofNullable(req.getHeader("X-Request-Id"))
            .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("requestId", requestId);
        resp.setHeader("X-Request-Id", requestId);
        try {
            chain.doFilter(req, resp);
        } finally {
            MDC.clear();
        }
    }
}

What happens here:

  • If the client sent an X-Request-Id header — we use it. This lets the client and server use the same identifier when investigating an incident.
  • If there is no header — we generate a UUID.
  • We put requestId into MDC — from this moment on it will be in every log line of the request.
  • We return X-Request-Id in the response — the client can save it and provide it to support if needed.
  • In the finally block we call MDC.clear() — this is critically important, more on it below.

@Order(Ordered.HIGHEST_PRECEDENCE) means the filter runs first — even before the Spring Security filter chain. This matters: if the request fails during authentication, the logs will still contain requestId.

MDC.clear() in finally — why it is mandatory

Tomcat and other servers reuse threads from a pool. After one request completes, the same thread is picked up for the next request from a different user.

If you don't clear MDC in finally, but instead call MDC.clear() in ordinary code:

// Dangerous — MDC.clear() won't run on an exception
protected void doFilterInternal(...) {
    MDC.put("requestId", UUID.randomUUID().toString());
    chain.doFilter(req, resp);
    MDC.clear();  // if something above threw an exception — this line won't run
}

Scenario: the handler threw an exception → MDC.clear() was not called → the thread returned to the pool → the next request from a different user got a thread with someone else's requestId and userId in MDC → all of its logs will be tagged with foreign data.

This is not just confusion in the logs — it is a leak of personal data between requests of different users, which is a security violation. That is why MDC.clear() must always be in the finally block.

traceId and spanId — automatically via OpenTelemetry

If the project includes the opentelemetry-logback-mdc-1.0 library, tracing identifiers appear in MDC automatically:

implementation("io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0")

After that every log will contain traceId and spanId from the currently active OpenTelemetry span. There is no need to do a manual MDC.put("traceId", ...) — it would conflict with the automatic value.

userId after authentication

requestId is placed into MDC before the security chain — at that point the user is not yet authenticated. For userId you need a separate filter that runs after Spring Security:

@Component
@Order(SecurityProperties.DEFAULT_FILTER_ORDER + 10)
public class UserIdMdcFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                     FilterChain chain) throws ServletException, IOException {
        try {
            var auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth != null && auth.isAuthenticated()) {
                MDC.put("userId", auth.getName());
            }
            chain.doFilter(req, resp);
        } finally {
            MDC.remove("userId");
        }
    }
}

Note: here it is MDC.remove("userId"), not MDC.clear(). The full cleanup is done by MdcFilter in its own finally — this filter only removes a specific key, so as not to touch requestId and traceId, which keep living until the end of the request.

TaskDecorator for asynchronous tasks

When a method is annotated with @Async or a task goes into CompletableFuture.runAsync(...), it runs on a different thread from the pool. MDC is thread-local — in the new thread it is empty:

@Async
public CompletableFuture<Void> sendEmail(Long userId) {
    log.info("Sending email to user");  // requestId and traceId are missing
    emailClient.send(userId);
    return CompletableFuture.completedFuture(null);
}

This means the logs of the asynchronous task are in no way connected to the original request — in the tracer they look detached.

The solution is a TaskDecorator. It runs at the moment the task is submitted to the pool: it copies MDC from the current thread and restores it in the executing thread:

@Bean
public TaskDecorator mdcTaskDecorator() {
    return runnable -> {
        var contextMap = MDC.getCopyOfContextMap();  // snapshot of MDC in the submitting thread
        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);   // restore in the executing thread
                }
                runnable.run();
            } finally {
                MDC.clear();                         // clean up after the task
            }
        };
    };
}

@Bean("taskExecutor")
public TaskExecutor taskExecutor(TaskDecorator decorator) {
    var executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(50);
    executor.setQueueCapacity(100);
    executor.setTaskDecorator(decorator);
    executor.setThreadNamePrefix("async-");
    executor.initialize();
    return executor;
}

After this, logs from @Async methods will contain the requestId and traceId of the original request, and in a tracer (for example, Tempo) you can see the full chain from the incoming request to the asynchronous branch.

Common mistakes

MDC.put in a service or handler. The logic that populates MDC should stay in filters, where there is a clear finally block. If you put MDC.put in a service, it is easy to forget to call MDC.remove — then the key leaks into subsequent requests on the same thread.

If you still need to add context for a short interval, use MDCCloseable — it is removed automatically when the block completes:

try (var ignored = MDC.putCloseable("orderId", order.id().toString())) {
    processOrder(order);
}
// orderId is automatically removed from MDC

MDC.put("traceId", ...) manually. If the OpenTelemetry Logback appender is included, traceId is already filled in automatically. A manual write would conflict with or overwrite the correct value.

@Async without TaskDecorator. The context is not carried over to the new thread, the logs of the asynchronous operation are not linked to the original request, and the trace is broken.

In short

  • MDC is a thread-local store that Logback automatically adds to every log. It lets you avoid passing requestId/userId through parameters.
  • MdcFilter with @Order(HIGHEST_PRECEDENCE) populates MDC at the start of every request and clears it in finally.
  • MDC.clear() is mandatory in finally, not in ordinary flow — otherwise, on an exception, the context leaks into the next request of a different user.
  • traceId and spanId are added automatically via opentelemetry-logback-mdc-1.0 — don't set them manually.
  • userId is added by a separate filter after Spring Security, once SecurityContextHolder is already populated.
  • For @Async you need a TaskDecorator that copies MDC from the original thread to the executing thread.