Skip to content
Go back

Telemetry: Shared Analytics and Crash Reporting in Kotlin Multiplatform

by KMP Bits

KMP Bits Cover

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 BRAKE_TEMP_FL and that the number arrives every 100 milliseconds.

Swap a sensor, the channel name stays. That’s the whole point of the standard.

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.


What I had before

My first KMP app logged analytics twice. Once in Kotlin for Android, once in Swift for iOS. Same events, same names, in theory.

In practice, the Android side sent screen_view with a screen_name parameter. The iOS side sent screenView with screenName. 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.

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.

If you read Change the Map, this is the same move applied to a different problem: the contract lives in commonMain, the SDKs sit underneath. It also pairs with flags directly, because the first event worth logging is which variant a user was actually shown.

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 kfun:..., and the Kotlin type is buried in the message. I’ll get to that.


The interface lives in commonMain

The fix is to stop treating analytics as a platform feature and start treating it as a domain contract.

// commonMain
interface Analytics {
    fun track(event: AnalyticsEvent)
    fun setUserId(id: String?)
}

sealed interface AnalyticsEvent {
    val name: String
    val params: Map<String, String>

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

    data class Purchase(val sku: String, val amountCents: Long) : AnalyticsEvent {
        override val name = "purchase"
        override val params = mapOf(
            "sku" to sku,
            "amount_cents" to amountCents.toString(),
        )
    }
}

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 when that cares about it. A typo in "screen_name" is no longer possible in two files, because it’s only written once.

I keep params as Map<String, String> 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.

The crash side is a second, separate interface.

// commonMain
interface CrashReporter {
    fun log(message: String)
    fun recordNonFatal(throwable: Throwable)
    fun setUserId(id: String?)
}

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.


Android: the easy side

On Android the implementation is a thin wrapper. Firebase is the obvious choice, though nothing here is Firebase-specific.

// androidMain
class FirebaseAnalyticsImpl(
    private val firebase: FirebaseAnalytics,
) : Analytics {

    override fun track(event: AnalyticsEvent) {
        val bundle = Bundle().apply {
            event.params.forEach { (k, v) -> 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())
    }
}

Nothing clever. If the Android side is the only one you ever shipped, this is the code you already had.


iOS: the Swift bridge

The simplest iOS implementation doesn’t call the Firebase iOS SDK from Kotlin at all. No expect/actual in this layer, no cinterop bindings, no CocoaPods dependency in the shared module.

The shared module defines the interface, and the iOS app passes in a Swift implementation at startup.

// 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)
    }
}

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 Analytics, 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.

// commonMain/Telemetry.kt
fun setUpTelemetry(analytics: Analytics, crashReporter: CrashReporter) {
    // register both in your DI graph of choice
}
// iosApp
@main
struct IosApp: App {
    init() {
        TelemetryKt.setUpTelemetry(
            analytics: FirebaseAnalyticsBridge(),
            crashReporter: CrashlyticsBridge()
        )
    }
    // ...
}

I don’t start that function name with init. The Objective-C export reserves init for initializers, so a Kotlin function called initTelemetry shows up in Swift as doInitTelemetry. It works, but it’s the kind of thing that costs ten minutes the first time you see it.

The Firebase SDK stays where Xcode manages it. The shared module never learns it exists.


Two other ways to bring the SDK in

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.

GitLive’s Firebase Kotlin SDK. firebase-kotlin-sdk from GitLive wraps Firebase so you can call it from commonMain. I haven’t shipped it myself. If you use it, your Analytics interface still sits in front of it, otherwise you’ve just moved the vendor lock-in into shared code.

CocoaPods through Gradle, with the call in iosMain. 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 iosMain can call the iOS SDK directly.

// build.gradle.kts (shared module)
plugins {
    kotlin("multiplatform")
    kotlin("native.cocoapods")
}

kotlin {
    cocoapods {
        version = "1.0"
        ios.deploymentTarget = "16.0"
        pod("FirebaseAnalytics")
        pod("FirebaseCrashlytics")
    }
}

You can pin a version with pod("FirebaseAnalytics", "~> 12.0"), and linkOnly = true exists for pods you only need to link and not bind. The Kotlin docs cover both.

