Engineering

There is no Logcat in commonMain, and println is not a logger

Five targets, five sinks, and one of them discards your output silently.

MobileByteSensei 9 min read
kmpobservabilityarchitecture

TL;DR

android.util.Log does not exist in shared code, so the first thing everyone reaches for is println. It compiles on every target and goes somewhere different on each: Logcat on Android, stdout on JVM, the Xcode console on iOS (unstructured, unfilterable), the browser console on wasm — and on a release iOS build it is effectively gone.

The failure mode that costs you is not the inconsistency. It is that println has no level, no tag, and no off switch, so the debug line you left in a hot loop ships, and the error you actually needed was never written anywhere a crash reporter could find it.

A logging seam is about fifteen lines of expect/actual. Write it on day one.

The case this post is about

A sync layer with diagnostics added during development:

// commonMain
class SyncEngine(private val api: Api, private val db: Db) {

    suspend fun sync(items: List<Item>) {
        println("SyncEngine: starting sync of ${items.size} items")
        items.forEach { item ->
            println("SyncEngine: pushing ${item.id} payload=${item.payload}")
            try {
                api.push(item)
            } catch (e: Throwable) {
                println("SyncEngine: push failed for ${item.id}: $e")
            }
        }
        println("SyncEngine: done")
    }
}

Useful while building it. Shipped exactly as written, because println produced visible output in every environment anyone checked.

The symptom

Four problems, discovered in the worst possible order.

A support escalation with no logs. A user's sync had been failing for days. The catch block ran every time — and println on a release iOS build does not reach any collectable log, so there was nothing to retrieve and nothing in the crash reporter, because the exception was swallowed by the catch.

A performance regression on wasm. A 4,000-item sync, one console.log per item:

sync of 4,000 items
  with per-item println    11.4s
  with it removed           1.2s

Nine-tenths of the runtime was logging. Browser consoles are slow and synchronous, and the line was written for a 20-item test.

A PII leak in the logs. payload=${item.payload} printed user content into Logcat, where any app with log access on older Android versions could read it, and into the browser console where it sat in the user's own devtools.

And the one that made it unfixable incrementally: 340 println calls across the codebase, with no level attached, so there was no mechanical way to tell "delete this one" from "keep this and route it to the crash reporter".

Why the obvious fix failed

"Write an expect fun log(msg: String)." My first version, and it reproduced the original problem with extra steps. No level means no filtering, so I still could not turn off the hot-loop line without deleting it.

"Add a logging library." The right answer for many projects — and adopting one does not retroactively give 340 un-levelled calls a severity. The migration is the work; the library is the easy part.

"Use a BuildConfig.DEBUG check." BuildConfig is Android's. There is no equivalent in commonMain, so this does not compile where the calls are.

"Just remove all the printlns." Tempting after the wasm finding, and it throws away the diagnostics you need for the support case. The error line was the one piece of information that would have resolved the escalation; the fix is to route it somewhere, not to delete it.

The actual mechanism

println on Kotlin/Multiplatform compiles to each platform's standard output, and "standard output" means something different everywhere:

Android — routed into Logcat under System.out, so it appears, with no tag of your choosing and at INFO.

JVM/desktop — real stdout. Fine.

iOS — written to the process's stdout, visible in Xcode when attached. On a release build on a user's device, nothing is attached and nothing persists it. There is no retrieval path.

wasmJs — console.log, which is synchronous and slow enough to dominate a loop.

Three properties are missing, and each one maps to one of the symptoms:

No level. So nothing can be filtered, in or out, by configuration. The hot-loop line and the error line are indistinguishable to any tooling.

No sink abstraction. So nothing can be routed — an error cannot also go to the crash reporter, because println only knows one destination.

No build-time elimination. The string is interpolated before the call, so "pushing ${item.id} payload=${item.payload}" does its concatenation whether or not anyone will read it.

One println call in shared code fanning out to five sinks: Logcat at INFO with no tag, real stdout on desktop, the Xcode console only while attached, the browser console synchronously and slowly, and on a release iOS build no collectable destination at all — with no level, no routing and no elimination available at any of them

println is a sink, not a logger. A logger is a level, a tag, a lazily-built message, and a routing decision — and three of those four are what you need to operate software you cannot attach a debugger to.

The fix

A small seam with a level and a lazy message, platform actuals that use each platform's real facility, and a routing hook for errors.

// commonMain
enum class LogLevel { Verbose, Debug, Info, Warn, Error }

interface LogSink {
    fun log(level: LogLevel, tag: String, message: String, throwable: Throwable?)
}

object Log {
    var minLevel: LogLevel = LogLevel.Info
    private val sinks = mutableListOf<LogSink>(platformSink())

    fun add(sink: LogSink) { sinks += sink }

    // message is a lambda: nothing is interpolated unless the level passes.
    inline fun d(tag: String, message: () -> String) = at(LogLevel.Debug, tag, null, message)
    inline fun w(tag: String, message: () -> String) = at(LogLevel.Warn, tag, null, message)
    inline fun e(tag: String, t: Throwable? = null, message: () -> String) =
        at(LogLevel.Error, tag, t, message)

