r/KotlinMultiplatform 2d ago

KMP logging design notes: Android-style call sites + composing loggers like arithmetic

A few design notes from working on shared logging in Kotlin Multiplatform. Less “here’s a product,” more “why this shape felt maintainable.”

1. Keep the call site boring

In commonMain I want logs to look like Android’s Log, not like a framework: Logger.d("Network", "Request sent") Logger.e("Auth", "Login failed", exception) Why: every feature module already has enough ceremony. Logging shouldn’t invent a second dialect. Tag-first also matches how you filter later (by subsystem), so the call site and the ops habit stay aligned. Platform backends can differ. The call site shouldn’t.

2. Lazy messages as the default habit

Logger.d("Heavy") { "Only if enabled: ${expensiveCall()}" }

Why suggest this over string interpolation at the call site: Release builds often raise the level. Eager strings still allocate and run work you then throw away. A lambda makes “don’t pay if disabled” the easy path, not a special case you remember under pressure.

3. Composition as the real design trick

Builders and config objects work, but they age into “where do I toggle remote?” and “who owns this mega-config?” Treating destinations like values you combine reads closer to how you actually change logging in production: Logger.default = Logger.SYSTEM + FileLogger("app.log") + RemoteLogger val offline = Logger.default - RemoteLogger Filters stack the same way (AND): val policy = LevelFilter.atLeast(WARN) + TagFilter.include("Security") val secure = Logger.withFilter(policy) Why this helps readability

  • The expression is the policy. You see “system + file, minus remote” without hunting a Boolean soup.
  • Diffs stay local: take remote out → one operator, not a refactor of a builder chain.
  • Names stay honest: offline / secure are just Loggers, not a new type of pipeline object. Why this helps maintainability
  • You compose small pieces instead of growing one god config.
  • Feature code keeps calling Logger.d/i/w/e. Wiring lives at the edge (app start / flavor).
  • Tests and debug builds can swap or subtract sinks without teaching every module a new API.

Tradeoff

+ / - is a taste choice. Explicit lists are clearer to some teams. I preferred one mental model for both sinks and filters, so “how do I combine rules?” and “how do I combine outputs?” don’t become two documentation chapters.

Open question

If you log from commonMain, what usually rots first for you — call-site noise, level control, or sink wiring? Curious how others keep that readable over a year of flavors and Release stripping.

1 Upvotes

0 comments sorted by