Analytics¶
The Starter Template provides a modular, provider-agnostic analytics system built with Clean Architecture. You track events through type-safe AppEvent models while keeping providers swappable in the data layer.
Starter ships two optional backends: Mixpanel and Firebase Analytics (added in 0.6.0). Routing lives in domain. You opt in by passing providers to initAnalytics.
Setup¶
Init after Koin. Each backend has its own init*. Then pass only the providers you want:
initKoin(config = koinConfig)
initMixPanel(apiKey = AppConstants.MIXPANEL_API_TOKEN) {
logging = platform.debug
}
initFirebaseAnalytics {
enabled = true
}
initAnalytics {
providers(
MixPanelAnalyticsScope.getProvider(),
FirebaseAnalyticsScope.getProvider(),
)
}
Omit a backend from providers(...) (and skip its init* / Koin data module) if you do not want it. A library consumer has full control.
Register the matching Koin modules:
analyticsDomainModule,
analyticsDataModule, // Mixpanel
analyticsFirebaseDataModule, // Firebase — 0.6.0
implementation(projects.features.analytics.domain)
implementation(projects.features.analytics.data) // Mixpanel
implementation(projects.features.analytics.dataFirebase) // Firebase — 0.6.0
Mixpanel¶
- Open the constants file:
| composeApp/src/commonMain/.../core/AppConstants.kt | |
|---|---|
-
Replace
"add-your-mixpanel-token-here"with your Mixpanel project token. -
Call
initMixPanelafter Koin, then passMixPanelAnalyticsScope.getProvider()ininitAnalytics.
Info
See the official Mixpanel docs for generating your token.
Firebase Analytics¶
Added in 0.6.0
Firebase Analytics lives in features/analytics/data-firebase. No Mixpanel-style API token. The SDK reads your Firebase project from platform config files.
-
Add Firebase to the app (same files Remote Config already uses):
- Android:
androidApp/google-services.jsonplus thegoogle-servicesplugin - iOS:
iosApp/iosApp/GoogleService-Info.plist
- Android:
-
Call
initFirebaseAnalyticsafter Koin. Configure optional GitLive options, then passFirebaseAnalyticsScope.getProvider()ininitAnalytics.
initFirebaseAnalytics {
enabled = true
sessionTimeoutInterval = 30.minutes
defaultEventParameters = mapOf("app_flavor" to "prod")
userProperties = mapOf("plan" to "free")
analyticsStorage = FirebaseAnalytics.ConsentStatus.GRANTED
}
| Option | Default | Role |
|---|---|---|
enabled |
true |
Collection on/off (setAnalyticsCollectionEnabled) |
sessionTimeoutInterval |
30.minutes |
Session timeout |
defaultEventParameters |
empty | Attached to every event |
userProperties |
empty | setUserProperty on init |
adPersonalization |
unset | Consent (AD_PERSONALIZATION) |
adStorage |
unset | Consent (AD_STORAGE) |
adUserData |
unset | Consent (AD_USER_DATA) |
analyticsStorage |
unset | Consent (ANALYTICS_STORAGE) |
Unset consent fields are skipped. flush() is a no-op — Firebase has no manual flush.
Look up this backend with AnalyticsProviderIds.Firebase.
Info
See the Firebase Analytics docs and GitLive Firebase KMP.
Debug (Android)¶
Enable DebugView so events show in the Firebase console in near real-time (without this, events can take hours). Replace com.kmpstarter with your applicationId if you changed the package:
Verbose FA logs:
adb shell setprop log.tag.FA VERBOSE
adb shell setprop log.tag.FA-SVC VERBOSE
adb logcat -v time -s FA FA-SVC
Disable debug mode:
Architecture¶
| Piece | Role |
|---|---|
AppEvent |
Base analytics event (event name + optional properties) in analytics domain |
AppEvents |
Your sealed hierarchy of all app events (starter ships one in core domain) |
EventsTracker |
Interface that sends events to the active provider set |
AnalyticsProvider |
One concrete backend. Mixpanel: analytics data. Firebase: analytics data-firebase (0.6.0) |
Analytics / AnalyticsProviderIds |
Facade + well-known ids (Mixpanel, Firebase) in analytics domain. Data modules are optional |
analyticsDomainModule |
Binds the router as Analytics + EventsTracker from [initAnalytics] |
analyticsDataModule |
Mixpanel SDK wiring. You still pass Mixpanel in initAnalytics { providers(...) } |
analyticsFirebaseDataModule |
Firebase SDK wiring (0.6.0). You still pass Firebase in initAnalytics { providers(...) } |
Recommended approach: keep one sealed AppEvents hierarchy (core/shared module) so every screen tracks through the same typed models.
1. Define Events¶
Extend AppEvent with a sealed hierarchy. Starter already provides AppEvents in core domain:
sealed class AppEvents(
event: String,
properties: Map<String, Any>? = null,
) : AppEvent(event, properties) {
constructor(event: String) : this(event = event, properties = null)
constructor(
event: String,
pair: Pair<String, Any>? = null,
) : this(
event = event,
properties = if (pair != null) mapOf(pair) else mapOf(),
)
data object DummyEvent : AppEvents(
event = "dummy_event",
)
data class TrackTrafficSource(
val source: String,
) : AppEvents(
event = "onboarding_traffic_source",
pair = "traffic_source" to source,
)
data class OnPurchaseSuccess(
val productId: String,
) : AppEvents(
event = "purchase_success",
pair = "product_id" to productId,
)
}
Add a new event as another nested type:
data class SignInSuccess(
val userId: String,
) : AppEvents(
event = "sign_in_success",
pair = "user_id" to userId,
)
For multiple properties, pass properties = mapOf(...) instead of pair.
Note
- Prefer
snake_caseevent names. - Keep all events in one sealed class for autocomplete and consistency.
- No need to add methods on
EventsTrackerper event — the type is the event.
2. Track in ViewModel¶
Inject EventsTracker and call track with an AppEvents instance:
| SignInViewModel.kt | |
|---|---|
Another example from onboarding:
eventsTracker.track(
event = AppEvents.TrackTrafficSource(
source = selectedTrafficSource ?: "--",
),
)
Note
- Keep analytics calls in the presentation layer.
- ViewModel is the best place.
You can still call the string overloads (track(event), track(event, pair), track(event, properties)) when needed, but typed AppEvent is preferred.
Koin binds the same router as both Analytics and EventsTracker. ViewModels can keep injecting EventsTracker. Inject Analytics only when you need lookup, combining, or runtime swaps.
Multiple providers¶
Starter ships Mixpanel and Firebase as optional data modules. Routing lives in domain, so ViewModels only need analytics.domain. All registered providers start active.
Look up one provider¶
class SignInViewModel(
private val analytics: Analytics,
) : ViewModel() {
fun onSignIn(userId: String) {
viewModelScope.launch {
analytics.provider(AnalyticsProviderIds.Mixpanel).track(
event = AppEvents.SignInSuccess(userId = userId),
)
analytics.provider(AnalyticsProviderIds.Firebase).track(
event = AppEvents.SignInSuccess(userId = userId),
)
}
}
}
provider(...) bypasses global routing. Unknown ids throw IllegalArgumentException.
Combine providers¶
val mixpanelAndFirebase = analytics.combine(
AnalyticsProviderIds.Mixpanel,
AnalyticsProviderIds.Firebase,
)
mixpanelAndFirebase.track(AppEvents.DummyEvent)
combine returns a fixed composite. Later setActiveProviders does not change it. Duplicate ids fail fast. Empty combine() is a no-op tracker.
Swap at runtime¶
analytics.setActiveProviders(
AnalyticsProviderIds.Mixpanel,
AnalyticsProviderIds.Firebase,
)
// existing EventsTracker injections now fan out to both
analytics.setActiveProviders(AnalyticsProviderIds.Firebase)
// same instance now routes to Firebase only
analytics.setActiveProviders()
// routed tracking disabled; SDKs stay initialized
isEnabled is true when any active provider is enabled. hasOptedIn is true when every active provider has opted in (empty set → true).
Adding a custom provider¶
Implement AnalyticsProvider in a data module. Mixpanel is analytics/data. Firebase is analytics/data-firebase. Give it its own init* if the SDK needs a token. Pass the instance in initAnalytics — do not auto-bind it as the app-wide EventsTracker.
initMixPanel(apiKey = token) { logging = platform.debug }
initFirebaseAnalytics { }
initPostHog(...)
initAnalytics {
providers(
MixPanelAnalyticsScope.getProvider(),
FirebaseAnalyticsScope.getProvider(),
PostHogAnalyticsScope.getProvider(),
)
}
Note
- Domain layer, ViewModels, and Compose code remain unchanged.
- Switching or combining providers does not require rewriting event definitions.
- Use
AnalyticsProviderIds.Mixpanel/AnalyticsProviderIds.Firebasefor the shipped backends. Custom backends pick their ownAnalyticsProviderId.
Migration
Previously Mixpanel was bound as EventsTracker in Koin. You now opt in by passing providers to initAnalytics. ViewModels stay on EventsTracker — no screen changes.
1. Register domain module
Add analyticsDomainModule in initKoin. Keep analyticsDataModule only if you still use Mixpanel. Add analyticsFirebaseDataModule if you use Firebase (0.6.0):
analyticsDomainModule,
analyticsDataModule,
analyticsFirebaseDataModule,
2. Pass providers after Koin
Keep each backend's init*. Then tell analytics which backends to use:
initMixPanel(apiKey = AppConstants.MIXPANEL_API_TOKEN) {
logging = platform.debug
}
initFirebaseAnalytics { }
initAnalytics {
providers(
MixPanelAnalyticsScope.getProvider(),
FirebaseAnalyticsScope.getProvider(),
)
}
Omit a backend from providers(...) (and skip its init* / data module) if you do not want it.
3. Custom backends
Do not replace EventsTracker in Koin. Implement AnalyticsProvider, init the SDK yourself, pass it in initAnalytics.
Warning
Call initAnalytics after initKoin and after each provider's init*. MixPanelAnalyticsScope.getProvider() needs Koin plus a Mixpanel token. FirebaseAnalyticsScope.getProvider() needs Koin plus analyticsFirebaseDataModule.