Overview

Analytics implementations suffer from a common problem: event names and parameter keys are stringly-typed, scattered across the codebase, and easy to misspell. The schema lives in someone's head or a Google Sheet — not enforced by the compiler.

typed-events solves this by making your schema the source of truth. You write a YAML file that describes every event — its name, a description, and typed parameters. The plugin generates top-level Kotlin functions, one per event, that forward to a sink you register once at startup. In KMP projects the generated code is wired into commonMain, so Android and iOS compile against the same event names, parameter keys, and types.

Typed parameters

All parameters are real Kotlin types — compiler errors instead of runtime crashes.

Native sink boundary

Implement AnalyticsSink once per host and register it at startup.

KDoc generated

Every function and parameter gets a doc comment from the YAML info fields.

Schema as source of truth

The YAML lives in version control alongside the code it drives.

Swift-callable facade

AnalyticsEvents exposes object methods that Kotlin/Native exports cleanly.

Parity-test contract

AnalyticsEventSchema exposes literal event and property names for snapshot tests.

Configuration cache compatible

The generation task is fully incremental and compatible with Gradle's configuration cache.

Installation

Add the Plugables Maven repository to settings.gradle.kts:

settings.gradle.kts
pluginManagement {
    repositories {
        maven("https://maven.rohittp.com")
        mavenCentral()
        gradlePluginPortal()
    }
}

Then apply the plugin and point it at your schema file:

build.gradle.kts
plugins {
    id("com.rohittp.plugables.typed-events") version "latest"
}

typedEvents {
    specFile.set(file("src/main/analytics/events.yaml"))
}

specFile is the only required property. Everything else has a sensible default.

KMP or Android

With org.jetbrains.kotlin.multiplatform, generated sources are wired into commonMain. In an Android-only application or library they are wired through the Android Variant API (AGP 7.2+). Do not apply both paths manually.

Recommended location

Android-only projects commonly use src/main/analytics/events.yaml. KMP projects can keep the catalogue at src/commonMain/analytics/events.yaml; the specFile property accepts either location.

YAML Schema

The schema is a YAML list where each item defines one analytics event. Two forms are supported: a verbose full form for events with parameters, and a one-line shorthand for parameter-less events.

Full form

Use the full form when an event carries parameters.

events.yaml
# Full form — use when the event has parameters
- purchase_completed:
    info: User completed a purchase flow
    function: logPurchaseCompleted  # optional — auto-derived if omitted
    params:
      item_id:
        type: String
        info: Unique identifier of the purchased item
      price:
        type: Double
        info: Final price paid after discounts, in the user's currency
      currency:
        type: String
        info: ISO 4217 currency code (e.g. USD, EUR)

Full form fields

Field Required Description
info Yes Human-readable description of the event. Becomes the KDoc comment on the generated method.
function No Override the generated method name. If omitted, the name is derived from event_name by camel-casing with a log prefix — e.g., purchase_completedlogPurchaseCompleted.
params No Map of parameter definitions. Omit entirely for parameter-less events.
params.*.type Yes (per param) Any valid Kotlin type string, e.g. String, Double, Boolean, String?.
params.*.info Yes (per param) Description of the parameter. Becomes a @param KDoc entry.

Shorthand form

For parameter-less events, collapse the definition to a single line. The value is the event description (the info field).

events.yaml
# Shorthand — event name: description
- screen_viewed:    User navigated to a new screen
- app_opened:      App launched or foregrounded
- logout_tapped:   User tapped the logout button

Validation rules

The task validates the schema before generating. Build fails with a clear message if any rule is violated:

  • info must be present and non-blank on every event and every parameter.
  • Event names must be unique within the file.
  • Function names (derived or explicit) must be unique within the file.
  • Each params entry must have both type and info.
  • Derived function names must be valid Kotlin identifiers.

Parameter name derivation

YAML parameter keys are converted to camelCase Kotlin identifiers by splitting on non-alphanumeric characters, lowercasing the first segment, and title-casing the rest. A leading digit causes a _ prefix.

YAML keyKotlin parameter name
item_iditemId
screen_namescreenName
priceprice
2fa_enabled_2faEnabled

Configuration

The typedEvents DSL block exposes two properties:

Property Type Required Default Description
specFile RegularFileProperty Yes Path to the YAML schema file. Build fails if unset or file not found.
outputDir DirectoryProperty No build/generated/source/typedEvents/main Where the four generated Kotlin files are written. Automatically wired into KMP commonMain or Android variants.

All generated code is placed in the package com.rohittp.plugables.analytics — this is fixed and not configurable.

Generated API

Given the following schema combining both full and shorthand forms:

events.yaml
- purchase_completed:
    info: User completed a purchase
    params:
      item_id:
        type: String
        info: The purchased item ID
      price:
        type: Double
        info: Final price paid
- screen_viewed: User viewed a screen

The plugin generates four files:

TypedEventHandler.kt

