
GA4 Measurement Protocol client with action→event middleware, routing analytics, session handling, batching/retry queue, typed event helpers and optional consent/PII scrubbing.
A Kotlin Multiplatform library for Google Analytics 4 (GA4) with the duks state management framework — Measurement Protocol client, action→event middleware, routing analytics, and opt-in privacy.
session_id + engagement_time_msec for Realtime metricsEventQueueStore for durabilitydependencies {
implementation("io.github.crowded-libs:duks-ga4:0.2.0")
}Requires duks 0.4 and duks-routing 0.3.1+ when using routing analytics.
Most apps should attach analytics through the store. Map actions to events, provide a stable client ID, and optionally wire routing.
import duks.*
import duks.ga4.middleware.ga4Analytics
import duks.ga4.model.*
import duks.routing.HasRouterState
import duks.routing.RouterState
import duks.routing.routing
data class AppState(
val userId: String? = null,
override val routerState: RouterState = RouterState(),
) : HasRouterState {
override fun withRouterState(routerState: RouterState) = copy(routerState = routerState)
}
sealed class AppAction : Action {
data class Login(val method: String) : AppAction()
data class AddToCart(val itemId: String, val price: Double) : AppAction()
}
val store = createStore(AppState()) {
val router = routing {
content("/home") { HomeScreen() }
content("/product/{id}") { ProductScreen() }
}
ga4Analytics {
config {
measurementId("G-XXXXXXXXXX")
apiSecret("your-api-secret")
debugMode() // DebugView while integrating
}
clientIdProvider { state -> state.userId }
trackRouting(router)
patternMapper {
pattern<AppAction.Login> { action, _ ->
listOf(loginEvent(method = action.method))
}
pattern<AppAction.AddToCart> { action, _ ->
listOf(
addToCartEvent(
items = listOf(
Item(itemId = action.itemId, price = action.price, quantity = 1)
),
currency = "USD",
value = action.price
)
)
}
}
}
}trackRouting(router) registers a NavigationListener on duks-routing so every committed stack change produces routing analytics. You get:
| Event | Notable params |
|---|---|
screen_view |
screen_name, screen_class, previous_screen, modal_route, … |
screen_time |
screen_name, route_duration_seconds, engagement_time_msec
|
navigation |
from_screen, to_screen, navigation_type, navigation_pattern, … |
modal_open / modal_close / modal_dismiss
|
modal_name, modal_path, parent_screen
|
tab_switch |
tab_name, screen_name (when route config has selectedTab) |
App state must implement HasRouterState + withRouterState (required by duks-routing 0.3). Domain reducers should not handle routing actions — stacks are reducer-owned by the routing library.
For scripts, services, or non-duks code, use GA4Client directly (you own the CoroutineScope):
val client = GA4Client(config = config, scope = scope)
client.sendEvent(
pageViewEvent(pageTitle = "Home", pageLocation = "/home"),
clientId = "stable-client-id"
)
// Critical events: send immediately. Everything else can batch.
client.sendEvent(purchaseEvent(...), clientId = id, immediate = true)
client.flush()
client.close()Typed helpers live in duks.ga4.model (loginEvent, searchEvent, viewItemEvent, purchaseEvent, …). Prefer those over hand-built GA4Event maps.
GA4Config only requires measurementId and apiSecret. Useful knobs:
| Option | Default | Notes |
|---|---|---|
debugMode |
false |
Live collect + debug_mode for DebugView |
autoGenerateClientId |
true |
Stable for the process; pass clientIdStore for restarts |
attachSessionParams |
true |
Adds session_id + engagement_time_msec
|
validationMode |
LOG |
OFF / LOG / STRICT
|
maxEventsPerBatch |
25 |
GA4 hard limit |
enableRetry |
true |
Exponential backoff on network failures |
Builder form (used by middleware):
config {
measurementId("G-XXXXXXXXXX")
apiSecret("your-api-secret")
debugMode()
validationMode(ValidationMode.STRICT)
}Optional on the client/middleware:
contextProvider — attach device / geo / ip_override per requesteventQueueStore — durable queue (e.g. app-provided kotlin-lmdb adapter); default queue is in-memory onlyPrivacy is off by default. Turn it on from the middleware builder:
ga4Analytics {
config { /* ... */ }
enablePrivacy() // consent gate + PII scrubbing
// or: enablePrivacy(consentStorage = myStorage, scrubPii = true)
}When enabled, events are dropped without analytics consent and PII can be scrubbed on the send path. For GDPR export/delete/anonymize of a local event store, see GA4PrivacyActions (requires enableEventStore / your own store).
ga4Analytics {
config { /* ... */ }
exclude<InternalAction>()
// or: filterActions { it is UserAction || it is PurchaseAction }
patternMapper { /* ... */ }
}clientIdProvider, defaultClientId, or clientIdStore).attachSessionParams = true for Realtime / engaged sessions.debugMode = true + GA4 DebugView while integrating.user_engagement, session_start, …) via Measurement Protocol.page_view over screen_view.immediate = true for purchases and other critical events.enablePrivacy() only when you need consent gating.EventQueueStore.validationMode / debug logs for reserved names or invalid params; with privacy enabled, confirm consent is granted.www.google-analytics.com.Apache License 2.0. See LICENSE.
A Kotlin Multiplatform library for Google Analytics 4 (GA4) with the duks state management framework — Measurement Protocol client, action→event middleware, routing analytics, and opt-in privacy.
session_id + engagement_time_msec for Realtime metricsEventQueueStore for durabilitydependencies {
implementation("io.github.crowded-libs:duks-ga4:0.2.0")
}Requires duks 0.4 and duks-routing 0.3.1+ when using routing analytics.
Most apps should attach analytics through the store. Map actions to events, provide a stable client ID, and optionally wire routing.
import duks.*
import duks.ga4.middleware.ga4Analytics
import duks.ga4.model.*
import duks.routing.HasRouterState
import duks.routing.RouterState
import duks.routing.routing
data class AppState(
val userId: String? = null,
override val routerState: RouterState = RouterState(),
) : HasRouterState {
override fun withRouterState(routerState: RouterState) = copy(routerState = routerState)
}
sealed class AppAction : Action {
data class Login(val method: String) : AppAction()
data class AddToCart(val itemId: String, val price: Double) : AppAction()
}
val store = createStore(AppState()) {
val router = routing {
content("/home") { HomeScreen() }
content("/product/{id}") { ProductScreen() }
}
ga4Analytics {
config {
measurementId("G-XXXXXXXXXX")
apiSecret("your-api-secret")
debugMode() // DebugView while integrating
}
clientIdProvider { state -> state.userId }
trackRouting(router)
patternMapper {
pattern<AppAction.Login> { action, _ ->
listOf(loginEvent(method = action.method))
}
pattern<AppAction.AddToCart> { action, _ ->
listOf(
addToCartEvent(
items = listOf(
Item(itemId = action.itemId, price = action.price, quantity = 1)
),
currency = "USD",
value = action.price
)
)
}
}
}
}trackRouting(router) registers a NavigationListener on duks-routing so every committed stack change produces routing analytics. You get:
| Event | Notable params |
|---|---|
screen_view |
screen_name, screen_class, previous_screen, modal_route, … |
screen_time |
screen_name, route_duration_seconds, engagement_time_msec
|
navigation |
from_screen, to_screen, navigation_type, navigation_pattern, … |
modal_open / modal_close / modal_dismiss
|
modal_name, modal_path, parent_screen
|
tab_switch |
tab_name, screen_name (when route config has selectedTab) |
App state must implement HasRouterState + withRouterState (required by duks-routing 0.3). Domain reducers should not handle routing actions — stacks are reducer-owned by the routing library.
For scripts, services, or non-duks code, use GA4Client directly (you own the CoroutineScope):
val client = GA4Client(config = config, scope = scope)
client.sendEvent(
pageViewEvent(pageTitle = "Home", pageLocation = "/home"),
clientId = "stable-client-id"
)
// Critical events: send immediately. Everything else can batch.
client.sendEvent(purchaseEvent(...), clientId = id, immediate = true)
client.flush()
client.close()Typed helpers live in duks.ga4.model (loginEvent, searchEvent, viewItemEvent, purchaseEvent, …). Prefer those over hand-built GA4Event maps.
GA4Config only requires measurementId and apiSecret. Useful knobs:
| Option | Default | Notes |
|---|---|---|
debugMode |
false |
Live collect + debug_mode for DebugView |
autoGenerateClientId |
true |
Stable for the process; pass clientIdStore for restarts |
attachSessionParams |
true |
Adds session_id + engagement_time_msec
|
validationMode |
LOG |
OFF / LOG / STRICT
|
maxEventsPerBatch |
25 |
GA4 hard limit |
enableRetry |
true |
Exponential backoff on network failures |
Builder form (used by middleware):
config {
measurementId("G-XXXXXXXXXX")
apiSecret("your-api-secret")
debugMode()
validationMode(ValidationMode.STRICT)
}Optional on the client/middleware:
contextProvider — attach device / geo / ip_override per requesteventQueueStore — durable queue (e.g. app-provided kotlin-lmdb adapter); default queue is in-memory onlyPrivacy is off by default. Turn it on from the middleware builder:
ga4Analytics {
config { /* ... */ }
enablePrivacy() // consent gate + PII scrubbing
// or: enablePrivacy(consentStorage = myStorage, scrubPii = true)
}When enabled, events are dropped without analytics consent and PII can be scrubbed on the send path. For GDPR export/delete/anonymize of a local event store, see GA4PrivacyActions (requires enableEventStore / your own store).
ga4Analytics {
config { /* ... */ }
exclude<InternalAction>()
// or: filterActions { it is UserAction || it is PurchaseAction }
patternMapper { /* ... */ }
}clientIdProvider, defaultClientId, or clientIdStore).attachSessionParams = true for Realtime / engaged sessions.debugMode = true + GA4 DebugView while integrating.user_engagement, session_start, …) via Measurement Protocol.page_view over screen_view.immediate = true for purchases and other critical events.enablePrivacy() only when you need consent gating.EventQueueStore.validationMode / debug logs for reserved names or invalid params; with privacy enabled, confirm consent is granted.www.google-analytics.com.Apache License 2.0. See LICENSE.