Skip to content

fix: gate MdcLoggable masking on log-level and Redis-shipping checks - #102

Closed
hongwei1 wants to merge 4 commits into
develop-obpfrom
fix/mdcloggable-unconditional-masking
Closed

hongwei1 wants to merge 4 commits into
develop-obpfrom
fix/mdcloggable-unconditional-masking

Conversation

@hongwei1

@hongwei1 hongwei1 commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • SecureLogging.maskSensitive ran unconditionally inside every info/warn/error/debug/trace override in MdcLoggable (code/util/Helper.scala), regardless of whether the underlying logger would emit the message. Each call runs the message through ~19 sequential regex passes.
  • Under sustained DEBUG-level traffic this became a hot path: profiling (JFR execution samples) showed the maskSensitive lambda as the single largest application-level CPU consumer, running on the request-handling compute pool rather than a dedicated logging thread, alongside heavy StringBuilder/regex allocation and G1 GC pressure from a near-full heap.
  • Turning the log level down alone does not fix the cost, because masking happens before the underlying logger's own level check.
  • Even once gated correctly, the masking + Redis-shipping serialization (org.json4s.native.Serialization.write) still ran inline on whatever thread called into the logger. On this codebase's http4s/cats-effect request path that's frequently a fiber-managed worker thread; non-yielding CPU-bound work run directly on it looks identical, from the runtime's own fairness/starvation detector, to genuine blocking I/O. cats-effect's response to detected starvation is to spin up additional compensating worker/blocker threads that are never reclaimed -- observed directly during the incident this PR traces back to (io-compute-blocker-* thread counts climbing into the thousands), and independently confirmed via a heap dump: hundreds of threads each holding their own private Scala-reflection symbol-table cache in thread-local storage, accounting for most of a ~7.7GB old-gen.

Change

  • Added RedisLogger.isEnabled/RedisLogger.shouldShip(level) plus a new redis_logging_min_level prop (default INFO). Redis log shipping is an independent sink with its own per-level queues, so a level must still reach it when Redis shipping is on -- but shouldn't force DEBUG/TRACE volume through just because the switch is on, since that reintroduces the same masking cost the level gate below is meant to remove.
  • Gated each MdcLoggable level (info/warn/error/debug/trace, including the Throwable overloads) on underlyingLogger.isXEnabled || RedisLogger.shouldShip(level). When neither sink wants the message, the call-by-name msg argument is never evaluated and maskSensitive never runs. Behaviour is unchanged when a level is actually enabled.
  • Once a call does pass that gate, masking and the actual write (local + Redis) are dispatched onto a small dedicated ExecutionContext (mdc_logging_dispatch_thread_pool_size, default 2) instead of running inline on the calling thread -- the same pattern RedisLogger already uses for its own async shipping. cats.effect.IO.blocking isn't an option here: MdcLoggable is mixed into ~260 classes, many called from contexts with no IO runtime in scope at all (actors, scheduled jobs, Lift-era code), so nothing would ever run an IO value in those contexts. A plain background ExecutionContext works everywhere the trait itself is used.
  • Trade-off worth being explicit about: a given logger's output is no longer strictly write-ordered relative to other concurrent callers on that logger. Same trade-off any async logging setup makes (Logback's own AsyncAppender, Log4j2's AsyncLogger); this was never a strict ordering guarantee once Redis shipping (already async) was in the picture.

Test plan

  • mvn -pl obp-commons,obp-api -am compile -DskipTests -o -- BUILD SUCCESS
  • RedisLoggerShouldShipTest (4/4) -- pins LogLevel's declaration-order-as-severity-order invariant shouldShip depends on, and the disabled-state contract.
  • MdcLoggableDispatchTest (1/1) -- verifies an enabled log call actually reaches the underlying logger asynchronously, on a thread other than the caller's. Caught a real bug before it shipped: the new executor was a plain val, but Helper's own static initializer transitively touches other objects (APIUtil/Constant) that log during their own initialization, re-entering Helper's init before the val at that point in the object body had run -- a NullPointerException on every such call. Fixed by making it lazy val.
  • Deploy alongside turning the affected instance's log level back to INFO (see fix: ship an active logback.xml defaulting to INFO #104) and confirm CPU drops.

