Aptabase Analytics for KMP/CMP Apps

Aptabase Analytics for KMP/CMP Apps

When I first started building Kotlin Multiplatform and Compose Multiplatform apps, I was surprised that there weren’t many “native” KMP analytic solutions in the market. Most required wrapping existing platform SDKs using expect/actual. Being a solo developer and needing just a pretty simple solution, I decided to go and build my own implementation on top of Aptabase.

I’d used Aptabase in a previous project and noticed that they had documentation on building your own version of the SDK if they didn’t have one for your platform. Here is a guide for how I implemented Aptabase in my CMP project. Let’s get started.

Disclosure: I’m not affiliated with Aptabase in any way. This guide is based purely on my own experience using their product and documentation.

1. Libs Versions and Gradle

First we will add Ktor and Kotlin serialization dependencies to our libs.versions.toml file. Also, I like to add an app version and an Android version code here. I like to use this as my single source of truth for the app version number that can be referenced across packages. This will be used later in the build config to fetch the version and build numbers on Android. I also have a pre-build script on iOS that will fetch this version number when building in Xcode cloud, but that’s beyond the scope of this guide.

[versions]
# App versioning
app = "1.0.0"
androidVersionCode = "1"

# Tooling
kotlin = "2.2.20"
agp = "8.12.3"

# Serialization + Networking
kotlinxSerialization = "1.9.0"
ktor = "3.3.3"


[libraries]
# Kotlinx Serialization
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }

# Ktor (KMP)
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }

# Platform engines
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }


[bundles]
ktor = [
    "ktor-client-core",
    "ktor-client-content-negotiation",
    "ktor-serialization-kotlinx-json"
]


[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }

kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

In the build.gradle.kts file we will setup the dependencies and enable buildConfig with custom fields for the Android app version information.

// shared/build.gradle.kts

plugins {
    //...
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    //...
    sourceSets {
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
        }
        commonMain.dependencies {
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.bundles.ktor)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}

android {
    //...
    buildFeatures{
        buildConfig = true
    }
    defaultConfig {
        buildConfigField("String", "VERSION_NAME", "\"${libs.versions.app.get()}\"")
        buildConfigField("String", "VERSION_CODE", "\"${libs.versions.androidVersionCode.get()}\"")
    }
}

2. Setup EnvironmentInfo

Now we'll set up our EnvironmentInfo data class and an expect/actual function to fetch this platform-specific implementation. I'll show you the Android and iOS implementations and you'll have to create similar setups if you are releasing your KMP app for other targets like desktop or web.

// commonMain

data class EnvironmentInfo(
    val isDebug: Boolean,
    val osName: String,
    val osVersion: String,
    val locale: String,
    val appVersion: String,
    val appBuildNumber: String,
    val deviceModel: String
) {
    companion object {
        fun get(): EnvironmentInfo = getEnvironmentInfo()
    }
}

expect fun getEnvironmentInfo(): EnvironmentInfo
// androidMain

import android.os.Build
import java.util.Locale

actual fun getEnvironmentInfo(): EnvironmentInfo {
    return EnvironmentInfo(
        isDebug = BuildConfig.DEBUG,
        osName = "Android",
        osVersion = Build.VERSION.RELEASE ?: Build.VERSION.SDK_INT.toString(),
        locale = Locale.getDefault().toLanguageTag(),
        appVersion = BuildConfig.VERSION_NAME,
        appBuildNumber = BuildConfig.VERSION_CODE,
        deviceModel = Build.MODEL ?: "unknown"
    )
}
// iosMain

import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import platform.Foundation.NSBundle
import platform.Foundation.NSLocale
import platform.Foundation.currentLocale
import platform.UIKit.UIDevice
import platform.posix.uname
import platform.posix.utsname
import kotlin.native.Platform

actual fun getEnvironmentInfo(): EnvironmentInfo {
    val device = UIDevice.currentDevice
    val info = NSBundle.mainBundle.infoDictionary

    val appVersion = (info?.get("CFBundleShortVersionString") as? String) ?: "unknown"
    val buildNumber = (info?.get("CFBundleVersion") as? String) ?: "unknown"

    return EnvironmentInfo(
        isDebug = Platform.isDebugBinary,
        osName = device.systemName ?: "iOS",
        osVersion = device.systemVersion ?: "unknown",
        locale = NSLocale.currentLocale.localeIdentifier,
        appVersion = appVersion,
        appBuildNumber = buildNumber,
        deviceModel = iosHardwareModel()
    )
}

private fun iosHardwareModel(): String = memScoped {
    val systemInfo = alloc<utsname>()
    uname(systemInfo.ptr)
    systemInfo.machine.toKString() // e.g. "iPhone15,2"
}

3. Aptabase analytics object

Next, we will create the Aptabase object itself. This will handle initializing and formatting our HTTP requests when tracking events. Note: Replace my calls to 'AppLog' with whatever your logging solution is.

import io.ktor.client.HttpClient
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import kotlin.time.Clock
import kotlin.uuid.Uuid

