Fix coroutine logging - #597
Conversation
a4cf22f to
8f88619
Compare
44a3974 to
3c30abf
Compare
3c30abf to
0dbe37b
Compare
13706b9 to
fbc9f5f
Compare
6022762 to
6ff2f9a
Compare
There was a problem hiding this comment.
Pull request overview
Adds a coroutine-context-based MDC wrapper to avoid thread-local MDC leakage when using suspending code in coroutines (per kotlin-logging issue #75), along with tests and documentation describing the new behavior.
Changes:
- Introduces
withCoroutineLoggingContext(...)suspend wrappers that build MDC state from the parent coroutineMDCContextinstead of the current thread-local MDC. - Adds coroutine tests demonstrating the leak in
withLoggingContextAsyncand the non-leaking behavior ofwithCoroutineLoggingContext. - Documents the new coroutine-safe wrapper and its non-inheritance of pre-existing thread-local MDC.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
src/jvmMain/kotlin/io/github/oshai/kotlinlogging/coroutines/KotlinLoggingAsyncMDC.kt |
Adds withCoroutineLoggingContext overloads and implementation based on parent coroutine MDCContext. |
src/jvmTest/kotlin/io/github/oshai/kotlinlogging/coroutines/KotlinLoggingAsyncMDCTest.kt |
Adds tests for cross-coroutine MDC leakage and for the new coroutine-context wrapper. |
src/jvmTest/kotlin/io/github/oshai/kotlinlogging/coroutines/README.md |
Documents intended usage and behavior of the new coroutine-safe wrapper. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| requestADuringBlock.complete(MDC.getCopyOfContextMap()) | ||
| requestBDone.await() // Suspend while B runs | ||
| } | ||
| // will be empty | ||
| requestAAfterBlock.complete(MDC.getCopyOfContextMap()) | ||
| } | ||
|
|
||
| val jobB = | ||
| launch(dispatcher) { | ||
| requestAStarted.await() | ||
| withLoggingContextAsync("foo" to "bar") { | ||
| requestBDuringBlock.complete(MDC.getCopyOfContextMap()) | ||
| } | ||
| // Capture MDC state immediately after Request B's block, will NOT be empty due to the bug | ||
| requestBAfterBlock.complete(MDC.getCopyOfContextMap()) | ||
| requestBDone.complete(Unit) | ||
| } | ||
|
|
||
| jobA.join() | ||
| jobB.join() | ||
|
|
||
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | ||
| assertEquals(emptyMap(), requestAAfterBlock.await()) | ||
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) |
There was a problem hiding this comment.
MDC.getCopyOfContextMap() is nullable per the SLF4J API contract (it can return null when the MDC is empty). The test currently asserts emptyMap() in a few places, which can be brittle across MDC implementations; consider normalizing captures to MDC.getCopyOfContextMap() ?: emptyMap() and using non-null deferred types.
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | ||
| assertEquals(emptyMap(), requestAAfterBlock.await()) | ||
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) | ||
| // THIS IS THE BUG: Request B sees "original" after its own block exits | ||
| // It should be null (no context) but it's leaking Request A's value | ||
| assertEquals(mapOf("foo" to "original"), requestBAfterBlock.await()) | ||
| } |
There was a problem hiding this comment.
This test asserts the leaking behavior of withLoggingContextAsync as the expected outcome. That locks the current bug in as “correct” and will fail once the underlying leak is fixed; consider either (a) asserting the correct behavior (no leak) and fixing withLoggingContextAsync, or (b) marking this test as @Disabled/documentation-only with a link to the issue so it doesn’t block future fixes.
| crossinline body: suspend () -> T, | ||
| ): T { | ||
| val parent = currentCoroutineContext()[MDCContext]?.contextMap ?: emptyMap() | ||
| val merged = (parent + map.filterValues { it != null }).mapValues { it.value!! } |
There was a problem hiding this comment.
withCoroutineLoggingContext(map, ...) currently drops null entries via filterValues { it != null }, which means callers cannot remove keys inherited from the parent coroutine MDC (e.g., passing "k" to null won’t clear an existing k). Since the API accepts String? values, it’s important to define and implement null semantics; if you want parity with withLoggingContext, apply entries so that null removes the key from the merged map rather than being ignored.
| val merged = (parent + map.filterValues { it != null }).mapValues { it.value!! } | |
| val merged = parent.toMutableMap().apply { | |
| for ((key, value) in map) { | |
| if (value == null) { | |
| remove(key) | |
| } else { | |
| put(key, value) | |
| } | |
| } | |
| } |
| If you prefer to leverage coroutine context instead of thread local storage, you can use the `withCoroutineLoggingContext` wrapper. | ||
| This will NOT inherit the current MDC set prior, but will carry existing coroutine context within nested declarations, restoring previous context | ||
| at the logical coroutine level rather than thread level. |
There was a problem hiding this comment.
The README says this wrapper uses coroutine context “instead of thread local storage”, but the implementation still relies on SLF4J MDC thread-locals (via kotlinx.coroutines.slf4j.MDCContext) and just propagates them safely through coroutine context. Rewording this to avoid implying MDC is no longer thread-local would make the docs technically accurate and less confusing.
| If you prefer to leverage coroutine context instead of thread local storage, you can use the `withCoroutineLoggingContext` wrapper. | |
| This will NOT inherit the current MDC set prior, but will carry existing coroutine context within nested declarations, restoring previous context | |
| at the logical coroutine level rather than thread level. | |
| If you prefer to leverage coroutine context to propagate MDC safely across suspensions, you can use the `withCoroutineLoggingContext` wrapper. | |
| This will NOT inherit the current MDC set on the calling thread, but will carry the logging context established by outer `withCoroutineLoggingContext` calls within nested declarations, | |
| restoring the previous MDC for the current coroutine when the block completes. |
| val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher() | ||
| // establish order of events with deferreds: | ||
| // 1. request A will start, set its context, and then wait for request B (before exiting | ||
| // withLoggingContextAsync) | ||
| // 2. request B will start, set its context, and then capture the MDC | ||
| val requestAStarted = CompletableDeferred<Unit>() | ||
| val requestBDone = CompletableDeferred<Unit>() | ||
|
|
||
| val requestADuringBlock = CompletableDeferred<Map<String, String>?>() | ||
| val requestAAfterBlock = CompletableDeferred<Map<String, String>?>() | ||
| val requestBDuringBlock = CompletableDeferred<Map<String, String>?>() | ||
| val requestBAfterBlock = CompletableDeferred<Map<String, String>?>() | ||
|
|
||
| val jobA = | ||
| launch(dispatcher) { | ||
| withCoroutineLoggingContext("foo" to "original") { | ||
| requestAStarted.complete(Unit) | ||
| requestADuringBlock.complete(MDC.getCopyOfContextMap()) | ||
| requestBDone.await() // Suspend while B runs | ||
| } | ||
| // will be empty | ||
| requestAAfterBlock.complete(MDC.getCopyOfContextMap()) | ||
| } | ||
|
|
||
| val jobB = | ||
| launch(dispatcher) { | ||
| requestAStarted.await() | ||
| withCoroutineLoggingContext("foo" to "bar") { | ||
| requestBDuringBlock.complete(MDC.getCopyOfContextMap()) | ||
| } | ||
| // will also be empty | ||
| requestBAfterBlock.complete(MDC.getCopyOfContextMap()) | ||
| requestBDone.complete(Unit) | ||
| } | ||
|
|
||
| jobA.join() | ||
| jobB.join() | ||
|
|
||
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | ||
| assertEquals(emptyMap(), requestAAfterBlock.await()) | ||
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) | ||
| assertEquals(emptyMap(), requestBAfterBlock.await()) |
There was a problem hiding this comment.
This test also creates a fixed-thread-pool-backed dispatcher and doesn’t close it. Please close the ExecutorCoroutineDispatcher (e.g., use { ... } or finally { dispatcher.close() }) to avoid leaking threads between tests.
| val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher() | |
| // establish order of events with deferreds: | |
| // 1. request A will start, set its context, and then wait for request B (before exiting | |
| // withLoggingContextAsync) | |
| // 2. request B will start, set its context, and then capture the MDC | |
| val requestAStarted = CompletableDeferred<Unit>() | |
| val requestBDone = CompletableDeferred<Unit>() | |
| val requestADuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestAAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBDuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val jobA = | |
| launch(dispatcher) { | |
| withCoroutineLoggingContext("foo" to "original") { | |
| requestAStarted.complete(Unit) | |
| requestADuringBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.await() // Suspend while B runs | |
| } | |
| // will be empty | |
| requestAAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| val jobB = | |
| launch(dispatcher) { | |
| requestAStarted.await() | |
| withCoroutineLoggingContext("foo" to "bar") { | |
| requestBDuringBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| // will also be empty | |
| requestBAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.complete(Unit) | |
| } | |
| jobA.join() | |
| jobB.join() | |
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | |
| assertEquals(emptyMap(), requestAAfterBlock.await()) | |
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) | |
| assertEquals(emptyMap(), requestBAfterBlock.await()) | |
| Executors.newFixedThreadPool(1).asCoroutineDispatcher().use { dispatcher -> | |
| // establish order of events with deferreds: | |
| // 1. request A will start, set its context, and then wait for request B (before exiting | |
| // withLoggingContextAsync) | |
| // 2. request B will start, set its context, and then capture the MDC | |
| val requestAStarted = CompletableDeferred<Unit>() | |
| val requestBDone = CompletableDeferred<Unit>() | |
| val requestADuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestAAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBDuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val jobA = | |
| launch(dispatcher) { | |
| withCoroutineLoggingContext("foo" to "original") { | |
| requestAStarted.complete(Unit) | |
| requestADuringBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.await() // Suspend while B runs | |
| } | |
| // will be empty | |
| requestAAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| val jobB = | |
| launch(dispatcher) { | |
| requestAStarted.await() | |
| withCoroutineLoggingContext("foo" to "bar") { | |
| requestBDuringBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| // will also be empty | |
| requestBAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.complete(Unit) | |
| } | |
| jobA.join() | |
| jobB.join() | |
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | |
| assertEquals(emptyMap(), requestAAfterBlock.await()) | |
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) | |
| assertEquals(emptyMap(), requestBAfterBlock.await()) | |
| } |
| // establish order of events with deferreds: | ||
| // 1. request A will start, set its context, and then wait for request B (before exiting | ||
| // withLoggingContextAsync) | ||
| // 2. request B will start, set its context, and then capture the MDC | ||
| val requestAStarted = CompletableDeferred<Unit>() | ||
| val requestBDone = CompletableDeferred<Unit>() | ||
|
|
||
| val requestADuringBlock = CompletableDeferred<Map<String, String>?>() | ||
| val requestAAfterBlock = CompletableDeferred<Map<String, String>?>() | ||
| val requestBDuringBlock = CompletableDeferred<Map<String, String>?>() | ||
| val requestBAfterBlock = CompletableDeferred<Map<String, String>?>() | ||
|
|
||
| val jobA = | ||
| launch(dispatcher) { | ||
| withLoggingContextAsync("foo" to "original") { | ||
| requestAStarted.complete(Unit) | ||
| requestADuringBlock.complete(MDC.getCopyOfContextMap()) | ||
| requestBDone.await() // Suspend while B runs | ||
| } | ||
| // will be empty | ||
| requestAAfterBlock.complete(MDC.getCopyOfContextMap()) | ||
| } | ||
|
|
||
| val jobB = | ||
| launch(dispatcher) { | ||
| requestAStarted.await() | ||
| withLoggingContextAsync("foo" to "bar") { | ||
| requestBDuringBlock.complete(MDC.getCopyOfContextMap()) | ||
| } | ||
| // Capture MDC state immediately after Request B's block, will NOT be empty due to the bug | ||
| requestBAfterBlock.complete(MDC.getCopyOfContextMap()) | ||
| requestBDone.complete(Unit) | ||
| } | ||
|
|
||
| jobA.join() | ||
| jobB.join() | ||
|
|
||
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | ||
| assertEquals(emptyMap(), requestAAfterBlock.await()) | ||
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) | ||
| // THIS IS THE BUG: Request B sees "original" after its own block exits | ||
| // It should be null (no context) but it's leaking Request A's value | ||
| assertEquals(mapOf("foo" to "original"), requestBAfterBlock.await()) |
There was a problem hiding this comment.
The test creates an ExecutorCoroutineDispatcher from a new fixed thread pool but never closes it. This can leak threads across the test suite; wrap the dispatcher in use { ... } or close it in a finally block (which will also shut down the underlying executor).
| // establish order of events with deferreds: | |
| // 1. request A will start, set its context, and then wait for request B (before exiting | |
| // withLoggingContextAsync) | |
| // 2. request B will start, set its context, and then capture the MDC | |
| val requestAStarted = CompletableDeferred<Unit>() | |
| val requestBDone = CompletableDeferred<Unit>() | |
| val requestADuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestAAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBDuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val jobA = | |
| launch(dispatcher) { | |
| withLoggingContextAsync("foo" to "original") { | |
| requestAStarted.complete(Unit) | |
| requestADuringBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.await() // Suspend while B runs | |
| } | |
| // will be empty | |
| requestAAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| val jobB = | |
| launch(dispatcher) { | |
| requestAStarted.await() | |
| withLoggingContextAsync("foo" to "bar") { | |
| requestBDuringBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| // Capture MDC state immediately after Request B's block, will NOT be empty due to the bug | |
| requestBAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.complete(Unit) | |
| } | |
| jobA.join() | |
| jobB.join() | |
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | |
| assertEquals(emptyMap(), requestAAfterBlock.await()) | |
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) | |
| // THIS IS THE BUG: Request B sees "original" after its own block exits | |
| // It should be null (no context) but it's leaking Request A's value | |
| assertEquals(mapOf("foo" to "original"), requestBAfterBlock.await()) | |
| dispatcher.use { dispatcher -> | |
| // establish order of events with deferreds: | |
| // 1. request A will start, set its context, and then wait for request B (before exiting | |
| // withLoggingContextAsync) | |
| // 2. request B will start, set its context, and then capture the MDC | |
| val requestAStarted = CompletableDeferred<Unit>() | |
| val requestBDone = CompletableDeferred<Unit>() | |
| val requestADuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestAAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBDuringBlock = CompletableDeferred<Map<String, String>?>() | |
| val requestBAfterBlock = CompletableDeferred<Map<String, String>?>() | |
| val jobA = | |
| launch(dispatcher) { | |
| withLoggingContextAsync("foo" to "original") { | |
| requestAStarted.complete(Unit) | |
| requestADuringBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.await() // Suspend while B runs | |
| } | |
| // will be empty | |
| requestAAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| val jobB = | |
| launch(dispatcher) { | |
| requestAStarted.await() | |
| withLoggingContextAsync("foo" to "bar") { | |
| requestBDuringBlock.complete(MDC.getCopyOfContextMap()) | |
| } | |
| // Capture MDC state immediately after Request B's block, will NOT be empty due to the bug | |
| requestBAfterBlock.complete(MDC.getCopyOfContextMap()) | |
| requestBDone.complete(Unit) | |
| } | |
| jobA.join() | |
| jobB.join() | |
| assertEquals(mapOf("foo" to "original"), requestADuringBlock.await()) | |
| assertEquals(emptyMap(), requestAAfterBlock.await()) | |
| assertEquals(mapOf("foo" to "bar"), requestBDuringBlock.await()) | |
| // THIS IS THE BUG: Request B sees "original" after its own block exits | |
| // It should be null (no context) but it's leaking Request A's value | |
| assertEquals(mapOf("foo" to "original"), requestBAfterBlock.await()) | |
| } |
fixes #75