feat(server,logging): correlation-structured MDC logging at seams
Implement T1 of observability epic: every server log line carries sessionId
(and stageId where known) via SLF4J MDC, propagated across coroutine
suspension points.
Changes:
- Create com.correx.apps.server.logging.Correlation: withSessionContext()
helper that sets MDC (sessionId, stageId), wraps block in MDCContext(),
and restores prior values on exit; supports safe nesting
- Wrap launchSessionRun() orchestrator loop in withSessionContext(sessionId)
so all session logs carry the sessionId
- Wrap GlobalStreamHandler ChatInput and StartChatSession onUserInput call
sites in withSessionContext(sessionId)
- Wrap SessionStreamHandler ChatInput onUserInput in withSessionContext(sessionId)
- Update log4j2.properties patterns: append
%notEmpty{ [%X{sessionId}]}%notEmpty{ [%X{stageId}]} before %msg
on both console and file appenders
- Add kotlinx-coroutines-slf4j:1.9.0 dependency for MDCContext
Test (6 tests):
- withSessionContext sets/restores sessionId ✓
- withSessionContext sets/restores stageId ✓
- Nested contexts stack and restore correctly ✓
- MDC cleared when stageId not provided ✓
- Propagation across coroutine boundaries via MDCContext ✓
- Cleanup after block exit ✓
Acceptance:
- Zero behavior change; no core module touched ✓
- All imports verified as used ✓
- No bare try-catch; try/finally used correctly ✓
- Detekt-compliant ✓
- Full test suite passes (120 tests, 119 passed, 1 skipped) ✓
This commit is contained in:
@@ -62,6 +62,7 @@ dependencies {
|
|||||||
implementation "org.apache.logging.log4j:log4j-core:2.24.1"
|
implementation "org.apache.logging.log4j:log4j-core:2.24.1"
|
||||||
implementation "org.apache.logging.log4j:log4j-slf4j2-impl:2.24.1"
|
implementation "org.apache.logging.log4j:log4j-slf4j2-impl:2.24.1"
|
||||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.9.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named("koverVerify").configure { enabled = false }
|
tasks.named("koverVerify").configure { enabled = false }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.correx.apps.server
|
|||||||
|
|
||||||
import com.correx.apps.server.approval.ApprovalCoordinator
|
import com.correx.apps.server.approval.ApprovalCoordinator
|
||||||
import com.correx.apps.server.freestyle.FreestyleDriver
|
import com.correx.apps.server.freestyle.FreestyleDriver
|
||||||
|
import com.correx.apps.server.logging.withSessionContext
|
||||||
import com.correx.apps.server.narration.NarrationSubscriber
|
import com.correx.apps.server.narration.NarrationSubscriber
|
||||||
import com.correx.apps.server.registry.ProviderRegistry
|
import com.correx.apps.server.registry.ProviderRegistry
|
||||||
import com.correx.apps.server.registry.WorkflowRegistry
|
import com.correx.apps.server.registry.WorkflowRegistry
|
||||||
@@ -195,73 +196,75 @@ class ServerModule(
|
|||||||
) {
|
) {
|
||||||
activeSessionJobs.computeIfAbsent(sessionId) {
|
activeSessionJobs.computeIfAbsent(sessionId) {
|
||||||
moduleScope.launch {
|
moduleScope.launch {
|
||||||
// Record the repo map + seed prior-session memory before the run so stages
|
withSessionContext(sessionId) {
|
||||||
// see both in context.
|
// Record the repo map + seed prior-session memory before the run so stages
|
||||||
projectMemory?.let { pm ->
|
// see both in context.
|
||||||
runCatching {
|
projectMemory?.let { pm ->
|
||||||
pm.observeAndRecord(sessionId, pm.repoRoot())
|
runCatching {
|
||||||
pm.indexAndRecord(sessionId, pm.repoRoot())
|
pm.observeAndRecord(sessionId, pm.repoRoot())
|
||||||
pm.retrieveAndSeed(sessionId, pm.repoRoot())
|
pm.indexAndRecord(sessionId, pm.repoRoot())
|
||||||
}
|
pm.retrieveAndSeed(sessionId, pm.repoRoot())
|
||||||
}
|
|
||||||
// Bind operator profile snapshot as an event so replay reads the recorded
|
|
||||||
// fact rather than re-reading the live file (invariants #8/#9).
|
|
||||||
operatorProfile?.let { profile ->
|
|
||||||
eventStore.append(
|
|
||||||
NewEvent(
|
|
||||||
metadata = EventMetadata(
|
|
||||||
eventId = EventId(java.util.UUID.randomUUID().toString()),
|
|
||||||
sessionId = sessionId,
|
|
||||||
timestamp = Clock.System.now(),
|
|
||||||
schemaVersion = 1,
|
|
||||||
causationId = null,
|
|
||||||
correlationId = null,
|
|
||||||
),
|
|
||||||
payload = OperatorProfileBoundEvent(
|
|
||||||
sessionId = sessionId,
|
|
||||||
about = profile.about,
|
|
||||||
approvalMode = profile.preferences.approvalMode,
|
|
||||||
preferredModels = profile.preferences.preferredModels,
|
|
||||||
conventions = profile.preferences.conventions,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
bindProjectProfile(sessionId)
|
|
||||||
runCatching {
|
|
||||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
|
||||||
freestyleHandoff(sessionId, graph, result)
|
|
||||||
// Distil this run's decisions into durable project memory on completion.
|
|
||||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
|
||||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
|
||||||
operatorProfile?.let { profile ->
|
|
||||||
profileAdaptationService?.let { svc ->
|
|
||||||
runCatching { svc.proposeAdaptation(sessionId, profile) }
|
|
||||||
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { ex ->
|
// Bind operator profile snapshot as an event so replay reads the recorded
|
||||||
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
|
// fact rather than re-reading the live file (invariants #8/#9).
|
||||||
eventStore.append(
|
operatorProfile?.let { profile ->
|
||||||
NewEvent(
|
eventStore.append(
|
||||||
metadata = EventMetadata(
|
NewEvent(
|
||||||
eventId = EventId(java.util.UUID.randomUUID().toString()),
|
metadata = EventMetadata(
|
||||||
sessionId = sessionId,
|
eventId = EventId(java.util.UUID.randomUUID().toString()),
|
||||||
timestamp = Clock.System.now(),
|
sessionId = sessionId,
|
||||||
schemaVersion = 1,
|
timestamp = Clock.System.now(),
|
||||||
causationId = null,
|
schemaVersion = 1,
|
||||||
correlationId = null,
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = OperatorProfileBoundEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
about = profile.about,
|
||||||
|
approvalMode = profile.preferences.approvalMode,
|
||||||
|
preferredModels = profile.preferences.preferredModels,
|
||||||
|
conventions = profile.preferences.conventions,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
payload = WorkflowFailedEvent(
|
)
|
||||||
sessionId = sessionId,
|
}
|
||||||
stageId = graph.start,
|
bindProjectProfile(sessionId)
|
||||||
reason = ex.message ?: "unexpected orchestrator failure",
|
runCatching {
|
||||||
retryExhausted = false,
|
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||||
|
freestyleHandoff(sessionId, graph, result)
|
||||||
|
// Distil this run's decisions into durable project memory on completion.
|
||||||
|
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
||||||
|
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||||
|
operatorProfile?.let { profile ->
|
||||||
|
profileAdaptationService?.let { svc ->
|
||||||
|
runCatching { svc.proposeAdaptation(sessionId, profile) }
|
||||||
|
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { ex ->
|
||||||
|
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(java.util.UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = WorkflowFailedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = graph.start,
|
||||||
|
reason = ex.message ?: "unexpected orchestrator failure",
|
||||||
|
retryExhausted = false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
|
activeSessionJobs.remove(sessionId)
|
||||||
}
|
}
|
||||||
activeSessionJobs.remove(sessionId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.correx.apps.server.logging
|
||||||
|
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.coroutines.slf4j.MDCContext
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.slf4j.MDC
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute [block] with [sessionId] and optional [stageId] bound to the SLF4J MDC (Mapped Diagnostic Context).
|
||||||
|
*
|
||||||
|
* The sessionId and stageId are automatically propagated across coroutine suspension points via
|
||||||
|
* [MDCContext] (requires kotlinx-coroutines-slf4j). Prior MDC values are saved and restored after
|
||||||
|
* the block exits, enabling safe nesting.
|
||||||
|
*
|
||||||
|
* @param sessionId The session identifier to bind in MDC under key "sessionId"
|
||||||
|
* @param stageId Optional stage identifier to bind in MDC under key "stageId"; if null, any existing
|
||||||
|
* stageId is cleared
|
||||||
|
* @param block The suspending function to execute with MDC context active
|
||||||
|
* @return The result of [block]
|
||||||
|
*/
|
||||||
|
suspend inline fun <T> withSessionContext(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId? = null,
|
||||||
|
crossinline block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
// Save prior values so we can restore them after the block.
|
||||||
|
val priorSessionId = MDC.get("sessionId")
|
||||||
|
val priorStageId = MDC.get("stageId")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// Set the new values in MDC.
|
||||||
|
MDC.put("sessionId", sessionId.value)
|
||||||
|
if (stageId != null) {
|
||||||
|
MDC.put("stageId", stageId.value)
|
||||||
|
} else {
|
||||||
|
// Clear stageId if not provided, in case one was set by an outer context.
|
||||||
|
MDC.remove("stageId")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the block in MDCContext to propagate MDC across coroutine boundaries.
|
||||||
|
// MDCContext captures the current MDC state and restores it when the coroutine resumes
|
||||||
|
// after a suspension point.
|
||||||
|
withContext(MDCContext()) {
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Restore prior values. If priorSessionId was null, remove the key entirely.
|
||||||
|
if (priorSessionId != null) {
|
||||||
|
MDC.put("sessionId", priorSessionId)
|
||||||
|
} else {
|
||||||
|
MDC.remove("sessionId")
|
||||||
|
}
|
||||||
|
if (priorStageId != null) {
|
||||||
|
MDC.put("stageId", priorStageId)
|
||||||
|
} else {
|
||||||
|
MDC.remove("stageId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.correx.apps.server.ws
|
|||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.bridge.DomainEventMapper
|
import com.correx.apps.server.bridge.DomainEventMapper
|
||||||
import com.correx.apps.server.bridge.SessionEventBridge
|
import com.correx.apps.server.bridge.SessionEventBridge
|
||||||
|
import com.correx.apps.server.logging.withSessionContext
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.GrantScopeDto
|
import com.correx.apps.server.protocol.GrantScopeDto
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
@@ -237,11 +238,13 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
|
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
|
||||||
// and reach the client as chat.turn frames via streamGlobal — no direct
|
// and reach the client as chat.turn frames via streamGlobal — no direct
|
||||||
// RouterResponseMessage (the conversation is event-derived).
|
// RouterResponseMessage (the conversation is event-derived).
|
||||||
runCatching {
|
withSessionContext(msg.sessionId) {
|
||||||
module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
|
runCatching {
|
||||||
}.onFailure {
|
module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
|
||||||
log.error("routerFacade.onUserInput failed: {}", it.message)
|
}.onFailure {
|
||||||
sendFrame(ServerMessage.ProtocolError("Router error: ${it.message}"))
|
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||||
|
sendFrame(ServerMessage.ProtocolError("Router error: ${it.message}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ClientMessage.SwapModel -> handleSwapModel(msg, sendFrame)
|
is ClientMessage.SwapModel -> handleSwapModel(msg, sendFrame)
|
||||||
@@ -384,15 +387,17 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
runCatching { module.bindProjectProfile(sessionId) }
|
runCatching { module.bindProjectProfile(sessionId) }
|
||||||
.onFailure { log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message) }
|
.onFailure { log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message) }
|
||||||
|
|
||||||
runCatching {
|
withSessionContext(sessionId) {
|
||||||
module.routerFacade.onUserInput(sessionId, msg.text)
|
runCatching {
|
||||||
}.onFailure {
|
module.routerFacade.onUserInput(sessionId, msg.text)
|
||||||
log.error("routerFacade.onUserInput failed: {}", it.message)
|
}.onFailure {
|
||||||
sendFrame(ServerMessage.ProtocolError(
|
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||||
message = "Router error: ${it.message}",
|
sendFrame(ServerMessage.ProtocolError(
|
||||||
sequence = null,
|
message = "Router error: ${it.message}",
|
||||||
sessionSequence = null,
|
sequence = null,
|
||||||
))
|
sessionSequence = null,
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.correx.apps.server.ws
|
|||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.bridge.DomainEventMapper
|
import com.correx.apps.server.bridge.DomainEventMapper
|
||||||
|
import com.correx.apps.server.logging.withSessionContext
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
@@ -78,28 +79,30 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ClientMessage.ChatInput -> {
|
is ClientMessage.ChatInput -> {
|
||||||
runCatching {
|
withSessionContext(msg.sessionId) {
|
||||||
module.routerFacade.onUserInput(
|
runCatching {
|
||||||
sessionId = msg.sessionId,
|
module.routerFacade.onUserInput(
|
||||||
input = msg.text,
|
sessionId = msg.sessionId,
|
||||||
mode = msg.mode,
|
input = msg.text,
|
||||||
)
|
mode = msg.mode,
|
||||||
}.onSuccess { response ->
|
)
|
||||||
session.send(
|
}.onSuccess { response ->
|
||||||
Frame.Text(
|
session.send(
|
||||||
ProtocolSerializer.encodeServerMessage(
|
Frame.Text(
|
||||||
RouterResponseMessage(
|
ProtocolSerializer.encodeServerMessage(
|
||||||
sessionId = msg.sessionId,
|
RouterResponseMessage(
|
||||||
content = response.content,
|
sessionId = msg.sessionId,
|
||||||
steeringEmitted = response.steeringEmitted,
|
content = response.content,
|
||||||
|
steeringEmitted = response.steeringEmitted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
)
|
}.onFailure {
|
||||||
}.onFailure {
|
log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it)
|
||||||
log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it)
|
val error = ServerMessage.ProtocolError("Router error: ${it.message}")
|
||||||
val error = ServerMessage.ProtocolError("Router error: ${it.message}")
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.correx.apps.server.logging
|
||||||
|
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class CorrelationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withSessionContext sets and restores sessionId in MDC`(): Unit = runBlocking {
|
||||||
|
assertNull(MDC.get("sessionId"), "MDC should be empty initially")
|
||||||
|
|
||||||
|
val sessionId = SessionId("test-session-123")
|
||||||
|
withSessionContext(sessionId) {
|
||||||
|
assertEquals("test-session-123", MDC.get("sessionId"), "sessionId should be set in MDC")
|
||||||
|
assertNull(MDC.get("stageId"), "stageId should not be set when not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(MDC.get("sessionId"), "sessionId should be restored after block exits")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withSessionContext with stageId sets and restores both values`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("session-abc")
|
||||||
|
val stageId = StageId("stage-xyz")
|
||||||
|
|
||||||
|
withSessionContext(sessionId, stageId) {
|
||||||
|
assertEquals("session-abc", MDC.get("sessionId"), "sessionId should be set")
|
||||||
|
assertEquals("stage-xyz", MDC.get("stageId"), "stageId should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(MDC.get("sessionId"), "sessionId should be cleared")
|
||||||
|
assertNull(MDC.get("stageId"), "stageId should be cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nested withSessionContext stacks and restores correctly`(): Unit = runBlocking {
|
||||||
|
val sessionId1 = SessionId("session-1")
|
||||||
|
val stageId1 = StageId("stage-1")
|
||||||
|
val sessionId2 = SessionId("session-2")
|
||||||
|
val stageId2 = StageId("stage-2")
|
||||||
|
|
||||||
|
withSessionContext(sessionId1, stageId1) {
|
||||||
|
assertEquals("session-1", MDC.get("sessionId"))
|
||||||
|
assertEquals("stage-1", MDC.get("stageId"))
|
||||||
|
|
||||||
|
withSessionContext(sessionId2, stageId2) {
|
||||||
|
assertEquals("session-2", MDC.get("sessionId"), "inner context should override")
|
||||||
|
assertEquals("stage-2", MDC.get("stageId"), "inner stage should override")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After inner block, outer values are restored
|
||||||
|
assertEquals("session-1", MDC.get("sessionId"), "outer sessionId should be restored")
|
||||||
|
assertEquals("stage-1", MDC.get("stageId"), "outer stageId should be restored")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(MDC.get("sessionId"))
|
||||||
|
assertNull(MDC.get("stageId"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withSessionContext clears MDC when overwriting with null stageId`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("session-x")
|
||||||
|
|
||||||
|
// Set up initial state
|
||||||
|
withSessionContext(sessionId, StageId("initial-stage")) {
|
||||||
|
assertEquals("initial-stage", MDC.get("stageId"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter new context with same sessionId but no stageId
|
||||||
|
withSessionContext(sessionId) {
|
||||||
|
assertEquals("session-x", MDC.get("sessionId"))
|
||||||
|
assertNull(MDC.get("stageId"), "stageId should be cleared when not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(MDC.get("sessionId"))
|
||||||
|
assertNull(MDC.get("stageId"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withSessionContext propagates across coroutine boundaries with MDCContext`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("session-coroutine")
|
||||||
|
|
||||||
|
withSessionContext(sessionId) {
|
||||||
|
val captured = MDC.get("sessionId")
|
||||||
|
assertEquals("session-coroutine", captured, "MDC should be accessible in nested suspension")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withSessionContext removes entries when both sessionId and stageId are absent`(): Unit = runBlocking {
|
||||||
|
// Ensure MDC is clean before the test
|
||||||
|
MDC.clear()
|
||||||
|
|
||||||
|
withSessionContext(SessionId("new-session")) {
|
||||||
|
assertEquals("new-session", MDC.get("sessionId"))
|
||||||
|
assertNull(MDC.get("stageId"), "stageId should be cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(MDC.get("sessionId"), "sessionId should be cleared after block")
|
||||||
|
assertNull(MDC.get("stageId"), "stageId should remain cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user