Skip to content

Commit 0dbe37b

Browse files
committed
feat(jvm) add withCoroutineLoggingContext
1 parent 239fee2 commit 0dbe37b

2 files changed

Lines changed: 152 additions & 20 deletions

File tree

src/jvmMain/kotlin/io/github/oshai/kotlinlogging/coroutines/KotlinLoggingAsyncMDC.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ public suspend inline fun <T> withLoggingContextAsync(
2626
withContext(MDCContext()) { body() }
2727
}
2828

29+
/**
30+
* Use a pair inheriting coroutine context but not current MDC Context. Example:
31+
* ```
32+
* withLoggingContextAsync("userId" to userId) {
33+
* doSomething()
34+
* }
35+
* ```
36+
*/
37+
public suspend inline fun <T> withCoroutineLoggingContext(
38+
pair: Pair<String, String?>,
39+
crossinline body: suspend () -> T,
40+
): T = withCoroutineLoggingContext(mapOf(pair), body)
41+
2942
/**
3043
* Use a varying number of pairs in an asynchronous MDC context. Example:
3144
* ```
@@ -43,6 +56,19 @@ public suspend inline fun <T> withLoggingContextAsync(
4356
withContext(MDCContext()) { body() }
4457
}
4558

59+
/**
60+
* Use a varying number of pairs inheriting coroutine context but not current MDC Context. Example:
61+
* ```
62+
* withCoroutineLoggingContext("userId" to userId) {
63+
* doSomething()
64+
* }
65+
* ```
66+
*/
67+
public suspend inline fun <T> withCoroutineLoggingContext(
68+
vararg pair: Pair<String, String?>,
69+
crossinline body: suspend () -> T,
70+
): T = withCoroutineLoggingContext(pair.toMap(), body)
71+
4672
/**
4773
* Use a map in an asynchronous MDC context. Example:
4874
* ```
@@ -64,3 +90,21 @@ public suspend inline fun <T> withLoggingContextAsync(
6490
withLoggingContext(map, restorePrevious = restorePrevious) {
6591
withContext(MDCContext()) { body() }
6692
}
93+
94+
/**
95+
* Use a map inheriting coroutine context but not current MDC Context. Example:
96+
* ```
97+
* withCoroutineLoggingContext(mapOf("userId" to userId)) {
98+
* doSomething()
99+
* }
100+
* ```
101+
*/
102+
public suspend inline fun <T> withCoroutineLoggingContext(
103+
map: Map<String, String?>,
104+
crossinline body: suspend () -> T,
105+
): T {
106+
val parent = currentCoroutineContext()[MDCContext]?.contextMap ?: emptyMap()
107+
val merged = (parent + map.filterValues { it != null }).mapValues { it.value!! }
108+
109+
return withContext(MDCContext(merged)) { body() }
110+
}

src/jvmTest/kotlin/io/github/oshai/kotlinlogging/coroutines/KotlinLoggingAsyncMDCTest.kt

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
package io.github.oshai.kotlinlogging.coroutines
22

3-
import kotlinx.coroutines.CompletableDeferred
3+
import java.util.concurrent.Executors
44
import kotlin.test.*
5+
import kotlinx.coroutines.CompletableDeferred
56
import kotlinx.coroutines.ExperimentalCoroutinesApi
67
import kotlinx.coroutines.asCoroutineDispatcher
78
import kotlinx.coroutines.launch
8-
import kotlinx.coroutines.runBlocking
99
import kotlinx.coroutines.test.runTest
1010
import org.apache.logging.log4j.*
1111
import org.apache.logging.log4j.core.config.*
1212
import org.junit.jupiter.api.BeforeEach
1313
import org.slf4j.*
14-
import java.util.concurrent.Executors
1514

1615
@ExperimentalCoroutinesApi
1716
class KotlinLoggingAsyncMDCTest {
@@ -168,37 +167,126 @@ class KotlinLoggingAsyncMDCTest {
168167
fun `withLoggingContextAsync leaks context across coroutines`() = runTest {
169168
val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
170169
// establish order of events with deferreds:
171-
// 1. request A will start, set its context, and then wait for request B (before exiting withLoggingContextAsync)
170+
// 1. request A will start, set its context, and then wait for request B (before exiting
171+
// withLoggingContextAsync)
172172
// 2. request B will start, set its context, and then capture the MDC
173173
val requestAStarted = CompletableDeferred<Unit>()
174174
val requestBDone = CompletableDeferred<Unit>()
175175

176176
val requestAAfterBlock = CompletableDeferred<Map<String, String>?>()
177177
val requestBAfterBlock = CompletableDeferred<Map<String, String>?>()
178178

179-
val jobA = launch(dispatcher) {
180-
withLoggingContextAsync("foo" to "original") {
181-
requestAStarted.complete(Unit)
182-
requestBDone.await() // Suspend while B runs
179+
val jobA =
180+
launch(dispatcher) {
181+
withLoggingContextAsync("foo" to "original") {
182+
requestAStarted.complete(Unit)
183+
requestBDone.await() // Suspend while B runs
184+
}
185+
// will be empty
186+
requestAAfterBlock.complete(MDC.getCopyOfContextMap())
183187
}
184-
// will be empty
185-
requestAAfterBlock.complete(MDC.getCopyOfContextMap())
186-
}
187188

188-
val jobB = launch(dispatcher) {
189-
requestAStarted.await()
190-
withLoggingContextAsync("foo" to "bar") { }
191-
// Capture MDC state immediately after Request B's block, will NOT be empty due to the bug
192-
requestBAfterBlock.complete(MDC.getCopyOfContextMap())
193-
requestBDone.complete(Unit)
194-
}
189+
val jobB =
190+
launch(dispatcher) {
191+
requestAStarted.await()
192+
withLoggingContextAsync("foo" to "bar") {}
193+
// Capture MDC state immediately after Request B's block, will NOT be empty due to the bug
194+
requestBAfterBlock.complete(MDC.getCopyOfContextMap())
195+
requestBDone.complete(Unit)
196+
}
195197

196198
jobA.join()
197199
jobB.join()
198200

199201
assertEquals(emptyMap(), requestAAfterBlock.await())
200-
// THIS IS THE BUG: Request B sees "AAA" after its own block exits
202+
// THIS IS THE BUG: Request B sees "original" after its own block exits
201203
// It should be null (no context) but it's leaking Request A's value
202-
assertEquals(mapOf("foo" to "original"), requestBAfterBlock.await()) // This will FAIL
204+
assertEquals(mapOf("foo" to "original"), requestBAfterBlock.await())
205+
}
206+
207+
@Test
208+
fun `withCoroutineLoggingContext does not leak context across coroutines`() = runTest {
209+
val dispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
210+
// establish order of events with deferreds:
211+
// 1. request A will start, set its context, and then wait for request B (before exiting
212+
// withLoggingContextAsync)
213+
// 2. request B will start, set its context, and then capture the MDC
214+
val requestAStarted = CompletableDeferred<Unit>()
215+
val requestBDone = CompletableDeferred<Unit>()
216+
217+
val requestAAfterBlock = CompletableDeferred<Map<String, String>?>()
218+
val requestBAfterBlock = CompletableDeferred<Map<String, String>?>()
219+
220+
val jobA =
221+
launch(dispatcher) {
222+
withCoroutineLoggingContext("foo" to "original") {
223+
requestAStarted.complete(Unit)
224+
requestBDone.await() // Suspend while B runs
225+
}
226+
// will be empty
227+
requestAAfterBlock.complete(MDC.getCopyOfContextMap())
228+
}
229+
230+
val jobB =
231+
launch(dispatcher) {
232+
requestAStarted.await()
233+
withCoroutineLoggingContext("foo" to "bar") {}
234+
// Capture MDC state immediately after Request B's block, will NOT be empty due to the bug
235+
requestBAfterBlock.complete(MDC.getCopyOfContextMap())
236+
requestBDone.complete(Unit)
237+
}
238+
239+
jobA.join()
240+
jobB.join()
241+
242+
assertEquals(emptyMap(), requestAAfterBlock.await())
243+
assertEquals(emptyMap(), requestBAfterBlock.await())
244+
}
245+
246+
@Test
247+
fun `map withCoroutineLoggingContext`() = runTest {
248+
assertNull(MDC.get("a"))
249+
assertNull(MDC.get("c"))
250+
assertNull(MDC.get("e"))
251+
assertNull(MDC.get("f"))
252+
assertNull(MDC.get("k"))
253+
254+
// original MDC outside of coroutine context
255+
MDC.put("e", "g")
256+
MDC.put("k", "l")
257+
258+
withCoroutineLoggingContext(mapOf("a" to "b", "c" to "d", "e" to null, "f" to "h")) {
259+
assertEquals("b", MDC.get("a"))
260+
assertEquals("d", MDC.get("c"))
261+
// does NOT inherit original MDC
262+
assertNull(MDC.get("e"))
263+
assertEquals("h", MDC.get("f"))
264+
// does NOT inherit original MDC
265+
assertNull(MDC.get("k"))
266+
267+
withCoroutineLoggingContext(mapOf("a" to "b", "e" to "i", "f" to "j")) {
268+
assertEquals("b", MDC.get("a"))
269+
// DOES inherit previous coroutine context
270+
assertEquals("d", MDC.get("c"))
271+
assertEquals("i", MDC.get("e"))
272+
assertEquals("j", MDC.get("f"))
273+
assertNull(MDC.get("k"))
274+
}
275+
276+
assertEquals("b", MDC.get("a"))
277+
assertEquals("d", MDC.get("c"))
278+
assertNull(MDC.get("e"))
279+
assertEquals("h", MDC.get("f"))
280+
assertNull(MDC.get("k"))
281+
}
282+
283+
assertNull(MDC.get("a"))
284+
assertNull(MDC.get("c"))
285+
286+
// original MDC is restored
287+
assertEquals("g", MDC.get("e"))
288+
assertNull(MDC.get("f"))
289+
// original MDC is restored
290+
assertEquals("l", MDC.get("k"))
203291
}
204292
}

0 commit comments

Comments
 (0)