data class InitOptions(
    val host: String? = null
)

// This is a minimal logger used for the example. In a real app, you can replace this with your preferred logging framework.
internal object AppLog {
    fun w(tag: String, message: String) {
        println("W/$tag: $message")
    }

    fun e(tag: String, message: String, throwable: Throwable? = null) {
        println("E/$tag: $message")
        throwable?.printStackTrace()
    }
}

object Aptabase {
    private const val SDK_VERSION = "custom-aptabase-KMP@0.0.1"
    private const val SESSION_TIMEOUT_MS: Long = 3_600_000 // 1 hour

    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    private val httpClient = HttpClient()


    private val hosts = mapOf(
        "US" to "https://us.aptabase.com",
        "EU" to "https://eu.aptabase.com",
        "DEV" to "http://localhost:3000",
        "SH" to "" // self-hosted
    )

    private var appKey: String? = null
    private var apiURL: String? = null
    private var env: EnvironmentInfo? = null

    private var sessionId = Uuid.random()
    private var lastTouched = Clock.System.now()

    fun initialize(appKey: String, opts: InitOptions? = null) {
        val parts = appKey.split("-")
        val region = parts.getOrNull(1)

        if (parts.size != 3 || region == null || !hosts.containsKey(region)) {
            AppLog.w("Aptabase", "The Aptabase App Key $appKey is invalid. Tracking will be disabled.")
            return
        }

        val url = getApiUrl(region, opts) ?: return

        this.appKey = appKey
        this.apiURL = url
        this.env = EnvironmentInfo.get()
    }

    fun trackEvent(eventName: String, props: Map<String, Any> = emptyMap()) {
        val key = appKey ?: return
        val url = apiURL ?: return
        val e = env ?: EnvironmentInfo.get().also { env = it }

        val now = Clock.System.now()
        if ((now - lastTouched).inWholeMilliseconds > SESSION_TIMEOUT_MS) {
            sessionId = Uuid.random()
        }
        lastTouched = now

        val body = buildJsonObject {
            put("timestamp", now.toString())
            put("sessionId", sessionId.toString().lowercase())
            put("eventName", eventName)

            putJsonObject("systemProps") {
                put("isDebug", e.isDebug)
                put("osName", e.osName)
                put("osVersion", e.osVersion)
                put("locale", e.locale)
                put("appVersion", e.appVersion)
                put("appBuildNumber", e.appBuildNumber)
                put("sdkVersion", SDK_VERSION)
                put("deviceModel", e.deviceModel)
            }

            putJsonObject("props") {
                props.forEach { (k, v) ->
                    when (v) {
                        is String -> put(k, v)
                        is Number -> put(k, v.toDouble())
                        is Boolean -> put(k, v)
                        else -> put(k, v.toString())
                    }
                }
            }
        }

        scope.launch {
            try {
                val response = httpClient.post(url) {
                    header("App-Key", key)
                    contentType(ContentType.Application.Json)
                    setBody(body.toString())
                }

                if (response.status.value >= 300) {
                    AppLog.w("Aptabase", "trackEvent failed (${response.status.value}): ${response.bodyAsText()}")
                }
            } catch (t: Throwable) {
                AppLog.e("Aptabase", "Error tracking event: ${t.message}", t)
            }
        }
    }

    private fun getApiUrl(region: String, opts: InitOptions?): String? {
        var baseURL = hosts[region] ?: return null

        if (region == "SH") {
            val host = opts?.host
            if (host.isNullOrBlank()) {
                AppLog.w("Aptabase", "Host must be provided for Self-Hosted key. Tracking will be disabled.")
                return null
            }
            baseURL = host
        }

        return "$baseURL/api/v0/event"
    }
}

4. Initialize Aptabase

Here you will have to have created an account and project on Aptabase to get your app key. And then you will just call initialize early in your app startup. I'm currently doing this in my MainActivity onCreate function on Android and in my MainViewController on iosMain targets.

//Call early in your app startup
Aptabase.initialize(
    appKey = "A-US-xxxxxxxxxxxxxxxxxxxx" // example
)

5. Track Events

You can now just call trackEvent anywhere inside your app. I would recommend sending an app start event right away so you'll start seeing events on your Aptabase dashboard.

Aptabase.trackEvent("app_start")

Aptabase.trackEvent("button_clicked")

Aptabase.trackEvent(
    eventName = "item_created",
    props = mapOf(
        "type" to "todo",
        "source" to "quick_add"
    )
)

Aptabase.trackEvent(
    eventName = "paywall_dismissed",
    props = mapOf(
        "from_close_button" to true
    )
)

Conclusion

At this point, you should have a lightweight, fully shared analytics implementation that works across Android and iOS. Hopefully someone out there finds it useful. As a self-taught solo developer, I’d love to hear any feedback or suggestions for improvement.

If you’re interested in more Kotlin Multiplatform and Compose Multiplatform content, you can subscribe below or check out my apps and projects here.