skip to content

How do you bridge Reactor Context with ThreadLocal-based libraries like SLF4J MDC for logging trace IDs?

level: principalimportance: should knowfreq 35%

answer

  1. MDC = ThreadLocal, Context = subscription
  2. doOnEach + Signal.getContextView(), clear MDC in finally
  3. Micrometer context-propagation + ThreadLocalAccessor
  4. Hooks.enableAutomaticContextPropagation() + contextCapture()
  5. Micrometer Tracing (ex-Sleuth) does this for traceId

basics

~20 s

Reactor 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 s

MDC 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
java
// 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

for a junior

Unlikely to know; fine to just say MDC and Context are separate worlds.

for a middle

Should recognize the gap exists and that logging trace IDs needs bridging.

for a senior

Should describe doOnEach manual bridge and clearing MDC, and name Micrometer Tracing.

for a principal

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

context