MDC Context Propagation
Finsta uses SLF4J MDC (Mapped Diagnostic Context) to attach tenantId and userId to every log entry.
This page explains how MDC is populated and how it propagates across thread boundaries.
How it works
HTTP requests: MdcServerFilter
MdcServerFilter is a @ServerFilter that runs early in the Micronaut HTTP pipeline.
It extracts tenantId from the OrganizationId header and userId from the authenticated principal, then sets both in MDC.
Micronaut captures its PropagatedContext snapshot before filters run, so MDC set in a filter would normally be lost on thread hops.
To fix this, the filter accepts a MutablePropagatedContext parameter and adds a fresh MdcPropagationContext snapshot after setting MDC.
This ensures the updated MDC is carried to any thread that handles work for this request.
The propagation chain:
-
MdcServerFiltersets MDC on the request thread -
The filter adds a new
MdcPropagationContexttoMutablePropagatedContext -
Micronaut’s
ExecutorServiceInstrumenterautomatically wraps all managed executors withContextPropagatingExecutorService -
When a task is submitted to a wrapped executor, the propagated context restores MDC on the worker thread
Non-HTTP contexts: MdcScope
Async event listeners, scheduled tasks, and migrations don’t originate from HTTP requests.
These use MdcScope to set MDC explicitly from data available in the event or context:
MdcScope.run(MdcOrigin.EVENT, tenantId, userId, () -> {
// mdcOrigin, tenantId and userId are in MDC here
log.info("Processing tenant");
});
Every entry point requires an MdcOrigin that identifies how MDC was populated.
Both tenantId and userId are @Nullable — pass null for either if unavailable.
For split lifecycles (e.g., request/response filters), use open/close:
MdcScope.open(MdcOrigin.REQUEST_TENANT, tenantId, userId);
// ... later, in a different method ...
MdcScope.close();
MdcScope is used in:
-
FsOverrideEventListener— setstenantSchemafrom the event -
PubSubPublisher/DummyPubSubPublisher— setstenantIdanduserIdfromFinancialStatementEvent -
TenantMigrationOrchestrator— setstenantIdfrom the tenant being migrated
Why not the old @RequestTracing approach?
The previous implementation used a custom @RequestTracing AOP annotation on each controller.
The interceptor set MDC correctly on the controller thread, but it ran after Micronaut had already captured the PropagatedContext snapshot.
This meant MDC was lost on any async code path — which is why tenant info was often missing from GCP log entries.
The server filter approach fixes this by explicitly updating the MutablePropagatedContext after setting MDC, so propagation works across all thread boundaries.
mdcOrigin
Every MDC entry includes an mdcOrigin key that identifies how it was populated.
This distinguishes "MDC was never set" (key absent) from "set but no tenant" in GCP logs.
| Value | Meaning |
|---|---|
|
HTTP request with tenant header |
|
HTTP request without tenant (admin/system endpoints) |
|
Async event listener (FsOverride, PubSub) |
|
Tenant schema migration |
Key classes
| Class | Location | Role |
|---|---|---|
|
|
Sets MDC from HTTP request headers |
|
|
Sets MDC in non-HTTP contexts |
|
|
Injectable filter parameter for updating the propagated context after filter logic |
|
|
Captures MDC as a propagated context element |
|
|
Wraps all managed executors to propagate context |
|
|
Executor wrapper that restores context on worker threads |