Four crash reporters, one shared module, and the traces nobody could read
The reporting SDK is platform code and the crash is in shared code.
TL;DR
- A
NullPointerExceptionin shared code produced a crash report whose stack was nine unsymbolicated addresses on iOS, a readable trace on Android, and nothing at all on the web. The same defect, three different levels of diagnosability. - The reporting SDK is platform code. The crash is in shared code. Symbolication needs a mapping file that Gradle produces and that nobody was uploading for three of four targets.
- Four changes, and the one that found the most was not a symbol upload — it was attaching the shared module's own version to every report so the traces could be grouped at all.
- What reading on buys: what each platform needs uploaded, why
--stacktraceis not the same as a symbolicated frame, and the gate that fails a release missing its mapping.
The case this post is about
A shared parser with a crash reporter wired per platform:
// commonMain/kotlin/parse/FeedParser.kt
fun parse(raw: String): List<Item> =
json.decodeFromString<FeedResponse>(raw).items.map { it.toItem() }
// ↑ items is nullable in the schema
// commonMain/kotlin/observability/CrashReporter.kt
expect object CrashReporter {
fun recordException(t: Throwable)
fun setKey(name: String, value: String)
}
// androidMain
actual object CrashReporter {
actual fun recordException(t: Throwable) = Firebase.crashlytics.recordException(t)
actual fun setKey(name: String, value: String) = Firebase.crashlytics.setCustomKey(name, value)
}
// iosMain → the same Crashlytics SDK via its Obj-C interface
// desktopMain → Sentry
// wasmJsMain → Sentry's browser SDK
Four SDKs, one shared interface, and a crash in parse when the server returned "items": null.
The symptom
The same defect, reported three ways and absent once:
Android — readable:
Fatal Exception: java.lang.NullPointerException
at com.example.shared.parse.FeedParser.parse(FeedParser.kt:14)
at com.example.shared.feed.FeedRepository$loadFeed$1.invokeSuspend(FeedRepository.kt:28)
…
iOS — nine addresses:
Fatal Exception: KotlinNullPointerException
0 Shared 0x00000001045a2f14 0x1045a0000 + 12052
1 Shared 0x00000001045a1b88 0x1045a0000 + 7048
2 Shared 0x00000001045b0210 0x1045a0000 + 66064
…
Desktop — readable, because the JVM. Web — nothing, because the exception was thrown inside
a coroutine whose scope had no handler and Sentry's browser SDK only captures
window.onerror and unhandled rejections, which this was not.
So the one platform where the crash was most frequent was the one where the report was least useful, and the first three days of the investigation were spent on Android — where the trace was readable and the crash was rare, because Android's users had a different server region.

Why the obvious fix failed
The first attempt was --stacktrace on the Kotlin/Native build, which is not what that does:
# gradle.properties
kotlin.native.binary.sourceInfoType=libbacktrace
That attaches source information to runtime stack traces — e.stackTraceToString() becomes
readable inside the app. It does nothing for a crash report, because the report is generated by
the OS or the SDK from a crashed process, and what it captures is addresses. The two are different
mechanisms and the setting is genuinely useful for the first one.
The second attempt was to upload the .dSYM from the Xcode archive, which is correct for Swift
frames and insufficient here:
$ ls iosApp.xcarchive/dSYMs/
iosApp.app.dSYM # ← uploaded
# Shared.framework.dSYM — absent
The archive contains a dSYM for the app and not for the Kotlin framework, because the framework
was built by Gradle outside Xcode's own compilation. Its dSYM is in shared/build/bin/ and nothing
was collecting it.
The third attempt was to upload it by hand, once, which worked for that build and established the real problem: the dSYM must match the exact binary that shipped, and a rebuild produces a different UUID. One manual upload fixes one release and silently stops working at the next.
The actual mechanism
Symbolication is a lookup from an address to a source location, and the table is produced at build time by whatever compiled the code.
Android needs a ProGuard/R8 mapping file. Shared Kotlin is compiled to the same dex as
everything else and minified with it, so one mapping.txt covers app and shared code. The
Crashlytics Gradle plugin uploads it automatically, which is why Android worked and why nobody had
thought about the mechanism.
iOS needs a dSYM per binary, keyed by UUID. The app's dSYM maps app addresses; the Kotlin
framework is a separate binary with its own UUID and its own dSYM, produced by the Kotlin/Native
linker into shared/build/bin/iosArm64/releaseFramework/. Crashlytics matches by UUID, so an
unmatched binary's frames stay as addresses — with no error, because a report with some unresolved
frames is a normal report.
Desktop needs nothing, because JVM stack traces carry class and method names.
And the web needs a source map, plus a reporter that sees the error at all. The wasm binary's frames are meaningless without a map, and separately the SDK must be installed as a coroutine exception handler because an unhandled coroutine failure is not an unhandled promise rejection.
So the four targets need four different artifacts, three of which are produced by Gradle and discarded:
android mapping.txt uploaded by a plugin ✓
ios Shared.framework.dSYM in build/, uploaded by nobody
desktop — not needed
web *.wasm.map in build/, uploaded by nobody
And the version-grouping problem, which turned out to matter more. Every report carried the app version. The shared module has its own version, and a crash introduced by a shared change appears across several app versions on several platforms — so the reports did not group, and the dashboard showed five small unrelated issues rather than one with two thousand occurrences. That is why it looked rare on Android: it was not rare, it was split.

