<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged crash-reporting</title>
<link>https://coderlegion.com/tag/crash-reporting</link>
<description>Powered by Question2Answer</description>
<item>
<title>Telemetry: Shared Analytics and Crash Reporting in Kotlin Multiplatform</title>
<link>https://coderlegion.com/30479/telemetry-shared-analytics-and-crash-reporting-in-kotlin-multiplatform</link>
<description>&lt;p&gt;In a modern endurance car, every sensor talks to the same box. Tyre pressure, brake temperature, fuel flow, throttle trace. They all end up in one data stream that goes to the pit wall over a single radio link. The engineer on the wall doesn't care which supplier made the brake sensor. He cares that the channel is called &lt;code&gt;BRAKE_TEMP_FL&lt;/code&gt; and that the number arrives every 100 milliseconds.&lt;/p&gt;
&lt;p&gt;Swap a sensor, the channel name stays. That's the whole point of the standard.&lt;/p&gt;
&lt;p&gt;Analytics and crash reporting in a KMP app should work the same way. The sensors are the SDKs, one per platform, made by different teams with different APIs. The channel names are yours.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-had-before&quot;&gt;What I had before&lt;/h3&gt;
&lt;p&gt;My first KMP app logged analytics twice. Once in Kotlin for Android, once in Swift for iOS. Same events, same names, in theory.&lt;/p&gt;
&lt;p&gt;In practice, the Android side sent &lt;code&gt;screen_view&lt;/code&gt; with a &lt;code&gt;screen_name&lt;/code&gt; parameter. The iOS side sent &lt;code&gt;screenView&lt;/code&gt; with &lt;code&gt;screenName&lt;/code&gt;. Nobody noticed for six weeks, until I tried to build a funnel in the dashboard and half the users were missing from it. The numbers weren't wrong. They were split across two event names that meant the same thing.&lt;/p&gt;
&lt;p&gt;That's the real cost of leaving telemetry in the platform layer. Not the duplicated code, which is small. The drift. Two implementations of the same contract, with no compiler checking that they agree.&lt;/p&gt;
&lt;p&gt;If you read &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/feature-flags-kmp&quot;&gt;Change the Map&lt;/a&gt;, this is the same move applied to a different problem: the contract lives in &lt;code&gt;commonMain&lt;/code&gt;, the SDKs sit underneath. It also pairs with flags directly, because the first event worth logging is which variant a user was actually shown.&lt;/p&gt;
&lt;p&gt;Crash reporting has a quieter version of the same problem. A Kotlin exception that crashes the Android app shows up in Crashlytics with a readable stack trace. The same exception on iOS arrives as a native crash with frames that look like &lt;code&gt;kfun:...&lt;/code&gt;, and the Kotlin type is buried in the message. I'll get to that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-interface-lives-in-commonmain&quot;&gt;The interface lives in commonMain&lt;/h3&gt;
&lt;p&gt;The fix is to stop treating analytics as a platform feature and start treating it as a domain contract.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
interface Analytics {
    fun track(event: AnalyticsEvent)
    fun setUserId(id: String?)
}

sealed interface AnalyticsEvent {
    val name: String
    val params: Map&amp;lt;String, String&amp;gt;

    data class ScreenView(val screen: String) : AnalyticsEvent {
        override val name = &quot;screen_view&quot;
        override val params = mapOf(&quot;screen_name&quot; to screen)
    }