    inline fun at(level: LogLevel, tag: String, t: Throwable?, message: () -> String) {
        if (level.ordinal < minLevel.ordinal) return      // ← the hot-loop fix
        val m = message()
        sinks.forEach { it.log(level, tag, m, t) }
    }
}

expect fun platformSink(): LogSink
// androidMain — a real tag and a real level
actual fun platformSink(): LogSink = object : LogSink {
    override fun log(level: LogLevel, tag: String, message: String, throwable: Throwable?) {
        when (level) {
            LogLevel.Error -> android.util.Log.e(tag, message, throwable)
            LogLevel.Warn  -> android.util.Log.w(tag, message, throwable)
            else           -> android.util.Log.d(tag, message)
        }
    }
}

// iosMain — os_log, which PERSISTS and is retrievable from a device
actual fun platformSink(): LogSink = object : LogSink {
    override fun log(level: LogLevel, tag: String, message: String, throwable: Throwable?) {
        val line = if (throwable != null) "$message | $throwable" else message
        os_log_with_type(osLogFor(tag), osTypeFor(level), "%{public}s", line)
    }
}

The os_log actual is the one that resolves the support escalation. It is a real logging facility: levelled, persisted, and retrievable via a sysdiagnose from a user's device — which println is not, on any iOS build the user has.

Then the call sites say what they mean:

// commonMain
private const val TAG = "SyncEngine"

suspend fun sync(items: List<Item>) {
    Log.i(TAG) { "starting sync of ${items.size} items" }
    items.forEach { item ->
        Log.d(TAG) { "pushing ${item.id}" }           // no payload; filtered out in release
        try {
            api.push(item)
        } catch (e: CancellationException) {
            throw e
        } catch (e: Throwable) {
            Log.e(TAG, e) { "push failed for ${item.id}" }   // ← reaches the crash reporter
        }
    }
}

Three decisions:

The message is a lambda, and at is inline. At minLevel = Info, the debug line's string is never built — no concatenation, no console.log. That is the wasm fix, and it is a property of the signature rather than of discipline at the call site.

The payload is gone from the log entirely. Not redacted at the sink — never passed. Redaction at the sink is a filter you can forget to apply; not logging it is structural.

Errors fan out to a second sink:

// in app startup
Log.add(CrashReporterSink())      // Error-level entries become breadcrumbs
Log.minLevel = if (isDebugBuild()) LogLevel.Verbose else LogLevel.Info

How we proved it

Three claims, three measurements.

That the debug line costs nothing in release. A counting sink plus the wasm timing:

// commonTest
@Test
fun debugMessagesAreNotBuiltBelowMinLevel() {
    var built = 0
    Log.minLevel = LogLevel.Info
    Log.d("T") { built++; "expensive" }
    assertEquals(0, built)            // the lambda never ran

    Log.minLevel = LogLevel.Debug
    Log.d("T") { built++; "expensive" }
    assertEquals(1, built)
}
wasm sync of 4,000 items
  println per item        11.4s
  Log.d at minLevel=Info   1.3s     ← lambda not invoked
  Log.d at minLevel=Debug 11.9s     ← when you ASK for it, you pay for it

The third row matters: the fix is not "logging is now free", it is "logging is now a choice".

That iOS errors are retrievable. The check that println fails:

release build on device, trigger a push failure, then:
$ log collect --device --last 5m
$ log show --predicate 'subsystem == "com.example.sync"' --last 5m

before:   (no entries — println is not in the unified log)
after:    2026-10-16 14:22:08  SyncEngine  push failed for item-4471 | HttpException: 503

Running the actual retrieval command rather than trusting that os_log works is the step that turns this from a plausible fix into a verified one.

That no payload reaches any sink. A grep-style assertion, run in commonTest:

@Test
fun noSinkEverReceivesPayloadContent() = runTest {
    val captured = CapturingSink().also { Log.add(it) }
    Log.minLevel = LogLevel.Verbose                       // worst case
    SyncEngine(failingApi, db).sync(listOf(Item("i1", payload = "SECRET-TOKEN-XYZ")))
    assertTrue(captured.lines.none { it.contains("SECRET-TOKEN-XYZ") })
}

Asserting at the most verbose level is deliberate — a redaction that only holds in release is not a redaction.

One honest limit: 340 call sites were migrated with a mechanical replace to Log.d and then reviewed by hand to promote the ones that were really errors. I cannot claim every one landed at the right level; what I can claim is that the error paths in the sync layer were audited, and that the levels are now something a tool can reason about, which they were not before.

What this generalizes to

A convenience that compiles everywhere can still be wrong everywhere. println is the multiplatform-safe choice and the operationally useless one. "It works on all targets" is a compilation property, not a fitness-for-purpose one.

Pass a lambda when the cost is in building the argument. This is the general shape of cheap-when-disabled instrumentation, and it is why every serious logging API takes a supplier rather than a string. Eager evaluation at the call site is invisible until it is in a loop.

Design for the build you cannot attach to. The whole value of a logging facility is on a release build on someone else's device. Whatever your diagnostics do there is what they do; if the answer is "nothing", the development-time convenience was a trap.

Do not log what you do not want stored. Redaction at the sink is a filter you can forget; never passing the value is structural. The same reasoning applies to analytics events, crash breadcrumbs, and error messages sent to a server.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS