Engineering

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.

MobileByteSensei 11 min read
kmpobservabilityrelease

TL;DR

  • A NullPointerException in 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 --stacktrace is 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.

One defect reported three different ways and absent once, with a readable trace on Android, nine unsymbolicated addresses on iOS, and nothing at all on the web because a coroutine failure is neither a window error nor a rejected promise

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 artifact each target needs to turn an address into a source line, with three of the four produced by the build on every release and uploaded by nobody

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.

Three measurements: an identical source tree producing a different dSYM identifier on each rebuild, a build setting that fixes runtime traces and not crash reports, and a grouping key change taking one defect from four low-volume issues to the single top issue

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.


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".

Follow by RSS