How do you bridge Reactor Context with ThreadLocal-based libraries like SLF4J MDC for logging trace IDs?
answer
- MDC = ThreadLocal, Context = subscription
- doOnEach + Signal.getContextView(), clear MDC in finally
- Micrometer context-propagation + ThreadLocalAccessor
- Hooks.enableAutomaticContextPropagation() + contextCapture()
- Micrometer Tracing (ex-Sleuth) does this for traceId
basics
~20 sReactor Context isn't automatically visible to MDC (a ThreadLocal). Use the Micrometer context-propagation library with contextCapture()/automatic context propagation, or manually copy Context values into MDC around log points, to make trace IDs appear in logs.
solid answer
~40 sMDC stores data in ThreadLocal, so values in Reactor Context don't reach it automatically across thread hops. Two approaches. (1) Manual: read the Context in an operator like doOnEach (its Signal exposes getContextView()) and set/clear MDC only for the synchronous logging call — you must clear it to avoid leaking onto pooled threads. (2) Automatic: add Micrometer's context-propagation library, register a ThreadLocalAccessor (e.g. for the MDC key), enable Reactor's automatic context propagation via Hooks.enableAutomaticContextPropagation(), and use contextCapture() to snapshot ThreadLocals into Context at subscription; Reactor then restores them around operator execution. Spring Boot 3 / Sleuth's successor Micrometer Tracing wires much of this. The key risks are thread-pool leakage (never leave MDC populated) and forgetting that only registered keys propagate.
code
java · 12 lines// Manual bridge: expose Reactor Context trace id to MDC only around the log call
public <T> Flux<T> logWithTrace(Flux<T> source) {
return source.doOnEach(signal -> {
if (!signal.isOnComplete()) {
String traceId = signal.getContextView().getOrDefault("traceId", "-");
try (MDC.MDCCloseable ignored = MDC.putCloseable("traceId", traceId)) {
if (signal.isOnNext()) log.info("next {}", signal.get());
if (signal.isOnError()) log.error("error", signal.getThrowable());
} // MDC auto-cleared here — prevents thread-pool leakage
}
});
}go deeper
Unlikely to know; fine to just say MDC and Context are separate worlds.
Should recognize the gap exists and that logging trace IDs needs bridging.
Should describe doOnEach manual bridge and clearing MDC, and name Micrometer Tracing.
Should discuss context-propagation ThreadLocalAccessor registration, enableAutomaticContextPropagation cost/scope, leakage risks, and choosing manual vs automatic.
## Why the gap exists **MDC** (Mapped Diagnostic Context, SLF4J/Logback) is a `ThreadLocal<Map>`. Log patterns like `%X{traceId}` read it from the *current thread*. Reactor Context lives on the subscription, not the thread, so on any given thread MDC is empty even though the Context holds the trace ID. Bridging = copying between the two worlds at the right moments. ## Approach 1 — manual bridging with doOnEach `doOnEach` runs a callback for every signal and its `Signal` exposes `getContextView()`. You can populate MDC, log, then clear: ```java flux.doOnEach(signal -> { if (signal.isOnNext()) { String traceId = signal.getContextView().getOrDefault("traceId", "-"); try (MDC.MDCCloseable c = MDC.putCloseable("traceId", traceId)) { log.info("processing {}", signal.get()); } } }); ``` The `try-with-resources`/finally clear is **critical**: WebFlux threads are pooled and long-lived, so a stale MDC entry leaks into unrelated requests. ## Approach 2 — Micrometer context-propagation (the modern way) Since Reactor 3.5 there is first-class integration with the **`io.micrometer:context-propagation`** library: 1. Register a **`ThreadLocalAccessor`** for each ThreadLocal you want bridged (Micrometer Tracing registers accessors for its trace/span ThreadLocals; you can register one that maps an MDC key ⇄ a Context key). 2. Call **`Hooks.enableAutomaticContextPropagation()`** (Reactor) once at startup — Boot's Micrometer Tracing autoconfig can do this. 3. Use **`contextCapture()`** to snapshot current ThreadLocals into the Reactor Context at that point, and Reactor will **restore** the registered ThreadLocals around the execution of downstream operators (so blocking code and logging see them on whatever thread runs). With automatic propagation enabled, imperative-looking code (`MDC.get`, `SecurityContextHolder`) can work inside reactive operators because Reactor restores/clears the ThreadLocals around each operator on each thread. ## Spring integration - **Micrometer Tracing** (the successor to Spring Cloud Sleuth) uses exactly this mechanism to put `traceId`/`spanId` into MDC for WebFlux apps. In Spring Boot 3 you typically just add the tracing starter and a bridge (Brave/OpenTelemetry) and the trace IDs show up in logs. - **Reactive Spring Security** stores `SecurityContext` in Reactor Context via `ReactiveSecurityContextHolder`; the same propagation machinery can expose it to blocking code that reads the classic `SecurityContextHolder`. ## Gotchas and design considerations - **Only registered keys propagate** — automatic propagation isn't magic; each ThreadLocal needs a `ThreadLocalAccessor`. - **Leakage** — always clear ThreadLocals; the framework does this for registered accessors, but manual bridging must clear in a finally block. - **Performance** — restoring ThreadLocals around every operator has a cost; `enableAutomaticContextPropagation` is broad. Scope it and measure. - **Ordering** — the Context must be populated upstream of where the capture/read happens (same upstream-propagation rule as always). - **contextCapture vs contextWrite** — `contextWrite` puts explicit values; `contextCapture()` pulls from currently-registered ThreadLocals into Context automatically. ## When to use which - Small app, one log line: manual `doOnEach` is fine and dependency-free. - Real distributed tracing across many log statements and blocking bridges: adopt Micrometer Tracing + context-propagation rather than hand-rolling.
- Why is clearing MDC essential in the manual approach?WebFlux uses a small pool of long-lived threads shared across requests. A leftover MDC entry would appear in log lines for unrelated later requests on that same thread, corrupting trace correlation.
- What does contextCapture() do that contextWrite() doesn't?contextWrite puts explicit key/values you provide. contextCapture() snapshots currently-registered ThreadLocal values (via Micrometer ThreadLocalAccessors) into the Reactor Context automatically, bridging imperative ThreadLocal state into the reactive world.
- Which library and hook enable automatic bidirectional propagation?io.micrometer:context-propagation providing ThreadLocalAccessors, plus Reactor's Hooks.enableAutomaticContextPropagation(); Micrometer Tracing wires this for distributed tracing.
saying these in an interview costs you the question
- Assuming %X{traceId} in Logback just works in WebFlux without any bridging
- Setting MDC in an operator and never clearing it
- Thinking every ThreadLocal auto-propagates without a registered accessor
- Confusing Sleuth (Spring Cloud, legacy) as still the current mechanism instead of Micrometer Tracing