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.
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-Idheader — 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
requestIdinto MDC — from this moment on it will be in every log line of the request. - We return
X-Request-Idin the response — the client can save it and provide it to support if needed. - In the
finallyblock we callMDC.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/userIdthrough parameters. MdcFilterwith@Order(HIGHEST_PRECEDENCE)populates MDC at the start of every request and clears it infinally.MDC.clear()is mandatory infinally, not in ordinary flow — otherwise, on an exception, the context leaks into the next request of a different user.traceIdandspanIdare added automatically viaopentelemetry-logback-mdc-1.0— don't set them manually.userIdis added by a separate filter after Spring Security, onceSecurityContextHolderis already populated.- For
@Asyncyou need aTaskDecoratorthat copies MDC from the original thread to the executing thread.
What to read next
- Logging in Spring Boot — how MDC fields end up in the structured JSON log.
- Tracing and OpenTelemetry — how the automatic
traceId/spanIdin MDC works.