What your users really want: Tracking behavior in KMP projects using Amplitude 📈
In the real world, it doesn’t matter how “killer” your new feature is or how cool your onboarding design looks. Users are unpredictable. They often ignore the “obvious” paths, get stuck in unexpected places, or use your app in ways you never even imagined. To stop guessing and start seeing the real UI/UX picture, we use event tracking services. Amplitude is a powerful tool that helps you collect user events, build funnels, and do other cool things like session replay (but that’s a different story =). It turns raw data into insights, helping you understand what your users actually need. In this article, we’ll dive into how to integrate Amplitude into a Kotlin Multiplatform (KMP) project and make it work seamlessly for both Android and iOS.
In the previous article, we covered how to manage app behavior on the “fly” using Firebase Remote Config. Now, it’s time to track what happens after those changes are live.
What’s inside:
- Console Setup: Creating a project and getting your API keys.
- Project Structure: Organizing your KMP modules.
- Dependency Injection: Setting up Koin for shared and platform-specific logic.
- The Multiplatform Bridge: Implementing the
expect/actualpattern for Amplitude SDKs. - Analytics Repository: Creating a clean, unified API for event tracking.
- Verification: Testing the flow in the Amplitude dashboard.
Step 1: Setting up the Amplitude Console ⚙️
First things first, you’ll need an Amplitude account. They offer a generous free plan, which is perfect for small projects or your pet project. The first step is to create a project for your applications.
Navigate to: Settings -> Projects -> Create Project
Give your project a name, hit Create, and wait a few seconds… Done! Now, we need to add the Sources — this is where Amplitude will receive events from.
Adding Android and iOS Sources
Start by selecting Android and enter your app’s name.
Once created, you’ll see an instruction page for the native Android SDK. You can skip the code for now — what we really need is API Key. We’ll use this later during the Amplitude initialization in our Kotlin code.
For iOS, the flow is identical. Choose the iOS SDK and follow the same steps.
Tip: API Key will be the same for both Android and iOS within the same project, so you don’t need to save two different strings. One key to rule them all!
Now that we have our credentials, let’s jump into Android Studio and start coding.
Project Structure
To make sure we’re on the same page, here is what a standard KMP project structure looks like:
Root Project (MyKMPApp)
├── composeApp/ # Main module containing the application code
│ ├── src/
│ │ ├── commonMain/ # All shared code (UI, Logic, ViewModels)
│ │ │ ├── kotlin/
│ │ │ │ └── org.example.project/
│ │ │ │ ├── App.kt # Entry point for Compose UI
│ │ │ └── composeResources/ # Shared resources (strings, images, fonts)
│ │ │
│ │ ├── androidMain/ # Android-specific code
│ │ │ ├── kotlin/
│ │ │ └── AndroidManifest.xml
│ │ │
│ │ └── iosMain/ # iOS-specific code (e.g., Platform.ios.kt)
│ │ └── kotlin/
│ │
│ └── build.gradle.kts # CMP module and dependency configuration
│
├── iosApp/ # Xcode project (Swift wrapper for iOS)
│ ├── iosApp/
│ │ ├── AppDelegate.swift
│ │ └── ContentView.swift # Calls Compose via ComposeUIViewController
│ └── iosApp.xcodeproj
│
├── gradle/ # Gradle configuration
| ├── libs.versions.toml #libs.versions.toml
├── build.gradle.kts # Root build script
└── settings.gradle.kts # Module inclusion scriptStep 2: Setting up Dependency Injection (Koin) 🛠️
Since the Amplitude SDK for Android requires a Context, we need a way to provide it cleanly across our multiplatform modules. This is where Koin comes in.
I’ll use Koin for this project because it’s lightweight, has native support for Compose Multiplatform (CMP), and it will save our time with platform-specific dependencies. We won’t spend too much time on the basics, but here is the common setup.
1. Add Dependencies
Open your libs.versions.toml and add:
[versions]
koin = "4.1.0"
[libraries]
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }In your composeApp/build.gradle.kts, add them to the respective source sets:
sourceSets {
androidMain.dependencies {
....................
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
....................
}
commonMain.dependencies {
....................
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
api(libs.koin.core)
....................
}
}2. Define the Modules
Sync the project. Now, in commonMain, create Module.kt. This will eventually hold all our repositories, ViewModels, and network logic.
expect val platformModule: Module
val sharedModule = module { /* Future dependencies go here */}To create the platform-specific implementations, use the IDE’s magic: place your cursor on platformModule, press Option + Enter (Alt + Enter), and select "Create missing actuals". This will generate:
iosMain/.../Modules.ios.ktandroidMain/.../Modules.android.kt
3. Initialize Koin
In commonMain, create InitKoin.kt to set up DI framework
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
modules(sharedModule, platformModule)
}
}Now, we need to trigger this initialization on both sides:
- Android: Create a
BaseApplicationinandroidMain, register it inAndroidManifest.xml(android:name=".BaseApplication"), and pass the context.
class BaseApplication: Application() {
override fun onCreate() {
super.onCreate()
initKoin {
androidContext(this@BaseApplication)
}
}
}- iOS: Update your
MainViewController.kt:
fun MainViewController() = ComposeUIViewController(
configure = {
initKoin()
}
) {
App()
}Step 3: Integrating Amplitude SDKs 📈
The infrastructure is ready! Now let’s add the Amplitude dependencies.
Get Vitalii Voitenko’s stories in your inbox
Join Medium for free to get updates from this writer.
For Android, add this to libs.versions.toml:
[versions]
amplitude-android = "1.22.2"
[libraries]
amplitude-android = { module = "com.amplitude:analytics-android", version.ref = "amplitude-android" }For iOS, we’ll use CocoaPods. In your composeApp/build.gradle.kts, add the pod dependency:
cocoapods {
summary = "My compose multiplatform app"
version = "1.0"
homepage = "https://medium.com"
ios.deploymentTarget = "16.0"
framework {
baseName = "ComposeApp"
isStatic = true
}
pod("Amplitude") {
version = "~> 8.20.0"
extraOpts += listOf("-compiler-option", "-fmodules")
}
}After syncing, run these commands in your terminal. This ‘clean’ sequence is a lifesaver if you’ve ever run into dependency conflicts on the iOS side — it ensures your Pods are fresh and correctly linked.
rm -rf iosApp/Pods
rm -rf iosApp/Podfile.lock
rm -rf ~/.cocoapods/repos
pod repo update
./gradlew podInstall
cd/iosApp/
pod install --repo-updateCongratulations! The dependencies are in place. Now we are ready to write some actual tracking logic!
Step 4: Bridging the Platforms (Expect/Actual) 🌉
Now for the fun part. We need to create a unified interface in commonMain so our shared code doesn't care whether it’s running on a Android or an iPhone.
In composeApp/commonMain, create an interface AmplitudeAnalytics and an expect class AmplitudeFactory:
/**
* Core interface for analytics.
* It abstracts the native Android and iOS SDK implementations.
*/
interface AmplitudeAnalytics {
/**
* The most important method. It starts the engine.
* @param apiKey your project key from the Amplitude dashboard.
* @param userId unique identifier for the user (if known).
* @param optOut if true, disables tracking (crucial for GDPR/privacy compliance).
*/
fun initialize(apiKey: String, userId: String?, optOut: Boolean = false)
/**
* Helper methods to retrieve system IDs from Amplitude.
* Useful for debugging or cross-referencing with other services.
*/
fun getDeviceId(): String
fun getSessionId(): Long
/**
* Records a specific action performed by user.
* @param eventName The name of the action (e.g., "button_clicked").
* @param properties Metadata related to the event (e.g., price, category).
*/
fun sendEvent(eventName: String, properties: Map<String, Any>? = null)
/**
* Updates User Properties.
* Used to "tag" a user (e.g., setting "is_premium" to true).
*/
fun identify(property: String, value: Boolean)
/**
* Custom method to log errors as events for better monitoring.
*/
fun sendEventError(eventName: String, value: Map<String, Any>? = null)
/**
* Sets or updates the User ID during the session.
*/
fun setUserId(userId: String)
}
expect class AmplitudeFactory {
fun create(): AmplitudeAnalytics
}Now, let’s use the IDE shortcut (Option + Enter on the factory name) to generate the “missing actuals”.
Android Implementation (androidMain) 🤖
On Android, we need to pass the Context to the Amplitude SDK. We also add a small helper to enable debug logging only for development builds.
// AmplitudeAnalytics.android.kt
import android.content.Context
import com.amplitude.android.Amplitude
import com.amplitude.android.Configuration
import com.amplitude.android.DefaultTrackingOptions
import com.amplitude.common.Logger
import com.amplitude.android.events.Identify
actual class AmplitudeFactory(
private val context: Context
) {
actual fun create(): AmplitudeAnalytics {
return AndroidAmplitudeAnalytics(context)
}
}
class AndroidAmplitudeAnalytics(
private val context: Context
) : AmplitudeAnalytics {
private var amplitude: Amplitude? = null
override fun initialize(apiKey: String, userId: String?, optOut: Boolean) {
amplitude = Amplitude(
Configuration(
context = context.applicationContext,
apiKey = apiKey,
defaultTracking = DefaultTrackingOptions.ALL,
optOut = optOut
)
).apply {
userId?.let { setUserId(it) }
// Enable logging only for debug builds to keep production logs clean
logger.logMode = if (isDebugBuild()) {
Logger.LogMode.DEBUG
} else {
Logger.LogMode.OFF
}
}
}
override fun getDeviceId(): String =
amplitude?.getDeviceId() ?: ""
override fun getSessionId(): Long =
amplitude?.sessionId ?: 0L
override fun sendEvent(eventName: String, properties: Map<String, Any>?) {
amplitude?.track(eventName, properties)
}
override fun identify(property: String, value: Boolean) {
val identify = Identify().setOnce(property, value)
amplitude?.identify(identify)
}
override fun sendEventError(eventName: String, value: Map<String, Any>?) {
amplitude?.track(eventName, value)
}
override fun setUserId(userId: String) {
amplitude?.setUserId(userId)
}
private fun isDebugBuild(): Boolean {
return context.applicationInfo.flags and
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
}
}iOS Implementation (iosMain) 🍎
The iOS SDK (via CocoaPods) feels a bit different. The biggest challenge here is that a Kotlin Map doesn't automatically convert to an iOS NSDictionary. We have to do some manual "mapping" to ensure the Objective-C/Swift side understands our data.
⚠️ A word of caution: This mapping method is a “quick and dirty” solution for demo purposes. It is not very stable for complex production apps because it doesn’t handle nested collections (like Lists or other Maps) or custom objects. If you plan to send complex data structures to Amplitude, you should refine this logic or use a more robust serialization library.
💡 Note on Instance Names: > We use instanceWithName("default") to initialize the standard Amplitude instance. For most apps, one instance is all you need. However, if your project requires sending events to multiple Amplitude projects simultaneously, you can create separate instances by giving them unique names ("Production" and "Staging").
// AmplitudeAnalytics.ios.kt
import cocoapods.Amplitude.AMPIdentify
import cocoapods.Amplitude.Amplitude
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDictionary
import platform.Foundation.NSNumber
import platform.Foundation.dictionaryWithDictionary
actual class AmplitudeFactory {
actual fun create(): AmplitudeAnalytics {
return IosAmplitudeAnalytics()
}
}
class IosAmplitudeAnalytics : AmplitudeAnalytics {
private var amplitude: Amplitude? = null
override fun initialize(apiKey: String, userId: String?, optOut: Boolean) {
// iOS uses instance names to manage different Amplitude instances
amplitude = Amplitude.instanceWithName("default").apply {
initializeApiKey(apiKey)
userId?.let { setUserId(it) }
setOptOut(optOut)
}
}
override fun getDeviceId(): String =
amplitude?.getDeviceId() ?: ""
override fun getSessionId(): Long =
amplitude?.getSessionId() ?: 0L
override fun sendEvent(eventName: String, properties: Map<String, Any>?) {
amplitude?.logEvent(
eventName,
withEventProperties = properties?.toNSDictionary() as? Map<Any?, *>
)
}
override fun identify(property: String, value: Boolean) {
val identify = AMPIdentify()
identify.setOnce(property, value = NSNumber(bool = value))
amplitude?.identify(identify)
}
override fun sendEventError(eventName: String, value: Map<String, Any>?) {
amplitude?.logEvent(
eventName,
withEventProperties = value?.toNSDictionary() as? Map<Any?, *>
)
}
override fun setUserId(userId: String) {
amplitude?.setUserId(userId)
}
// Helper to convert Kotlin Map to iOS-friendly NSDictionary
private fun Map<String, Any>.toNSDictionary(): NSDictionary {
val mutableDict = mutableMapOf<Any?, Any?>()
forEach { (key, value) ->
val convertedValue = when (value) {
is String -> value
is Int -> NSNumber(int = value)
is Long -> NSNumber(longLong = value)
is Double -> NSNumber(double = value)
is Float -> NSNumber(float = value)
is Boolean -> NSNumber(bool = value)// Kotlin Boolean -> Swift Bool (NSNumber)
else -> value.toString()
}
mutableDict[key] = convertedValue
}
return NSDictionary.dictionaryWithDictionary(mutableDict as Map<Any?, *>) as NSDictionary
}
}Step 5: The Analytics Repository 🏗️
To make our life even easier, let’s wrap everything in a AnalyticsRepository. This class will handle the initialization logic (like passing the API Key) so your ViewModels don't have to deal with it.
Create AnalyticsRepository in commonMain:
class AnalyticsRepository(
private val amplitude: AmplitudeAnalytics,
private val apiKey: String,
private val getUserId: () -> String,
private val shouldTrackEvents: () -> Boolean
) {
init {
amplitude.initialize(
apiKey = apiKey,
userId = getUserId(),
optOut = !shouldTrackEvents()
)
}
fun getDeviceId(): String = amplitude.getDeviceId()
fun getSessionId(): Long = amplitude.getSessionId()
fun sendEvent(eventName: String, properties: Map<String, Any>? = null) {
amplitude.sendEvent(eventName, properties)
}
fun identify(property: String, value: Boolean) {
amplitude.identify(property, value)
}
fun sendEventError(eventName: String, value: Map<String, Any>? = null) {
amplitude.sendEventError(eventName, value)
}
fun updateUserId(userId: String) {
amplitude.setUserId(userId)
}
}Final part: Connecting Koin
Now we just need to tell Koin how to create these classes.
In androidMain/Modules.android.kt:
import org.appvithin.cmpsample.analytics.AmplitudeFactory
import org.koin.android.ext.koin.androidApplication
import org.koin.core.module.Module
import org.koin.dsl.module
import org.koin.core.module.Module
import org.koin.dsl.module
actual val platformModule: Module
get() = module {
single { AmplitudeFactory() }
}And finally, in commonMain/Module.kt, we put it all together:
expect val platformModule: Module
val sharedModule = module {
single { get<AmplitudeFactory>().create() } // Create the platform-specific engine
single {
AnalyticsRepository(
amplitude = get(),
apiKey = "YOU_API_KEY",
getUserId = { "random_user_id" }, // Replace with real logic later
shouldTrackEvents = { true }
)
}
viewModelOf(::HomeViewModel)
}And that’s it! Our multiplatform tracking engine is officially under the hood and ready to roar. 🚀
Step 6: Putting It Into Action 🚀
Now that our engine is ready, let’s fire it up. According to best practices, event tracking should happen in the ViewModel layer. This keeps our UI clean and ensures events are triggered by actual logic.
For this demo, we’ll track a simple app_opened event whenever our HomeViewModel is initialized.
class HomeViewModel(
private val analyticsRepository: AnalyticsRepository
): ViewModel() {
init {
analyticsRepository.sendEvent(
eventName = "app_opened"
)
}
}Testing and Verification
To see the data in Amplitude, we need to provide a unique userId. In a real app, you’d use a persistent ID (like one from your database or local storage). For testing purposes, we can generate a random UUID:
single {
// Helper method for generated random UUID
fun randomUUID(): String {
return buildString(36) {
repeat(8) { append(Random.nextInt(16).toString(16)) }
append('-')
repeat(4) { append(Random.nextInt(16).toString(16)) }
append('-')
append('4')
repeat(3) { append(Random.nextInt(16).toString(16)) }
append('-')
append((Random.nextInt(4) or 8).toString(16))
repeat(3) { append(Random.nextInt(16).toString(16)) }
append('-')
repeat(12) { append(Random.nextInt(16).toString(16)) }
}
}
AnalyticsRepository(
amplitude = get(),
apiKey = "API_KEY",
getUserId = { randomUUID() },
shouldTrackEvents = { true }
)
}⚠️ Note: In a production app, you should save this UUID to the device’s local storage. If you generate a new one every time, Amplitude will treat the same person as a “New User” every time they reopen the app, which will ruin your retention metrics!
The Moment of Truth
Run your app on both an Android Emulator and an iOS Simulator. After the apps launch, head over to your Amplitude Dashboard under the Home or User Look-Up tab.
In the Recent Users widget, you should see your test devices appear. If you click on a specific user, you can dive deep into their activity feed.
Take a look at the event details:
- The Red Area shows the chronological stream of events (our
app_openedshould be right there). - The Blue Area confirms the metadata: you’ll see different Platforms (Android vs. iOS) and OS versions.
Conclusion
That’s it! You’ve successfully built a cross-platform analytics engine using Kotlin Multiplatform and Amplitude. Now you have a single, clean interface to track user behavior across both worlds without duplicating your logic.
Happy coding, and may your funnels always be green! 📈 Also, feel free to connect with me on LinkedIn