Then the iOS implementation is Kotlin, in the same module as the interface.

// iosMain
import cocoapods.FirebaseAnalytics.FIRAnalytics
import kotlinx.cinterop.ExperimentalForeignApi

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

    @Suppress("UNCHECKED_CAST")
    override fun track(event: AnalyticsEvent) {
        FIRAnalytics.logEventWithName(event.name, event.params as Map<Any?, *>)
    }

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

The bindings come from the Objective-C headers, not the Swift API, so the class is FIRAnalytics and the method is logEventWithName. The parameters dictionary arrives as Map<Any?, *>?. A Map<String, String> doesn’t fit that type as-is, because map keys are invariant in Kotlin, hence the cast.

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 Analytics fails the build in the same module.

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 swiftPMDependencies { } block replaces cocoapods { }. 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.

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.


Kotlin exceptions on iOS are not Kotlin exceptions

This is the part nobody mentions until you’re staring at a dashboard.

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.

For non-fatal errors you have a better option. Catch the exception at a boundary you control, and hand it to recordNonFatal. On iOS that means converting it to an NSError, and that’s where it gets fiddly.

Swift can’t get much out of a KotlinThrowable by itself. The Kotlin stack trace accessor on Native, getStackTrace(), is a stdlib extension marked @ExperimentalNativeApi, so I don’t count on it being reachable from Swift. I let Kotlin do the work instead, with two small extensions in iosMain.

// iosMain
fun Throwable.kotlinTypeName(): String =
    this::class.qualifiedName ?: "UnknownKotlinThrowable"

fun Throwable.kotlinStackTrace(): String = stackTraceToString()

They extend a Kotlin class, so the export turns them into methods on KotlinThrowable and Swift calls them like any other method.

// iosApp
final class CrashlyticsBridge: CrashReporter {

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

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

    func setUserId(id: String?) {
        Crashlytics.crashlytics().setUserID(id ?? "")
    }
}

Crashlytics turns userInfo 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.

The domain choice matters more than it looks. For fatal crashes Crashlytics groups by stack trace, but the Firebase docs say logged errors are grouped by domain and code. A fixed domain like "kotlin" with code 0 would collapse every non-fatal into one issue. With the Kotlin type there, an IllegalStateException 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 recordNonFatal is not a log.


Where the calls go

The interface is only worth having if people use it in the same place. I let ViewModels own analytics events and nothing else.

// 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)
                }
        }
    }
}

The CancellationException check is there because runCatching catches it too. Without it, every user who leaves the screen mid-payment becomes a non-fatal in Crashlytics.

Composables and SwiftUI views don’t call track. Screen views are the exception, and I fire them from a single navigation observer, so there’s one line of code that produces every screen_view in the app.

The other benefit shows up in tests. A FakeAnalytics that stores events in a list takes ten lines, and asserting on it in commonTest is trivial. I used to skip testing analytics because it meant mocking an SDK. Now I assert that tapping Pay produces exactly one Purchase with the right amount, and it runs on every target.


Gotchas

Don’t init the SDKs inside commonMain, whichever option you pick. Firebase on Android wants a Context. On iOS it wants FirebaseApp.configure() before anything else touches it. Those calls belong in Application.onCreate and the iOS App.init, not behind an abstraction that pretends the platforms are the same.

User IDs are a privacy decision, not a plumbing one. setUserId 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.

Consent has to be a gate, not a flag. If your users can opt out, wrap the implementation, not the call sites. A ConsentAwareAnalytics decorator that drops events when consent is off keeps the rule in one class. Crash reporting is different. setCrashlyticsCollectionEnabled 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.

A debug build should not talk to production. I give the debug variant a LoggingAnalytics that prints to the console. It’s another implementation of the same interface, which is the quiet argument for having the interface at all.


Wrapping up

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 commonMain, in code your compiler can check. The SDKs sit underneath, one per platform, and nobody outside the platform layer should know which ones.

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.

Same channel names, whatever the sensor. 🏁


The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Minimum Weight: Code Coverage Gates in Kotlin Multiplatform with Kover