EndpointMdcCallableInterceptor.kt
package eu.inqudium.limesium.servlet.logging
import eu.inqudium.limesium.common.EndpointLoggingMetrics
import eu.inqudium.limesium.common.MdcScope
import eu.inqudium.limesium.common.reportFailOpen
import eu.inqudium.limesium.common.reportQuietly
import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.context.request.async.CallableProcessingInterceptor
import java.util.concurrent.Callable
/**
* Restores the `endpoint_*` MDC identity on the Spring MVC async WORKER thread: the filter's chain
* scope closes as soon as `doFilter` returns, but a `Callable`/`WebAsyncTask` controller keeps working
* on an MVC task-executor thread afterwards - its application logs carried no correlation keys although
* the module advertises request identity in MDC while the request is handled.
*
* Spring MVC invokes [preProcess] on exactly that worker thread immediately before the task and
* [postProcess] on the same thread immediately after (applied in a `finally` around the invocation), so
* an [MdcScope] opened and closed there gives the worker the same additive overlay-and-restore
* semantics as the sync path. The filter registers one instance per request through `WebAsyncUtils` -
* no `spring-webmvc` dependency, no global MVC configuration, inert for requests that never go async.
*
* BOUNDARY: covers the `Callable`/`WebAsyncTask` worker only - `DeferredResult` producers and raw async
* workers are application-owned threads; see the "MDC coverage" section of [RequestLoggingFilter].
*
* FAIL-OPEN: both callbacks confine their own failures and count them as `stage=wiring` - MDC trouble
* must never disturb async dispatch.
*/
internal class EndpointMdcCallableInterceptor(
private val exchange: Exchange,
private val metrics: EndpointLoggingMetrics,
) : CallableProcessingInterceptor {
// preProcess and postProcess run on the SAME worker thread; the thread-local pairs them without
// assuming anything about how many workers the executor cycles through.
private val scope = ThreadLocal<MdcScope?>()
override fun <T : Any> preProcess(
request: NativeWebRequest,
task: Callable<T>,
) {
try {
scope.set(MdcScope(exchange.requestId, exchange.method, exchange.path))
} catch (e: Exception) {
scope.remove()
reportFailOpen(
metrics::wiringFailure,
internalLog,
Level.DEBUG,
e,
"Endpoint MDC could not be installed on the async worker; handler logs lose the identity",
)
}
}
override fun <T : Any> postProcess(
request: NativeWebRequest,
task: Callable<T>,
concurrentResult: Any?,
) {
try {
scope.get()?.close()
} catch (e: Exception) {
reportFailOpen(
metrics::wiringFailure,
internalLog,
Level.DEBUG,
e,
"Endpoint MDC could not be restored on the async worker",
)
} finally {
scope.remove()
}
}
companion object {
private val internalLog = LoggerFactory.getLogger(EndpointMdcCallableInterceptor::class.java)
}
}