The fix
Upload the mapping artifact per target from one Gradle task, attach the shared module's version to every report, and gate the release on the upload having happened.
// build-logic/src/main/kotlin/CrashSymbolUpload.kt
//
// One task per target, because each needs a different artifact from a different directory.
// The gate below is the load-bearing half: a manual upload fixes one release and stops
// working at the next, because the dSYM UUID changes with every rebuild.
abstract class UploadCrashSymbols : DefaultTask() {
@get:InputFiles abstract val artifacts: ConfigurableFileCollection
@get:Input abstract val target: Property<String>
@get:Input abstract val sharedVersion: Property<String>
@get:Input abstract val buildId: Property<String>
// INJECTED rather than reached through `project`. Calling project.exec {} from a
// @TaskAction captures the Project at execution time, which makes the task
// configuration-cache incompatible — and the fix for that is not to disable the cache.
@get:Inject abstract val execOps: ExecOperations
@TaskAction
fun upload() {
val files = artifacts.files.filter { it.exists() }
// An EMPTY artifact set is the failure this exists to catch, and it is silent by
// default: Gradle produces the dSYM, nobody references it, and the upload step
// "succeeds" having sent nothing. Treating absence as success is how three targets
// shipped unsymbolicated for a year.
if (files.isEmpty()) {
throw GradleException(
"no crash-symbol artifacts found for ${target.get()}.\n" +
" expected one of: ${artifacts.files.joinToString { it.name }}\n" +
" these are produced by the link task — run it before this, and do not\n" +
" skip this task on a release: a missing mapping is nine hex addresses in\n" +
" every report from this build, with no error anywhere."
)
}
files.forEach { f ->
logger.lifecycle("uploading ${f.name} (${f.length() / 1024} KB) for ${target.get()}")
execOps.exec {
commandLine(
"crashlytics-upload", "--target", target.get(),
// The shared version travels WITH the symbols, so the backend can group
// a shared-code crash across app versions and platforms. Without it the
// same defect is five separate low-volume issues nobody prioritises.
// Resolved at CONFIGURATION time into buildId, because reading
// project.version here would reintroduce the Project capture above.
"--build-id", buildId.get(),
f.absolutePath,
)
}
}
}
}
And the reporter, with the version and the coroutine handler that the web target was missing:
// commonMain/kotlin/observability/CrashReporter.kt
expect object CrashReporter {
fun recordException(t: Throwable)
fun setKey(name: String, value: String)
}
/**
* Called ONCE at startup, before any scope exists. Separate from the handler below on purpose:
* the key is process-wide state and the handler is per-scope, and an earlier version did both
* in one function that every scope constructor called — so the key was re-set on each scope
* and, worse, was not set at all until the first scope was built.
*/
fun installCrashReporting(sharedVersion: String) {
CrashReporter.setKey("shared_version", sharedVersion)
}
/**
* The handler every failable scope needs. It is what makes a coroutine failure a report at all
* on the two targets where it otherwise is not.
*/
fun crashReportingHandler(): CoroutineExceptionHandler =
CoroutineExceptionHandler { ctx, t ->
// CancellationException is not a crash. Reporting it would bury real failures under
// normal scope teardown, which is the mistake that makes a crash dashboard useless.
if (t is CancellationException) return@CoroutineExceptionHandler
CrashReporter.setKey("coroutine", ctx[CoroutineName]?.name ?: "unnamed")
CrashReporter.recordException(t)
}
// once, at startup
installCrashReporting(BuildConfig.SHARED_VERSION)
// and then every scope that can fail, which is the part that is not automatic
private val scope = CoroutineScope(
SupervisorJob() + Dispatchers.Default + crashReportingHandler()
)
And the output it produced, the same crash after the next release:
# iOS, previously nine addresses
Fatal Exception: KotlinNullPointerException
0 Shared FeedParser.parse(FeedParser.kt:14)
1 Shared FeedRepository$loadFeed$1.invokeSuspend(FeedRepository.kt:28)
2 Shared SuspendFunctionKt.startCoroutineUninterceptedOrReturn(…)
…
# and the dashboard, grouped by shared_version
issue: KotlinNullPointerException · FeedParser.parse
occurrences 2,041 (was: five issues of 125–658, across 3 platforms)
platforms ios 1,702 · android 214 · web 125
shared 1.8.2 ← introduced here
How we proved it
That the dSYM was present in the build and absent from the upload — the measurement that located the gap:
$ find shared/build -maxdepth 6 -name '*.dSYM'
shared/build/bin/iosArm64/releaseFramework/Shared.framework.dSYM
$ # and what the release pipeline uploaded
$ grep -c 'Shared.framework.dSYM' .github/workflows/release.yml
0
Produced on every release build for a year and referenced nowhere.
That the UUID must match the shipped binary, which is why one manual upload was not a fix:
$ dwarfdump --uuid shared/build/.../Shared.framework.dSYM
UUID: 4F2A1C9E-… (arm64)
$ # rebuild, nothing changed in the source
$ dwarfdump --uuid shared/build/.../Shared.framework.dSYM
UUID: B9041EE7-… (arm64)
A different UUID from an identical source tree, so a stored dSYM matches exactly one build.
That sourceInfoType=libbacktrace does not help a crash report. Both mechanisms measured
separately, because the setting is genuinely useful and for the other one:
e.stackTraceToString() crash report frames
default addresses addresses
sourceInfoType=libbacktrace file:line addresses ← unchanged
+ dSYM uploaded file:line file:line
That the grouping was hiding the volume. The number that redirected the investigation:
before, grouped by app version
ios 1.4.0 KotlinNullPointerException 412
ios 1.4.1 KotlinNullPointerException 658
ios 1.4.2 KotlinNullPointerException 632
android 1.4.1 NullPointerException 214
web 125 (4 issues, none top-20)
after, grouped by shared_version
shared 1.8.2 KotlinNullPointerException 2,041 (the top issue)
Same crashes, same window. Nothing had been fixed at this point — only the grouping key had changed, and the issue went from invisible to first.
That the web target was not reporting at all, and why the SDK's defaults do not cover it:
failure shape captured by Sentry browser SDK
throw inside window.onerror reach yes
an unhandled promise rejection yes
a coroutine failure in a SupervisorJob scope NO ← ours
the same, with CoroutineExceptionHandler yes
A coroutine that fails in a scope with a SupervisorJob and no handler logs and does not surface
as either browser-level event.
That the gate catches a skipped upload. The fixture is a release task graph with the symbol task excluded, which is how it would regress:
$ ./gradlew :shared:assembleRelease -x uploadCrashSymbolsIos
$ ./gradlew :shared:verifyReleaseSymbols
FAILURE: release 1.9.0 has no uploaded symbols for target ios.
nine hex addresses in every report from this build, with no error anywhere.
What I cannot tell you is the web source-map story in full. We attach the map and the wasm frames are partially resolved — Kotlin function names appear, line numbers often do not — and I have not characterised when. It is better than addresses and it is not the Android experience, and I would not claim otherwise.
And an honest limitation. recordException is a non-fatal report on three of the four SDKs. A
genuine process-terminating crash on iOS is captured by the SDK's own signal handler, which does
not run our handler and therefore does not attach the shared_version key — so the grouping
improvement applies to recorded exceptions and not to hard crashes. Fixing that means setting the
key at startup rather than at report time, which we now do, and it is still absent for a crash
before startup completes.

What this generalizes to
Observability for shared code is assembled per platform, and the assembly is easy to miss because a report with unresolved frames is a valid report — nothing fails. The useful check is to ask, for each target, which artifact turns an address into a line and who uploads it; three of ours were being produced on every build and collected by nobody. And before improving the resolution of individual reports, check how they are grouped: a defect split across four low-volume issues is one nobody prioritises, and the grouping key is cheaper to fix than the symbolication.
Related
- A Kotlin crash on iOS arrives without a usable stack trace — the dSYM half in detail, including the UUID matching.
- The shared analytics event that meant something different on each platform — the same four-SDK problem for events rather than crashes.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".