There is no Logcat in commonMain, and println is not a logger
Five targets, five sinks, and one of them discards your output silently.
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.

printlnis 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.
Related
- One failed child cancelled the whole screen, and the scope was the reason — why that catch re-throws CancellationException
- expect/actual is not an interface, and the difference shows up at link time — the seam pattern this uses
- Wasm has no threads, and that decides your dispatcher — why a synchronous console call dominates a loop
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".