I'm encountering a discrepancy in retrieving a custom CoroutineContext.Element (TraceContextElement) depending on the environment (test vs. production) and how I access the context within a launched coroutine, specifically when MDCContext is involved.
Goal: Propagate a custom TraceContextElement across coroutines, including async "fire-and-forget" tasks launched from a main request thread, while also maintaining MDC context using kotlinx-coroutines-slf4j.
Code Setup:
Custom Context Element:
import com.kakao.zertfds.infrastructure.logger.domain.TraceContext // Simplified TraceContext import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext // Wrapper for our TraceContext class TraceContextElement(val context: TraceContext) : AbstractCoroutineContextElement(Key) { companion object Key : CoroutineContext.Key<TraceContextElement> } // Helper to get the context fun CoroutineContext.traceContext(): TraceContext? = this[TraceContextElement]?.context // Simplified TraceContext data class data class TraceContext(val traceId: String?, /* other fields */ )Launcher Utilities: I have two utility functions to launch coroutines while adding both
MDCContextandTraceContextElement:import kotlinx.coroutines.* import kotlinx.coroutines.slf4j.MDCContext import org.slf4j.MDC import kotlin.coroutines.coroutineContext // --- Launcher 1: Uses CoroutineScope extension lambda --- fun CoroutineScope.launchWithTrace( traceManager: TraceManager, // Mocked in tests block: suspend CoroutineScope.() -> Unit // Lambda is CoroutineScope extension ): Job { val mdcContextMap = MDC.getCopyOfContextMap() ?: emptyMap() val traceContext = traceManager.createTraceContext() // Returns a test TraceContext // Launch with both contexts return launch(MDCContext() + TraceContextElement(traceContext)) { try { if (mdcContextMap.isNotEmpty()) MDC.setContextMap(mdcContextMap) block.invoke(this) // `this` is the CoroutineScope } finally { MDC.clear() } } } // --- Launcher 2: Uses standard suspend lambda --- fun CoroutineScope.launchWithTrace2( traceManager: TraceManager, // Mocked in tests block: suspend () -> Unit // Lambda is a standard suspend function ): Job { val mdcContextMap = MDC.getCopyOfContextMap() ?: emptyMap() val traceContext = traceManager.createTraceContext() // Returns a test TraceContext // Launch with both contexts return launch(MDCContext() + TraceContextElement(traceContext)) { try { if (mdcContextMap.isNotEmpty()) MDC.setContextMap(mdcContextMap) block() // Standard function call } finally { MDC.clear() } } }(Assume
TraceManageris a class that providescreateTraceContext(): TraceContext)
The Problem:
In my Kotest test environment, using Dispatchers.Default:
Using
launchWithTrace(CoroutineScope extension lambda):test("Test with launchWithTrace") { val scope = CoroutineScope(Dispatchers.Default) val mockTraceManager = mockk<TraceManager>() every { mockTraceManager.createTraceContext() } returns TraceContext("test-id") val latch = CountDownLatch(1) scope.launchWithTrace(mockTraceManager) { // Uses the extension lambda // Access context via implicit 'this.coroutineContext' val ctx = coroutineContext.traceContext() println("launchWithTrace context: $ctx") // PRINTS: launchWithTrace context: TraceContext(traceId=test-id) ctx shouldNotBe null // Assertion Passes latch.countDown() } latch.await(2, TimeUnit.SECONDS) shouldBe true }This works as expected. The
TraceContextElementis successfully retrieved usingcoroutineContext(which refers to the scope's context).Using
launchWithTrace2(standard suspend lambda):test("Test with launchWithTrace2 - FAILS") { val scope = CoroutineScope(Dispatchers.Default) val mockTraceManager = mockk<TraceManager>() every { mockTraceManager.createTraceContext() } returns TraceContext("test-id") val latch = CountDownLatch(1) scope.launchWithTrace2(mockTraceManager) { // Uses the standard lambda // Access context via top-level 'kotlin.coroutines.coroutineContext' val ctx = kotlin.coroutines.coroutineContext.traceContext() println("launchWithTrace2 context: $ctx") // PRINTS: launchWithTrace2 context: null // ctx shouldNotBe null // This assertion would FAIL latch.countDown() // This might not even be reached if assert added } latch.await(2, TimeUnit.SECONDS) shouldBe true // Test potentially hangs or fails assertion }This fails. Accessing the context via
kotlin.coroutines.coroutineContextinside the lambda returnsnullfor theTraceContextElement.
However:
In my actual Spring Boot application running on the JVM:
- When I use
launchWithTrace2within a controller or service (often using aCoroutineScopewithDispatchers.IO), accessing the context viakotlin.coroutines.coroutineContext.traceContext()works correctly and retrieves theTraceContextElementas expected.
Question:
Why does accessing the TraceContextElement via kotlin.coroutines.coroutineContext fail specifically within the standard suspend () -> Unit lambda in the test environment (Kotest, MockK, Dispatchers.Default, MDCContext), but works fine in the production environment and also works in the test environment if using a CoroutineScope extension lambda (suspend CoroutineScope.() -> Unit)?
Is this related to how kotlin.coroutines.coroutineContext interacts with MDCContext and potential thread switching in Dispatchers.Default within the test framework? Is there a known issue or a subtlety in how context is resolved via Continuation vs. the CoroutineScope object itself in this testing scenario?
Environment:
- Kotlin: 1.9.x
- kotlinx-coroutines-core: 1.8.x
- kotlinx-coroutines-slf4j: 1.8.x
- Kotest: 5.x.x
- MockK: 1.13.x
- JVM: 21
val ctx = kotlin.coroutines.coroutineContext.traceContext()is this the actual code you are using in the failing test, or did you add the full qualifier to avoid imports here?