Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
dcd3d99
feat(analytics-events): add the shared event module
bmc08gt Sep 23, 2026
c6ddfbe
feat(analytics-events): add the value vocabulary
bmc08gt Sep 23, 2026
4ab3ed1
feat(analytics-events): chat events
bmc08gt Sep 23, 2026
3e837eb
feat(analytics-events): transfer events
bmc08gt Sep 23, 2026
9e84240
feat(analytics-events): add money events
bmc08gt Sep 23, 2026
38e6260
feat(analytics-events): onramp events
bmc08gt Sep 23, 2026
df7409f
feat(analytics-events): wallet events
bmc08gt Sep 23, 2026
63d0f18
feat(analytics-events): token info events
bmc08gt Sep 23, 2026
0f3f83c
feat(analytics-events): buy and sell events
bmc08gt Sep 23, 2026
bdc108a
feat(analytics-events): scan events
bmc08gt Sep 23, 2026
28f65a5
feat(analytics-events): deeplink events
bmc08gt Sep 23, 2026
c89e441
feat(analytics-events): display name events
bmc08gt Sep 23, 2026
4a85e22
feat(analytics-events): error modal events
bmc08gt Sep 23, 2026
af81e97
feat(analytics-events): account events
bmc08gt Sep 23, 2026
e3c1959
feat(analytics-events): button events
bmc08gt Sep 23, 2026
f581f7b
build(shared-core): export the analytics events module
bmc08gt Sep 23, 2026
2242c43
feat(shared-core-kit): add the analytics events facade
bmc08gt Sep 23, 2026
1bb1197
ci(shared-core): run the analytics events tests
bmc08gt Sep 23, 2026
c1438a5
feat(build-logic): generate analytics builders from a TOML catalogue
bmc08gt Sep 23, 2026
7df1547
build(analytics-events): generate builders at build time
bmc08gt Sep 23, 2026
1204e38
refactor(analytics-events): move chat into the catalogue
bmc08gt Sep 23, 2026
391a191
refactor(analytics-events): move transfer into the catalogue
bmc08gt Sep 23, 2026
1bebead
refactor(analytics-events): move add money into the catalogue
bmc08gt Sep 23, 2026
916ab01
refactor(analytics-events): move onramp into the catalogue
bmc08gt Sep 23, 2026
52dd66b
refactor(analytics-events): move wallet into the catalogue
bmc08gt Sep 23, 2026
8d8bd88
refactor(analytics-events): move token info into the catalogue
bmc08gt Sep 23, 2026
a775b1f
fix(build-logic): let a builder send Mint beside the amount block
bmc08gt Sep 23, 2026
38a51e4
refactor(analytics-events): move swap into the catalogue
bmc08gt Sep 23, 2026
d634255
refactor(analytics-events): move scan into the catalogue
bmc08gt Sep 23, 2026
267457e
refactor(analytics-events): move deeplink into the catalogue
bmc08gt Sep 23, 2026
cdc622d
refactor(analytics-events): move display name into the catalogue
bmc08gt Sep 23, 2026
c9764fe
refactor(analytics-events): move error modal into the catalogue
bmc08gt Sep 23, 2026
89e26b4
refactor(analytics-events): move account into the catalogue
bmc08gt Sep 23, 2026
0e742c9
refactor(analytics-events): move buttons into the catalogue
bmc08gt Sep 23, 2026
24ebf56
refactor(analytics-events): move people counters into the catalogue
bmc08gt Sep 23, 2026
ed880ef
ci(analytics-events): run the macOS lane on generator changes
bmc08gt Sep 23, 2026
15413d5
feat(analytics-events): make events.toml writable without Kotlin (#1547)
bmc08gt Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/shared-core-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ on:
- 'libs/currency-math/**'
- 'libs/encryption/**'
- 'libs/reporting/**'
- 'libs/analytics-events/**'
- 'build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt'
- 'build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/**'
- 'gradle/libs.versions.toml'
- '.github/workflows/shared-core-tests.yml'

Expand Down Expand Up @@ -65,6 +68,7 @@ jobs:
- name: Run the Kotlin tests on the iOS simulator target
run: |
./gradlew \
:libs:analytics-events:iosSimulatorArm64Test \
:libs:codes:kikcode:iosSimulatorArm64Test \
:libs:currency-math:discrete-curve:iosSimulatorArm64Test \
:libs:encryption:base58:iosSimulatorArm64Test \
Expand Down
8 changes: 8 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ dependencies {
compileOnly(libs.compose.compiler.gradle.plugin)
compileOnly(libs.kotlin.serialization.gradle.plugin)
compileOnly(libs.kover.gradle.plugin)

implementation(libs.tomlj)

testImplementation(kotlin("test-junit"))
}

gradlePlugin {
Expand Down Expand Up @@ -49,5 +53,9 @@ gradlePlugin {
id = "flipcash.kmp.test.fixtures"
implementationClass = "KmpTestFixturesConventionPlugin"
}
register("analyticsCatalogue") {
id = "flipcash.analytics.catalogue"
implementationClass = "AnalyticsCatalogueConventionPlugin"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import com.getcode.buildlogic.analytics.CheckAnalyticsEventsPage
import com.getcode.buildlogic.analytics.GenerateAnalyticsEvents
import com.getcode.buildlogic.analytics.WriteAnalyticsEventsPage
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

/**
* Generates a KMP module's analytics builders and value enums from its `events.toml` into
* `commonMain`, so both apps call the same Kotlin and the catalogue is the only place an event
* is written down. See `com.getcode.buildlogic.analytics` for the format.
*
* Usage in a module's `build.gradle.kts`:
* ```
* plugins {
* alias(libs.plugins.flipcash.analytics.catalogue)
* }
* ```
*
* The generated directory is registered on `commonMain`, which carries the task dependency to
* every compilation, including the Kotlin/Native ones the XCFramework build links. Lint reads
* source directories straight off disk, so it is made to depend on the task explicitly, as in
* [KmpTestFixturesConventionPlugin].
*
* `EVENTS.md`, the catalogue as a readable page, is checked in next to `events.toml`.
* `writeAnalyticsEventsPage` rewrites it; `checkAnalyticsEventsPage` fails when it is stale.
*/
class AnalyticsCatalogueConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
val generateAnalyticsEvents = tasks.register<GenerateAnalyticsEvents>("generateAnalyticsEvents") {
catalogue.set(layout.projectDirectory.file("events.toml"))
outputDirectory.set(layout.buildDirectory.dir("generated/analytics/commonMain/kotlin"))
}

val catalogueFile = layout.projectDirectory.file("events.toml")
val pageFile = layout.projectDirectory.file("EVENTS.md")
tasks.register<WriteAnalyticsEventsPage>("writeAnalyticsEventsPage") {
catalogue.set(catalogueFile)
page.set(pageFile)
}
tasks.register<CheckAnalyticsEventsPage>("checkAnalyticsEventsPage") {
catalogue.set(catalogueFile)
page.set(pageFile)
fixCommand.set("./gradlew ${path.substringBeforeLast(':')}:writeAnalyticsEventsPage")
}

tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintModel") }
.configureEach { dependsOn(generateAnalyticsEvents) }

pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.named("commonMain") { kotlin.srcDir(generateAnalyticsEvents) }
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.getcode.buildlogic.analytics

/** Reads `events.toml` and writes the builders and value enums it describes, and `EVENTS.md`. */
internal object AnalyticsCatalogue {
fun parse(text: String): Catalogue = CatalogueParser.parse(text)

/** The generated sources, keyed by path relative to the source root. */
fun emit(catalogue: Catalogue): Map<String, String> = KotlinEmitter.emit(catalogue)

/** `EVENTS.md`: the catalogue as a readable page. */
fun document(catalogue: Catalogue): String = MarkdownEmitter.emit(catalogue)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.getcode.buildlogic.analytics

import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction

private fun page(catalogue: RegularFileProperty): String = try {
AnalyticsCatalogue.document(AnalyticsCatalogue.parse(catalogue.get().asFile.readText()))
} catch (malformed: CatalogueException) {
throw GradleException(malformed.message.orEmpty(), malformed)
}

/** Writes `EVENTS.md` from `events.toml`. The page is checked in, so this writes into the module. */
abstract class WriteAnalyticsEventsPage : DefaultTask() {

@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val catalogue: RegularFileProperty

@get:OutputFile
abstract val page: RegularFileProperty

@TaskAction
fun write() = page.get().asFile.writeText(page(catalogue))
}

/** Fails when the checked-in `EVENTS.md` is not what `events.toml` generates. */
abstract class CheckAnalyticsEventsPage : DefaultTask() {

@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val catalogue: RegularFileProperty

/** Read only if it exists: a missing page is reported, not a missing-input failure. */
@get:Internal
abstract val page: RegularFileProperty

/** The command the failure tells the reader to run. */
@get:Input
abstract val fixCommand: Property<String>

@TaskAction
fun check() {
val file = page.get().asFile
val expected = page(catalogue)
if (!file.exists() || file.readText() != expected) {
throw GradleException(
"${file.name} is out of date with events.toml. Run `${fixCommand.get()}` and commit the result.",
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.getcode.buildlogic.analytics

/** The catalogue is malformed. The message names the entry at fault. */
class CatalogueException(message: String) : IllegalArgumentException(message)

/** `events.toml`, parsed and checked. Every reference in it resolves. */
internal data class Catalogue(
val packageName: String,
val enums: List<EnumDef>,
val counters: List<EnumCase>,
val domains: List<Domain>,
)

/** A value list: a Kotlin enum whose cases carry the string sent to Mixpanel. */
internal data class EnumDef(val name: String, val doc: String?, val cases: List<EnumCase>)

internal data class EnumCase(val name: String, val value: String, val drift: String?)

/** One generated `object`, e.g. `ChatEvents`, named from the [title] entries give, e.g. `Chat`. */
internal data class Domain(val name: String, val title: String, val doc: String?, val builders: List<Builder>)

internal data class Builder(
val name: String,
val event: Template,
val params: List<Param>,
val properties: List<Property>,
val doc: String?,
val drift: String?,
)

internal data class Param(val name: String, val type: ParamType, val nullable: Boolean) {
val kotlinType: String get() = type.kotlinName + if (nullable) "?" else ""
}

internal sealed class ParamType(val kotlinName: String) {
data object Text : ParamType("String")
data object Decimal : ParamType("Double")
data object Whole : ParamType("Long")
data object SmallWhole : ParamType("Int")
data object Flag : ParamType("Boolean")

/** The hand-written `Amount`, sent only through the `amount` group. */
data object Amount : ParamType("Amount")
data class Enum(val name: String) : ParamType(name)
}

internal sealed interface Property {
/** A parameter sent as it is, typed by the parameter. */
data class Value(val key: String, val param: Param) : Property

/** A text value built from literal text and parameters. */
data class Format(val key: String, val template: Template) : Property

/** The hand-written `amount(...)` block. */
data class AmountBlock(val param: Param) : Property
}

/** Text with `{param}` slots, used for event names and fixed-format values. */
internal data class Template(val parts: List<Part>) {
sealed interface Part {
data class Literal(val text: String) : Part
data class Slot(val param: Param) : Part
}
}
Loading
Loading