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.