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:

  1. MdcServerFilter sets MDC on the request thread

  2. The filter adds a new MdcPropagationContext to MutablePropagatedContext

  3. Micronaut’s ExecutorServiceInstrumenter automatically wraps all managed executors with ContextPropagatingExecutorService

  4. 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 — sets tenantSchema from the event

  • PubSubPublisher / DummyPubSubPublisher — sets tenantId and userId from FinancialStatementEvent

  • TenantMigrationOrchestrator — sets tenantId from 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

request:tenant

HTTP request with tenant header

request:global

HTTP request without tenant (admin/system endpoints)

event

Async event listener (FsOverride, PubSub)

migration

Tenant schema migration

Key classes

Class Location Role

MdcServerFilter

finsta-service-app

Sets MDC from HTTP request headers

MdcScope

finsta-service

Sets MDC in non-HTTP contexts

MutablePropagatedContext

micronaut-core (framework)

Injectable filter parameter for updating the propagated context after filter logic

MdcPropagationContext

micronaut-context-propagation (framework)

Captures MDC as a propagated context element

ExecutorServiceInstrumenter

micronaut-context-propagation (framework)

Wraps all managed executors to propagate context

ContextPropagatingExecutorService

micronaut-context-propagation (framework)

Executor wrapper that restores context on worker threads