The registration surface. Implement AnalyticsSink for the host's supported analytics SDK and call registerAnalyticsSink once at startup. The lambda-based registerTypedEventHandler remains as a source-compatible Kotlin convenience.

generated kotlin — TypedEventHandler.kt
package com.rohittp.plugables.analytics

// GENERATED FILE. Do not edit.

fun interface AnalyticsSink {
    fun capture(eventName: String, properties: Map<String, Any?>)
}

fun registerAnalyticsSink(sink: AnalyticsSink) {
    typedEventSink = sink
}

fun registerTypedEventHandler(handler: (String, Map<String, Any?>) -> Unit) {
    registerAnalyticsSink(AnalyticsSink(handler))
}

AnalyticsEvents.kt

One top-level function per event. Each call uses checkNotNull before forwarding, so a missing startup registration fails in every build rather than silently dropping events.

generated kotlin — AnalyticsEvents.kt
package com.rohittp.plugables.analytics

// GENERATED FILE. Do not edit.
// Source: events.yaml

/**
 * User completed a purchase
 * @param itemId The purchased item ID
 * @param price Final price paid
 */
fun logPurchaseCompleted(itemId: String, price: Double) {
    emitTypedEvent("purchase_completed", mapOf("item_id" to itemId, "price" to price))
}

/** User viewed a screen */
fun logScreenViewed() {
    emitTypedEvent("screen_viewed", emptyMap())
}

Registration is mandatory

Calling any generated event before registration throws with registerAnalyticsSink() must be called before logging events. Register the host adapter before application code can emit events.

AnalyticsEventsFacade.kt

Generates the AnalyticsEvents object with the same typed methods. Kotlin/Native exports it to Swift as AnalyticsEvents.shared.log…, avoiding top-level-function discovery and file-class naming at the native boundary.

AnalyticsEventSchema.kt

Exposes literal eventNames and propertyNames collections. Use these in cross-platform snapshot tests to prove that a host adapter has neither added nor dropped catalogue entries.

Usage

Register a handler once at startup, then call the generated top-level functions anywhere in the app.

Step 1 — Register the handler

Call registerTypedEventHandler in Application.onCreate() before any event functions are invoked.

kotlin
import com.rohittp.plugables.analytics.registerTypedEventHandler

class App : Application() {
    override fun onCreate() {
        super.onCreate()

        registerTypedEventHandler { eventName, params ->
            // forward to your backend here
        }
    }
}

Step 2 — Call the typed functions

The generated functions are top-level — import and call them directly. Full IDE autocomplete and compiler-checked types.

kotlin
import com.rohittp.plugables.analytics.logPurchaseCompleted
import com.rohittp.plugables.analytics.logScreenViewed

logPurchaseCompleted(
    itemId = "sku_abc123",
    price  = 9.99
)

logScreenViewed()

KMP and iOS

Apply the plugin to the KMP module and keep the YAML schema with shared source. The generated files are added to commonMain; each host supplies only its SDK adapter.

shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    id("com.rohittp.plugables.typed-events") version "latest"
}

typedEvents {
    specFile.set(layout.projectDirectory.file("src/commonMain/analytics/events.yaml"))
}
Swift
final class PostHogSink: NSObject, AnalyticsSink {
    func capture(eventName: String, properties: [String: Any]) {
        postHog.capture(eventName, properties: properties)
    }
}

TypedEventHandlerKt.registerAnalyticsSink(sink: PostHogSink())

AnalyticsEvents.shared.logPurchaseCompleted(
    itemId: "sku_abc123",
    price: 9.99
)

Use native SDKs behind the shared contract

The generated catalogue is shared; SDK lifecycle, consent, identity, and platform-specific property conversion stay in each host's AnalyticsSink. This keeps vendor integrations native while preventing event-schema drift.

Integrations

The handler receives a plain String event name and a Map<String, Any?>, making it trivial to bridge to any SDK. Pass the lambda directly to registerTypedEventHandler.

Firebase Analytics

kotlin
import com.rohittp.plugables.analytics.registerTypedEventHandler

registerTypedEventHandler { eventName, params ->
    val bundle = Bundle().apply {
        params.forEach { (key, value) ->
            when (value) {
                is String  -> putString(key, value)
                is Double  -> putDouble(key, value)
                is Int     -> putInt(key, value)
                is Boolean -> putBoolean(key, value)
                null       -> {}
                else       -> putString(key, value.toString())
            }
        }
    }
    firebase.logEvent(eventName, bundle)
}

Mixpanel

kotlin
registerTypedEventHandler { eventName, params ->
    val props = JSONObject(params.filterValues { it != null })
    mixpanel.track(eventName, props)
}

No-op (testing / development)

Register an empty lambda for tests or debug builds where you don't want real events sent.

kotlin
// No-op — swallow all events (tests, debug builds)
registerTypedEventHandler { _, _ -> }

// Logging — print events in debug builds
registerTypedEventHandler { eventName, params ->
    Log.d("Analytics", "${eventName} ${params}")
}