Опирается на правила:
R-OBS-CTX-1…R-OBS-CTX-4иR-OBS-CTX-X1…R-OBS-CTX-X3из Observability Style Guide → раздел 6. Context propagation (MDC).
Важно знать
MdcFilterс@Order(HIGHEST_PRECEDENCE)populatesrequestIdна каждом incoming HTTP request.MDC.clear()вfinally— обязательно. Иначе контекст утекает между requests в thread pool, и чужойuserIdпопадает в чужие логи.traceId/spanIdавтоматически через OTel Logback appender. Не добавлять руками.userIdpopulates после JWT-валидации в Security filter chain.- Async / CompletableFuture / @Async —
TaskDecoratorсcopyOfContextMap→setContextMap→clear. Без декоратора traces разрываются.MDC.putтолько в filter/interceptor, не в handler/service. Иначе clear-логика неочевидна.- Утечка MDC — compliance incident: чужой userId в чужих логах под чужими действиями.
MDC (Mapped Diagnostic Context) — потоково-локальное хранилище в Slf4j/Logback. Каждый log-entry автоматически содержит текущий MDC. Это даёт requestId/traceId/userId в каждом логе без явного передавания через все методы. Цена — дисциплина в lifecycle: populated в правильном месте, cleared в правильном месте.
MdcFilter
R-OBS-CTX-1: один filter на entry-point.
@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из header, если есть; иначе генерирует UUID. - Возвращает тот же
X-Request-Idв response — клиент может его залогировать и предоставить при инциденте. - Кладёт в MDC, и все log-entries в этом request будут с этим
requestId. - В
finallyчистит MDC.
@Order(HIGHEST_PRECEDENCE) — filter работает до Security filter chain. Значит ошибки в Security уже залогируются с requestId. Без этого инциденты с auth-фейлами расследуются вслепую.
TraceId/spanId автоматически
R-OBS-CTX-2: подключаем opentelemetry-logback-mdc-1.0 (см. Tracing).
implementation("io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0")
OTel сам populates traceId, spanId в MDC на каждое incoming HTTP request. Руками MDC.put("traceId", ...) не делать — конфликт с авто.
TaskDecorator для @Async
R-OBS-CTX-3: проблема — @Async и CompletableFuture.runAsync(...) выполняются на отдельном thread, у которого MDC пустой.
@Bean
public TaskDecorator mdcTaskDecorator() {
return runnable -> {
var contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
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;
}
Что делает декоратор:
- На submitting thread — копирует текущий MDC (
getCopyOfContextMap). - На executing thread — устанавливает скопированный context (
setContextMap). - После выполнения — clear, чтобы не загрязнять следующую задачу на том же thread.
Без декоратора:
@Async
public CompletableFuture<Void> sendEmail(Long userId) {
log.info("Sending email to user"); // нет requestId, нет traceId — лог сирота
emailClient.send(userId);
return CompletableFuture.completedFuture(null);
}
С декоратором — requestId, traceId пропагируются. Trace в Tempo показывает span от incoming request → @Async branch без разрыва.
Для OTel context (нужен для traceparent propagation в outgoing HTTP внутри @Async) — отдельный механизм через Context.taskWrapping. OTel java agent делает это автоматически, без javaagent нужно явное оборачивание.
userId после JWT
R-OBS-CTX-4: populates после auth, не в MdcFilter.
@Component
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");
}
}
}
Этот filter порядок — после Spring Security filter chain (чтобы SecurityContextHolder уже заполнен). В Spring Boot 3 — @Order(SecurityFilterChain.DEFAULT_ORDER + 10).
MDC.remove("userId") вместо полного MDC.clear() — requestId/traceId ставит MdcFilter/OTel и они должны жить до конца request. Здесь снимаем только userId.
Что запрещено
MDC без MDC.clear() в finally
R-OBS-CTX-X1 — главная ошибка.
// КАТАСТРОФА — MDC утекает в thread pool
@Component
public class BadMdcFilter extends OncePerRequestFilter {
protected void doFilterInternal(...) {
MDC.put("requestId", UUID.randomUUID().toString());
chain.doFilter(req, resp);
MDC.clear(); // НЕ выполнится при exception
}
}
Сценарий: handler бросает IllegalStateException, filter не выполняет MDC.clear(). Tomcat возвращает thread в pool. Через 5ms тот же thread обслуживает request другого пользователя — но MDC.requestId остался от старого. Все логи нового пользователя помечены userId=user-of-previous-request (если userId тоже не cleared).
В compliance audit это incident: чужой userId в чужих логах. Расследование — недели.
Всегда try { ... } finally { MDC.clear(); }.
MDC.put в произвольных местах
R-OBS-CTX-X2: MDC.put внутри handler-а или service — нарушает дисциплину.
// ПЛОХО
@Service
public class OrderService {
public void process(Order order) {
MDC.put("orderId", String.valueOf(order.id()));
// ... обработка
// MDC.remove("orderId") где?
}
}
Если забыть MDC.remove("orderId") — orderId утекает в логи следующих операций на том же thread. Дисциплина — MDC.put только в filter/interceptor с явным MDC.clear() в finally. Внутри handler-а используем log-параметры log.info("...orderId={}", order.id()), не MDC.
Если действительно нужна business-метка на короткий период — MDC.MDCCloseable:
try (var ignored = MDC.putCloseable("orderId", order.id().toString())) {
process(order);
}
Автозакрытие try-with-resources снимает риск утечки.
@Async без TaskDecorator
R-OBS-CTX-X3: см. секцию выше. Traces разрываются на границе thread, requestId исчезает из логов async-операций.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
MDC.put без MDC.clear() в finally | R-OBS-CTX-X1 | try/finally или MDCCloseable |
MDC.put в handler/service напрямую | R-OBS-CTX-X2 | только в filter; для коротких — MDCCloseable |
@Async без TaskDecorator | R-OBS-CTX-X3 | TaskDecorator с copy → setContextMap → clear |
MDC.put("traceId", ...) руками | R-OBS-CTX-2 | opentelemetry-logback-mdc-1.0 |
userId в MdcFilter до Security | R-OBS-CTX-4 | отдельный filter после Security |
| Один filter для request+user без четкого order | R-OBS-CTX-1, R-OBS-CTX-4 | два filter с явным @Order |
MDC.clear() в обычном flow (не в finally) | R-OBS-CTX-X1 | всегда в finally — exception-safe |
Куда дальше
- Observability → раздел 6. Context propagation — нормативные формулировки.
- Logging — как MDC поля попадают в JSON.
- Tracing —
traceId/spanIdавтоматически в MDC. - Configuration —
<includeMdcKeyName>в LogstashEncoder. - Auth → AUTH-16 — почему утечка userId в логах — compliance incident.
- Resilience → async polling — TaskDecorator для async-обработки.