← назад к разделу

Когда в логах нет ни 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.