    data class Purchase(val sku: String, val amountCents: Long) : AnalyticsEvent {
        override val name = &quot;purchase&quot;
        override val params = mapOf(
            &quot;sku&quot; to sku,
            &quot;amount_cents&quot; to amountCents.toString(),
        )
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A sealed interface for events is the part that pays for itself. The event name and the parameter keys exist in exactly one place. A new event is a new class, and the compiler tells every &lt;code&gt;when&lt;/code&gt; that cares about it. A typo in &lt;code&gt;&quot;screen_name&quot;&lt;/code&gt; is no longer possible in two files, because it's only written once.&lt;/p&gt;
&lt;p&gt;I keep params as &lt;code&gt;Map&amp;lt;String, String&amp;gt;&lt;/code&gt; on purpose. Firebase, Mixpanel, and Amplitude all accept richer types, but they disagree about which ones. Strings are the lowest common denominator, and the conversion cost is trivial.&lt;/p&gt;
&lt;p&gt;The crash side is a second, separate interface.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
interface CrashReporter {
    fun log(message: String)
    fun recordNonFatal(throwable: Throwable)
    fun setUserId(id: String?)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two interfaces, not one. Analytics is about what users did. Crash reporting is about what broke. They often go to different vendors, and you'll want to swap one without touching the other.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;android-the-easy-side&quot;&gt;Android: the easy side&lt;/h3&gt;
&lt;p&gt;On Android the implementation is a thin wrapper. Firebase is the obvious choice, though nothing here is Firebase-specific.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// androidMain
class FirebaseAnalyticsImpl(
    private val firebase: FirebaseAnalytics,
) : Analytics {

    override fun track(event: AnalyticsEvent) {
        val bundle = Bundle().apply {
            event.params.forEach { (k, v) -&amp;gt; putString(k, v) }
        }
        firebase.logEvent(event.name, bundle)
    }

    override fun setUserId(id: String?) {
        firebase.setUserId(id)
    }
}

class CrashlyticsReporter(
    private val crashlytics: FirebaseCrashlytics,
) : CrashReporter {

    override fun log(message: String) = crashlytics.log(message)

    override fun recordNonFatal(throwable: Throwable) =
        crashlytics.recordException(throwable)

    override fun setUserId(id: String?) {
        crashlytics.setUserId(id.orEmpty())
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing clever. If the Android side is the only one you ever shipped, this is the code you already had.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ios-the-swift-bridge&quot;&gt;iOS: the Swift bridge&lt;/h3&gt;
&lt;p&gt;The simplest iOS implementation doesn't call the Firebase iOS SDK from Kotlin at all. No &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; in this layer, no cinterop bindings, no CocoaPods dependency in the shared module.&lt;/p&gt;
&lt;p&gt;The shared module defines the interface, and the iOS app passes in a Swift implementation at startup.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// iosApp
final class FirebaseAnalyticsBridge: Analytics {

    func track(event: AnalyticsEvent) {
        FirebaseAnalytics.Analytics.logEvent(event.name, parameters: event.params)
    }

    func setUserId(id: String?) {
        FirebaseAnalytics.Analytics.setUserID(id)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swift sees the Kotlin interface as a protocol, so conforming to it is ordinary Swift. The one snag is the name: Firebase's Swift class is also called &lt;code&gt;Analytics&lt;/code&gt;, so inside this file the bare name is ambiguous. I qualify the Firebase one with its module. Renaming the Kotlin interface would also work, but I'd rather not let a vendor pick my names. The app registers it with the DI container before the first screen renders.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/Telemetry.kt
fun setUpTelemetry(analytics: Analytics, crashReporter: CrashReporter) {
    // register both in your DI graph of choice
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// iosApp
@main
struct IosApp: App {
    init() {
        TelemetryKt.setUpTelemetry(
            analytics: FirebaseAnalyticsBridge(),
            crashReporter: CrashlyticsBridge()
        )
    }
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I don't start that function name with &lt;code&gt;init&lt;/code&gt;. The Objective-C export reserves &lt;code&gt;init&lt;/code&gt; for initializers, so a Kotlin function called &lt;code&gt;initTelemetry&lt;/code&gt; shows up in Swift as &lt;code&gt;doInitTelemetry&lt;/code&gt;. It works, but it's the kind of thing that costs ten minutes the first time you see it.&lt;/p&gt;
&lt;p&gt;The Firebase SDK stays where Xcode manages it. The shared module never learns it exists.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;two-other-ways-to-bring-the-sdk-in&quot;&gt;Two other ways to bring the SDK in&lt;/h3&gt;
&lt;p&gt;That Swift bridge is how I wrote this in my first KMP apps, and it's the version I'd explain to anyone starting out because every moving part is visible. It isn't what I use today.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitLive's Firebase Kotlin SDK.&lt;/strong&gt; &lt;code&gt;firebase-kotlin-sdk&lt;/code&gt; from GitLive wraps Firebase so you can call it from &lt;code&gt;commonMain&lt;/code&gt;. I haven't shipped it myself. If you use it, your &lt;code&gt;Analytics&lt;/code&gt; interface still sits in front of it, otherwise you've just moved the vendor lock-in into shared code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CocoaPods through Gradle, with the call in &lt;code&gt;iosMain&lt;/code&gt;.&lt;/strong&gt; This is what I use now. The Kotlin CocoaPods plugin lets the shared module declare the Firebase pod itself, and Kotlin generates the bindings so &lt;code&gt;iosMain&lt;/code&gt; can call the iOS SDK directly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.gradle.kts (shared module)
plugins {
    kotlin(&quot;multiplatform&quot;)
    kotlin(&quot;native.cocoapods&quot;)
}

kotlin {
    cocoapods {
        version = &quot;1.0&quot;
        ios.deploymentTarget = &quot;16.0&quot;
        pod(&quot;FirebaseAnalytics&quot;)
        pod(&quot;FirebaseCrashlytics&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can pin a version with &lt;code&gt;pod(&quot;FirebaseAnalytics&quot;, &quot;~&amp;gt; 12.0&quot;)&lt;/code&gt;, and &lt;code&gt;linkOnly = true&lt;/code&gt; exists for pods you only need to link and not bind. The Kotlin docs cover both.&lt;/p&gt;
&lt;p&gt;Then the iOS implementation is Kotlin, in the same module as the interface.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// iosMain
import cocoapods.FirebaseAnalytics.FIRAnalytics
import kotlinx.cinterop.ExperimentalForeignApi

@OptIn(ExperimentalForeignApi::class)
class IosAnalytics : Analytics {

    @Suppress(&quot;UNCHECKED_CAST&quot;)
    override fun track(event: AnalyticsEvent) {
        FIRAnalytics.logEventWithName(event.name, event.params as Map&amp;lt;Any?, *&amp;gt;)
    }

    override fun setUserId(id: String?) {
        FIRAnalytics.setUserID(id)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bindings come from the Objective-C headers, not the Swift API, so the class is &lt;code&gt;FIRAnalytics&lt;/code&gt; and the method is &lt;code&gt;logEventWithName&lt;/code&gt;. The &lt;code&gt;parameters&lt;/code&gt; dictionary arrives as &lt;code&gt;Map&amp;lt;Any?, *&amp;gt;?&lt;/code&gt;. A &lt;code&gt;Map&amp;lt;String, String&amp;gt;&lt;/code&gt; doesn't fit that type as-is, because map keys are invariant in Kotlin, hence the cast.&lt;/p&gt;
&lt;p&gt;I switched because the Swift bridge had a cost I only noticed over time: two files per interface, in two languages, in two repos' worth of mental context. With the pod declared in Gradle, the iOS implementation sits next to the Android one, in Kotlin, and a new method on &lt;code&gt;Analytics&lt;/code&gt; fails the build in the same module.&lt;/p&gt;
&lt;p&gt;It has its own cost. Pod dependencies and cinterop bindings are now part of your shared module's build, so Gradle sync and iOS builds feel it, and a Firebase SDK bump can break the generated bindings. It's also the option with an expiry date: the Kotlin docs now carry a CocoaPods-to-SwiftPM migration guide, and the new &lt;code&gt;swiftPMDependencies { }&lt;/code&gt; block replaces &lt;code&gt;cocoapods { }&lt;/code&gt;. The docs I read put the minimum Kotlin Gradle plugin at 2.4.20-RC3 for it, so check your version before planning the move. That's the trade I accept for keeping everything telemetry-related in one language.&lt;/p&gt;
&lt;p&gt;I'm showing three options on purpose, and Firebase is only the example. A KMP project has more than one way to bring a native dependency in: a bridge the app injects, a KMP wrapper library, or a native dependency declared in Gradle. Which one fits depends on how much of the native SDK surface you need, and who maintains the glue.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;kotlin-exceptions-on-ios-are-not-kotlin-exceptions&quot;&gt;Kotlin exceptions on iOS are not Kotlin exceptions&lt;/h3&gt;
&lt;p&gt;This is the part nobody mentions until you're staring at a dashboard.&lt;/p&gt;
&lt;p&gt;When an uncaught Kotlin exception terminates the app on iOS, the crash is reported by the native crash handler. The stack trace is the native one. The Kotlin frames are there, but unsymbolicated Kotlin frames read as noise, and the exception type is flattened. Touchlab's CrashKiOS exists for exactly this: it sends symbolicated Kotlin crashes and handled exceptions to Crashlytics, with breadcrumbs and custom keys. Its docs page was last updated in January 2024, so check the repo for recent activity before you rely on it.&lt;/p&gt;
&lt;p&gt;For non-fatal errors you have a better option. Catch the exception at a boundary you control, and hand it to &lt;code&gt;recordNonFatal&lt;/code&gt;. On iOS that means converting it to an &lt;code&gt;NSError&lt;/code&gt;, and that's where it gets fiddly.&lt;/p&gt;
&lt;p&gt;Swift can't get much out of a &lt;code&gt;KotlinThrowable&lt;/code&gt; by itself. The Kotlin stack trace accessor on Native, &lt;code&gt;getStackTrace()&lt;/code&gt;, is a stdlib extension marked &lt;code&gt;@ExperimentalNativeApi&lt;/code&gt;, so I don't count on it being reachable from Swift. I let Kotlin do the work instead, with two small extensions in &lt;code&gt;iosMain&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// iosMain
fun Throwable.kotlinTypeName(): String =
    this::class.qualifiedName ?: &quot;UnknownKotlinThrowable&quot;

fun Throwable.kotlinStackTrace(): String = stackTraceToString()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They extend a Kotlin class, so the export turns them into methods on &lt;code&gt;KotlinThrowable&lt;/code&gt; and Swift calls them like any other method.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// iosApp
final class CrashlyticsBridge: CrashReporter {

    func recordNonFatal(throwable: KotlinThrowable) {
        let error = NSError(
            domain: throwable.kotlinTypeName(),
            code: 0,
            userInfo: [
                NSLocalizedDescriptionKey: throwable.message ?? &quot;unknown&quot;,
                &quot;kotlin_stacktrace&quot;: throwable.kotlinStackTrace(),
            ]
        )
        Crashlytics.crashlytics().record(error: error)
    }

    func log(message: String) {
        Crashlytics.crashlytics().log(message)
    }

    func setUserId(id: String?) {
        Crashlytics.crashlytics().setUserID(id ?? &quot;&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Crashlytics turns &lt;code&gt;userInfo&lt;/code&gt; into key-value pairs in the keys section of the issue, so the full Kotlin trace sits right there. I put it there on purpose rather than hoping the dashboard parses the message, because it won't.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;domain&lt;/code&gt; choice matters more than it looks. For fatal crashes Crashlytics groups by stack trace, but the Firebase docs say logged errors are grouped by &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;code&lt;/code&gt;. A fixed domain like &lt;code&gt;&quot;kotlin&quot;&lt;/code&gt; with code &lt;code&gt;0&lt;/code&gt; would collapse every non-fatal into one issue. With the Kotlin type there, an &lt;code&gt;IllegalStateException&lt;/code&gt; and a network timeout stay separate. Keep unique values like user IDs or timestamps out of both fields, or you'll fragment the dashboard the other way. Crashlytics also only stores the most recent eight exceptions in a session, so &lt;code&gt;recordNonFatal&lt;/code&gt; is not a log.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;where-the-calls-go&quot;&gt;Where the calls go&lt;/h3&gt;
&lt;p&gt;The interface is only worth having if people use it in the same place. I let ViewModels own analytics events and nothing else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
class CheckoutViewModel(
    private val analytics: Analytics,
    private val crashReporter: CrashReporter,
    private val repository: CheckoutRepository,
) : ViewModel() {

    fun onPay(sku: String, amountCents: Long) {
        viewModelScope.launch {
            runCatching { repository.pay(sku, amountCents) }
                .onSuccess {
                    analytics.track(AnalyticsEvent.Purchase(sku, amountCents))
                }
                .onFailure {
                    if (it is CancellationException) throw it
                    crashReporter.recordNonFatal(it)
                }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;CancellationException&lt;/code&gt; check is there because &lt;code&gt;runCatching&lt;/code&gt; catches it too. Without it, every user who leaves the screen mid-payment becomes a non-fatal in Crashlytics.&lt;/p&gt;
&lt;p&gt;Composables and SwiftUI views don't call &lt;code&gt;track&lt;/code&gt;. Screen views are the exception, and I fire them from a single navigation observer, so there's one line of code that produces every &lt;code&gt;screen_view&lt;/code&gt; in the app.&lt;/p&gt;
&lt;p&gt;The other benefit shows up in tests. A &lt;code&gt;FakeAnalytics&lt;/code&gt; that stores events in a list takes ten lines, and asserting on it in &lt;code&gt;commonTest&lt;/code&gt; is trivial. I used to skip testing analytics because it meant mocking an SDK. Now I assert that tapping Pay produces exactly one &lt;code&gt;Purchase&lt;/code&gt; with the right amount, and it runs on every target.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;gotchas&quot;&gt;Gotchas&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Don't init the SDKs inside commonMain, whichever option you pick.&lt;/strong&gt; Firebase on Android wants a &lt;code&gt;Context&lt;/code&gt;. On iOS it wants &lt;code&gt;FirebaseApp.configure()&lt;/code&gt; before anything else touches it. Those calls belong in &lt;code&gt;Application.onCreate&lt;/code&gt; and the iOS &lt;code&gt;App.init&lt;/code&gt;, not behind an abstraction that pretends the platforms are the same.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;User IDs are a privacy decision, not a plumbing one.&lt;/strong&gt; &lt;code&gt;setUserId&lt;/code&gt; is easy to wire and easy to get wrong. I pass an internal opaque ID and never an email. Deciding that once, in the interface docs, beats hoping every caller remembers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consent has to be a gate, not a flag.&lt;/strong&gt; If your users can opt out, wrap the implementation, not the call sites. A &lt;code&gt;ConsentAwareAnalytics&lt;/code&gt; decorator that drops events when consent is off keeps the rule in one class. Crash reporting is different. &lt;code&gt;setCrashlyticsCollectionEnabled&lt;/code&gt; can be called at runtime, but the Firebase docs say the override applies from the next launch, so a user who opts out mid-session is still reporting until the app restarts. Say that in your consent screen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A debug build should not talk to production.&lt;/strong&gt; I give the debug variant a &lt;code&gt;LoggingAnalytics&lt;/code&gt; that prints to the console. It's another implementation of the same interface, which is the quiet argument for having the interface at all.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Treat telemetry as a contract you own and a set of sensors you rent. The event names, the parameter keys, and the rule for what counts as a non-fatal live in &lt;code&gt;commonMain&lt;/code&gt;, in code your compiler can check. The SDKs sit underneath, one per platform, and nobody outside the platform layer should know which ones.&lt;/p&gt;
&lt;p&gt;I would still start with the interface even if the app only ships on one platform today. Adding iOS later then costs one Swift file instead of an audit of every event.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Same channel names, whatever the sensor. 🏁&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The KMP Bits app is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/pt/app/bitsreader/id6755438235&quot;&gt;App Store&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.joel.bitsreader&quot;&gt;Google Play&lt;/a&gt; — built entirely with KMP.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30479/telemetry-shared-analytics-and-crash-reporting-in-kotlin-multiplatform</guid>
<pubDate>Fri, 09 Oct 2026 14:00:00 +0000</pubDate>
</item>
</channel>
</rss>