Когда в логах нет ни requestId, ни userId, расследовать инцидент почти невозможно: непонятно, чей запрос упал, к какой трассировке относится строка, и что вообще произошло. Цель context propagation — сделать так, чтобы эти поля были в каждом логе автоматически, без передачи через параметры каждого метода.
Что такое MDC
MDC (Mapped Diagnostic Context) — это хранилище пар «ключ → значение», которое Slf4j/Logback привязывает к текущему потоку. Всё, что вы положили в MDC, автоматически появляется в каждой строке лога этого потока.
Без MDC нужно было бы писать так:
log.info("Processing order orderId={} requestId={} userId={}", orderId, requestId, userId);
С MDC достаточно:
log.info("Processing order orderId={}", orderId);
// requestId и userId подтянутся автоматически из MDC
Это делает логи чище и защищает от случаев, когда разработчик забыл передать requestId в метод — контекст уже в потоке.
Главное ограничение MDC: он потоко-локальный. Когда задача уходит в другой поток (асинхронные задачи, @Async, CompletableFuture), MDC в новом потоке пустой. Об этом — в разделе про TaskDecorator.
MdcFilter — один фильтр для всего запроса
Самый важный компонент — фильтр, который кладёт requestId в MDC в самом начале каждого HTTP-запроса и очищает его в конце:
@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();
}
}
}
Что здесь происходит:
- Если клиент прислал заголовок
X-Request-Id— используем его. Это позволяет клиенту и серверу использовать один и тот же идентификатор при разборе инцидента. - Если заголовка нет — генерируем UUID.
- Кладём
requestIdв MDC — с этого момента он будет в каждом логе запроса. - Возвращаем
X-Request-Idв ответе — клиент может его сохранить и при необходимости предоставить в поддержку. - В блоке
finallyвызываемMDC.clear()— это критически важно, об этом ниже.
@Order(Ordered.HIGHEST_PRECEDENCE) означает, что фильтр запускается первым — ещё до цепочки фильтров Spring Security. Это важно: если запрос падает на аутентификации, в логах всё равно будет requestId.
MDC.clear() в finally — почему это обязательно
Tomcat и другие серверы переиспользуют потоки из пула. После завершения одного запроса тот же поток берётся для следующего запроса другого пользователя.
Если не очистить MDC в finally, а вместо этого вызвать MDC.clear() в обычном коде:
// Опасно — MDC.clear() не выполнится при исключении
protected void doFilterInternal(...) {
MDC.put("requestId", UUID.randomUUID().toString());
chain.doFilter(req, resp);
MDC.clear(); // если выше бросили исключение — этой строки не будет
}
Сценарий: обработчик бросил исключение → MDC.clear() не вызвался → поток вернулся в пул → следующий запрос другого пользователя получил поток с чужим requestId и userId в MDC → все его логи будут помечены чужими данными.
Это не просто путаница в логах — это утечка персональных данных между запросами разных пользователей, что является нарушением безопасности. Поэтому MDC.clear() всегда должен быть в блоке finally.
traceId и spanId — автоматически через OpenTelemetry
Если в проекте подключена библиотека opentelemetry-logback-mdc-1.0, трассировочные идентификаторы появляются в MDC автоматически:
implementation("io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0")
После этого в каждом логе будут traceId и spanId от текущего активного span'а OpenTelemetry. Ручной MDC.put("traceId", ...) делать не нужно — он создаст конфликт с автоматическим значением.
userId после аутентификации
requestId кладётся в MDC до цепочки безопасности — в этот момент пользователь ещё не аутентифицирован. Для userId нужен отдельный фильтр, который запускается уже после 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");
}
}
}
Обратите внимание: здесь MDC.remove("userId"), а не MDC.clear(). Полную очистку делает MdcFilter в своём finally — этот фильтр лишь убирает конкретный ключ, чтобы не затронуть requestId и traceId, которые продолжают жить до конца запроса.
TaskDecorator для асинхронных задач
Когда метод помечен @Async или задача уходит в CompletableFuture.runAsync(...), она выполняется на другом потоке из пула. MDC потоко-локальный — в новом потоке он пустой:
@Async
public CompletableFuture<Void> sendEmail(Long userId) {
log.info("Sending email to user"); // requestId и traceId отсутствуют
emailClient.send(userId);
return CompletableFuture.completedFuture(null);
}
Это значит, что логи асинхронной задачи никак не связаны с исходным запросом — в трассировщике они выглядят оторванными.
Решение — TaskDecorator. Он запускается в момент передачи задачи в пул: копирует MDC из текущего потока и восстанавливает его в потоке-исполнителе:
@Bean
public TaskDecorator mdcTaskDecorator() {
return runnable -> {
var contextMap = MDC.getCopyOfContextMap(); // снимок MDC в submitting-потоке
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap); // восстанавливаем в executing-потоке
}
runnable.run();
} finally {
MDC.clear(); // очищаем после задачи
}
};
};
}
@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;
}
После этого логи из @Async-методов будут содержать requestId и traceId исходного запроса, и в трассировщике (например, Tempo) можно увидеть полную цепочку от входящего запроса до асинхронной ветки.
Частые ошибки
MDC.put в сервисе или обработчике. Логику наполнения MDC нужно держать в фильтрах, где есть чёткий finally-блок. Если поставить MDC.put в сервисе, легко забыть вызвать MDC.remove — тогда ключ утечёт в следующие запросы на том же потоке.
Если всё же нужно добавить контекст на короткий промежуток, используйте MDCCloseable — он снимается автоматически по завершении блока:
try (var ignored = MDC.putCloseable("orderId", order.id().toString())) {
processOrder(order);
}
// orderId автоматически убран из MDC
MDC.put("traceId", ...) вручную. Если подключён OpenTelemetry Logback appender, traceId уже заполняется автоматически. Ручная запись создаст конфликт или перезапишет правильное значение.
@Async без TaskDecorator. Контекст не передаётся в новый поток, логи асинхронной операции не связаны с исходным запросом, трассировка разрывается.
Коротко
- MDC — потоко-локальное хранилище, которое Logback автоматически добавляет в каждый лог. Позволяет не передавать
requestId/userIdчерез параметры. MdcFilterс@Order(HIGHEST_PRECEDENCE)наполняет MDC в начале каждого запроса и очищает его вfinally.MDC.clear()обязательно вfinally, а не в обычном потоке — иначе при исключении контекст утечёт в следующий запрос чужого пользователя.traceIdиspanIdдобавляются автоматически черезopentelemetry-logback-mdc-1.0— вручную их не ставить.userIdдобавляется отдельным фильтром после Spring Security, когдаSecurityContextHolderуже заполнен.- Для
@AsyncнуженTaskDecorator, который копирует MDC из исходного потока в поток-исполнитель.
Что почитать дальше
- Логирование в Spring Boot — как MDC-поля попадают в структурированный JSON-лог.
- Трассировка и OpenTelemetry — как работает автоматический
traceId/spanIdв MDC.