SecureLogging.maskSensitive ran unconditionally inside every info/warn/
error/debug/trace override in MdcLoggable, regardless of whether the
underlying logger would actually emit the message. Each call walked the
message through roughly 19 sequential regex passes, so any code path
logging at DEBUG/TRACE volume paid that cost even with those levels
disabled, and paid it every single time when they were enabled.

Gate each level on the underlying logger's isXEnabled plus a new
RedisLogger.isEnabled check (Redis log shipping is an independent
sink with its own per-level queues, so it must still receive a
level's messages when enabled even if the local logger threshold is
higher). When neither sink wants the message, the call-by-name msg
argument is never evaluated and maskSensitive never runs. Behaviour is
unchanged when a level is actually enabled.
MdcLoggable's isXEnabled-or-RedisLogger.isEnabled gate treated Redis
log shipping as all-or-nothing: turning redis_logging_enabled on for a
deployment made every DEBUG/TRACE call site ship to Redis regardless
of the local logger's own level, defeating the level check this same
change just added for the local-logger-only case. On a deployment that
enables Redis shipping and also happens to log at high DEBUG/TRACE
volume, that reintroduces the same masking cost the isXEnabled gate
was meant to remove.

Add a configurable floor (redis_logging_min_level, default INFO) and
RedisLogger.shouldShip(level), and use it in place of the blanket
isEnabled check both in MdcLoggable and inside logAsync itself. Redis
shipping still works exactly as before for INFO and up; DEBUG/TRACE
now also need the floor lowered explicitly, not just the switch
flipped on.
shouldShip compares levels by LogLevel.id, which depends on Scala
Enumeration's declaration-order id assignment matching severity order --
nothing about the Enumeration itself enforces that, so pin it explicitly.
Also covers the actually-testable half of shouldShip: with Redis log
shipping disabled (this suite's default, and the production-safe
default), it returns false for every level, including ALL.

The props-driven half (redis_logging_enabled / redis_logging_min_level)
isn't covered here: RedisLogger's config vals are read once on first JVM
access to the object, and something during boot has almost certainly
already touched it before any test runs, so changing props at test time
has no effect on what's already cached. That part was verified by hand
against the actual deployed jar over SSH instead.
Once a log call passes the level/Redis-shipping gate added earlier in
this branch, SecureLogging.maskSensitive and the local/Redis write
still ran inline on whatever thread called logger.debug/info/etc. On
this codebase's http4s/cats-effect request path that's often a
fiber-managed worker thread; non-yielding CPU-bound work run directly
on it is indistinguishable, from the runtime's own fairness/starvation
detector, from genuine blocking I/O. cats-effect's runtime responds to
detected starvation by spinning up additional compensating worker/
blocker threads that are never reclaimed -- observed directly during
the NMB incident investigation as io-compute-blocker-* thread counts
climbing into the thousands, and traced independently via a heap dump
to hundreds of threads each holding their own private Scala-reflection
symbol-table cache in thread-local storage.

cats.effect.IO.blocking isn't the right tool here: MdcLoggable is mixed
into ~260 classes, many called from contexts with no IO runtime in
scope at all (actors, scheduled jobs, Lift-era code), so nothing would
ever run an IO value in those contexts. Dispatch onto a small dedicated
ExecutionContext instead -- works everywhere the trait itself is used,
mirrors the pattern RedisLogger already uses for its own async
shipping, and decouples the calling thread regardless of what kind of
thread it happens to be.

Writing MdcLoggableDispatchTest caught a second, unrelated real bug
before it shipped: the new executor was a plain val, but Helper's own
static initializer transitively touches other objects (APIUtil/
Constant) that log during their own initialization -- a call that can
re-enter Helper's initialization before a val at this point in the
object body would have run, throwing a NullPointerException on the
executor. Changed to lazy val, which computes on first real use instead
of at a fixed point in top-to-bottom initialization order.
@sonarqubecloud

Copy link
Copy Markdown

@hongwei1

Copy link
Copy Markdown
Owner Author

Superseded — squashed together with #103 and #104 into one combined commit, PR'd upstream directly: OpenBankProject#2920

@hongwei1 hongwei1 closed this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant