diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index 5db736050b..63b9509076 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -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' @@ -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 \ diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index b510504fb5..256dd56494 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -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 { @@ -49,5 +53,9 @@ gradlePlugin { id = "flipcash.kmp.test.fixtures" implementationClass = "KmpTestFixturesConventionPlugin" } + register("analyticsCatalogue") { + id = "flipcash.analytics.catalogue" + implementationClass = "AnalyticsCatalogueConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt new file mode 100644 index 0000000000..a7dec4ab57 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt @@ -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 { + override fun apply(target: Project) { + with(target) { + val generateAnalyticsEvents = tasks.register("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") { + catalogue.set(catalogueFile) + page.set(pageFile) + } + tasks.register("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 { + sourceSets.named("commonMain") { kotlin.srcDir(generateAnalyticsEvents) } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt new file mode 100644 index 0000000000..6473259f2e --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt @@ -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 = KotlinEmitter.emit(catalogue) + + /** `EVENTS.md`: the catalogue as a readable page. */ + fun document(catalogue: Catalogue): String = MarkdownEmitter.emit(catalogue) +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt new file mode 100644 index 0000000000..6e84db95f0 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt @@ -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 + + @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.", + ) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt new file mode 100644 index 0000000000..dd9973111f --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt @@ -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, + val counters: List, + val domains: List, +) + +/** 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) + +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) + +internal data class Builder( + val name: String, + val event: Template, + val params: List, + val properties: List, + 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) { + sealed interface Part { + data class Literal(val text: String) : Part + data class Slot(val param: Param) : Part + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt new file mode 100644 index 0000000000..9258d6bd21 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt @@ -0,0 +1,435 @@ +package com.getcode.buildlogic.analytics + +import org.tomlj.Toml +import org.tomlj.TomlArray +import org.tomlj.TomlTable + +/** + * Reads `events.toml` into a [Catalogue]. An entry describes the event in plain words and this + * derives the Kotlin: the builder name from the event name, each parameter from a property, and + * the parameter order from where the properties sit. + * + * Anything the generator would otherwise have to guess at is rejected: unknown keys, types, + * lists and groups, names that don't make a usable function or parameter, and duplicates. + * The people reading these messages may not know Kotlin, so each one names the event and says + * what to change in the file. + * + * Keys that come from the data (list, domain and group names) are always looked up as + * one-element paths, since tomlj reads a plain string key as a dotted path. + */ +internal object CatalogueParser { + + private const val FILE = "events.toml" + + /** The built-in group that calls the hand-written `amount(...)` helper. */ + private const val AMOUNT_GROUP = "amount" + + /** + * The keys `amount(...)` always writes, so a duplicate of one is caught like any other. It + * also writes `Exchange Rate` and `Mint` when the amount has them, and those two may appear + * beside the group: the builder sends the property, and the amount's value replaces it when + * present. `SwapEvents` relies on that for `Mint`. + */ + private val AMOUNT_KEYS = listOf("Fiat", "Currency", "USDC", "Quarks") + + /** The plain-word property types, in the order messages list them. */ + private val TYPE_WORDS = linkedMapOf( + "text" to ParamType.Text, + "yes/no" to ParamType.Flag, + "count" to ParamType.SmallWhole, + "decimal" to ParamType.Decimal, + "duration" to ParamType.Whole, + ) + + /** A `duration` is sent in milliseconds, and its parameter says so. */ + private const val DURATION_SUFFIX = "Millis" + + /** Types the module already declares by hand, which a list must not shadow. */ + private val RESERVED_TYPES = setOf( + "String", "Double", "Long", "Int", "Boolean", "Amount", "AnalyticsEvent", "PropertyValue", "PeopleCounter", + ) + + private val KEYWORDS = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", "true", + "try", "typealias", "typeof", "val", "var", "when", "while", + ) + + private val TYPE_NAME = Regex("[A-Z][A-Za-z0-9]*") + private val CASE_NAME = Regex("[A-Z][A-Z0-9_]*") + private val MEMBER_NAME = Regex("[a-z][A-Za-z0-9]*") + private val WORD_BREAK = Regex("[^A-Za-z0-9]+") + private val PLACEHOLDER = Regex("""\{[^{}]*}""") + + fun parse(text: String): Catalogue { + val root = Toml.parse(text) + if (root.hasErrors()) { + fail(FILE, root.errors().joinToString("; ") { "line ${it.position().line()}: ${it.message}" }) + } + root.requireOnly(FILE, "package", "lists", "counters", "groups", "domains", "event") + + val packageName = root.string("package", FILE) ?: fail(FILE, "`package` is required") + + val lists = root.subtables("lists", FILE).map { (name, table) -> list(name, table) } + val types = Types(lists.map { it.name }) + + val counters = root.table("counters", FILE)?.let { table -> + table.keySet().map { name -> value("counters", name, table.get(listOf(name))) } + }.orEmpty() + + val groups = root.subtables("groups", FILE).toMap() + groups.forEach { (name, table) -> + val where = "group \"$name\"" + if (name == AMOUNT_GROUP) fail(where, "`$AMOUNT_GROUP` is built in, so a group can't use that name") + table.requireOnly(where, "properties") + } + + val descriptions = root.table("domains", FILE)?.let { table -> + table.keySet().associateWith { name -> table.string(name, "domains") } + }.orEmpty() + + val entries = root.tables("event", FILE).mapIndexed { index, table -> entry(index, table, types, groups) } + val byDomain = entries.groupBy { it.first } + descriptions.keys.firstOrNull { it !in byDomain }?.let { unused -> + fail("domains", "\"$unused\" has a description, but no event has `domain = \"$unused\"`") + } + + val domains = byDomain.map { (domain, found) -> + val events = found.map { it.second } + events.groupBy { it.builder.name }.forEach { (builder, same) -> + if (same.size > 1) { + fail( + "domain \"$domain\"", + same.joinToString(" and ") { "event \"${it.eventName}\"" } + + " would all get the builder `$builder`. To fix it, add `builder = \"...\"` " + + "to all but one of them, with a short name of your own such as \"${builder}Failed\"", + ) + } + } + Domain(objectName(domain), domain, descriptions[domain], events.map { it.builder }) + } + + return Catalogue(packageName, lists, counters, domains) + } + + private fun list(name: String, table: TomlTable): EnumDef { + val where = "list $name" + if (!TYPE_NAME.matches(name)) fail(where, "a list name is words run together, each starting with a capital, such as \"ChatType\"") + if (name in RESERVED_TYPES) fail(where, "`$name` is already a type in the module, so pick another list name") + table.requireOnly(where, "description", "values") + val values = table.table("values", where) ?: fail(where, "add a [lists.$name.values] table with the list's values") + if (values.isEmpty) fail(where, "a list needs at least one value") + return EnumDef( + name = name, + doc = table.string("description", where), + cases = values.keySet().map { case -> value(where, case, values.get(listOf(case))) }, + ) + } + + private fun value(owner: String, name: String, raw: Any?): EnumCase { + val where = "$owner, value $name" + if (!CASE_NAME.matches(name)) fail(where, "a value's name is capitals and underscores, such as CHAT_GATE") + val (wire, drift) = when (raw) { + is String -> raw to null + is TomlTable -> { + raw.requireOnly(where, "value", "drift") + raw.string("value", where) to raw.string("drift", where) + } + else -> fail(where, "write it as $name = \"...\" or $name = { value = \"...\", drift = \"...\" }") + } + if (wire.isNullOrEmpty()) fail(where, "give it the text Mixpanel receives, such as $name = \"${titleCase(name)}\"") + return EnumCase(name, wire, drift) + } + + /** One `[[event]]`, paired with the domain it belongs to. */ + private fun entry(index: Int, table: TomlTable, types: Types, groups: Map): Pair { + val eventName = table.string("name", "event #${index + 1}") + ?: fail("event #${index + 1}", "every [[event]] needs a `name`: the event name as Mixpanel shows it") + val where = "event \"$eventName\"" + table.requireOnly(where, "domain", "name", "builder", "placeholders", "properties", "order", "description", "drift") + val domain = table.string("domain", where) + ?: fail(where, "add `domain = \"...\"`, the part of the app it belongs to, such as \"Transfer\"") + if (words(domain).isEmpty() || !objectName(domain).let(TYPE_NAME::matches)) { + fail(where, "`domain = \"$domain\"` must be words, such as \"Add Money\"") + } + + val placeholderTypes = table.table("placeholders", where)?.let { placeholders -> + placeholders.keySet().associateWith { name -> + val word = placeholders.string(name, where)!! + val type = types.resolve(word, where, "placeholder {$name}") + if (type != ParamType.Text && type !is ParamType.Enum) { + fail(where, "{$name} can only be text or a list, since it's written into the name") + } + type + } + }.orEmpty() + + val params = Params(where) + val usedPlaceholders = mutableSetOf() + fun slots(text: String) = template(text, where, placeholderTypes, params, usedPlaceholders) + + val event = slots(eventName) + val properties = mutableListOf() + table.tables("properties", where).forEach { property -> + expand(property, where, types, groups, params, ::slots, properties, groupStack = emptyList()) + } + (placeholderTypes.keys - usedPlaceholders).firstOrNull()?.let { unused -> + fail(where, "`placeholders` gives a type for {$unused}, but neither the name nor a format has {$unused}") + } + + val keys = properties.flatMap { property -> + when (property) { + is Property.Value -> listOf(property.key) + is Property.Format -> listOf(property.key) + is Property.AmountBlock -> AMOUNT_KEYS + } + } + keys.groupBy { it }.forEach { (key, found) -> + if (found.size > 1) { + val why = if (key in AMOUNT_KEYS) " (the amount group already sends \"$key\")" else "" + fail(where, "the property \"$key\" is sent more than once$why. Remove one of them") + } + } + + val builderName = table.string("builder", where) ?: derivedBuilder(eventName, where) + if (!MEMBER_NAME.matches(builderName) || builderName in KEYWORDS) { + fail(where, "`builder = \"$builderName\"` must be one word, or words run together, starting lowercase, such as \"opened\"") + } + + val ordered = table.array("order", where)?.let { order(it, where, params.all) } ?: params.all + + val builder = Builder( + name = builderName, + event = event, + params = ordered, + properties = properties, + doc = table.string("description", where), + drift = table.string("drift", where), + ) + return domain to Entry(eventName, builder) + } + + private class Entry(val eventName: String, val builder: Builder) + + /** The event name in camelCase, without the `Domain:` prefix and the `{placeholders}`. */ + private fun derivedBuilder(eventName: String, where: String): String { + val name = camelCase(eventName.substringAfter(':').replace(PLACEHOLDER, " ")) + if (name.isEmpty() || !MEMBER_NAME.matches(name) || name in KEYWORDS) { + fail(where, "no builder name can be made from this event name, so add `builder = \"...\"`, a short name such as \"opened\"") + } + return name + } + + private fun order(order: TomlArray, where: String, params: List): List { + val names = order.toList().map { it as? String ?: fail(where, "`order` is a list of parameter names in quotes") } + val byName = params.associateBy { it.name } + val missing = params.map { it.name }.filter { it !in names } + val unknown = names.filter { it !in byName } + val twice = names.groupBy { it }.filterValues { it.size > 1 }.keys + if (missing.isNotEmpty() || unknown.isNotEmpty() || twice.isNotEmpty()) { + val problems = listOfNotNull( + missing.takeIf { it.isNotEmpty() }?.let { "missing ${it.joinToString()}" }, + unknown.takeIf { it.isNotEmpty() }?.let { "${it.joinToString()} isn't a parameter" }, + twice.takeIf { it.isNotEmpty() }?.let { "${it.joinToString()} is listed twice" }, + ) + fail( + where, + "`order` must list every parameter once: ${params.joinToString { it.name }}. " + + "Right now it's ${problems.joinToString("; ")}", + ) + } + return names.map(byName::getValue) + } + + private fun expand( + entry: TomlTable, + where: String, + types: Types, + groups: Map, + params: Params, + template: (String) -> Template, + into: MutableList, + groupStack: List, + ) { + val group = entry.string("group", where) + if (group != null) { + entry.requireOnly(where, "group", "optional") + val optional = entry.boolean("optional", where) + when { + group == AMOUNT_GROUP -> { + val param = params.add(Param(AMOUNT_GROUP, ParamType.Amount, optional), "the amount group") + into += Property.AmountBlock(param) + } + optional -> fail(where, "only the amount group can be `optional`; mark the properties inside group \"$group\" instead") + group in groupStack -> fail(where, "group \"$group\" includes itself") + else -> { + val table = groups[group] + ?: fail(where, "there's no group \"$group\". Groups: ${(groups.keys + AMOUNT_GROUP).joinToString()}") + table.tables("properties", "group \"$group\"").forEach { nested -> + expand(nested, where, types, groups, params, template, into, groupStack + group) + } + } + } + return + } + + entry.requireOnly(where, "name", "type", "optional", "param", "format") + val key = entry.string("name", where) + ?: fail(where, "every property needs a `name`, the key Mixpanel shows, or a `group`") + val format = entry.string("format", where) + if (format != null) { + if (entry.keySet().any { it in setOf("type", "optional", "param") }) { + fail(where, "the property \"$key\" has a `format`, so it's always text and can't have `type`, `optional` or `param`") + } + into += Property.Format(key, template(format)) + return + } + + val word = entry.string("type", where) + ?: fail(where, "the property \"$key\" needs a `type`: ${TYPE_WORDS.keys.joinToString()}, or a list name") + val type = types.resolve(word, where, "the property \"$key\"") + val name = entry.string("param", where) + ?: (camelCase(key) + if (type == ParamType.Whole) DURATION_SUFFIX else "") + val param = params.add(Param(name, type, entry.boolean("optional", where)), "the property \"$key\"") + into += Property.Value(key, param) + } + + /** + * Splits `Token Purchase With {method}` into literal text and parameter slots. A placeholder + * is a text parameter unless the entry's `placeholders` gives it a list, and one that appears + * twice reads the same parameter. + */ + private fun template( + text: String, + where: String, + placeholderTypes: Map, + params: Params, + used: MutableSet, + ): Template { + val parts = mutableListOf() + var index = 0 + while (index < text.length) { + val open = text.indexOf('{', index) + val close = text.indexOf('}', index) + if (open < 0) { + if (close >= 0) fail(where, "\"$text\" has a `}` with no `{` before it") + parts += Template.Part.Literal(text.substring(index)) + break + } + if (close in index until open) fail(where, "\"$text\" has a `}` with no `{` before it") + val end = text.indexOf('}', open) + if (end < 0) fail(where, "\"$text\" has a `{` with no `}` after it") + if (open > index) parts += Template.Part.Literal(text.substring(index, open)) + val slot = text.substring(open + 1, end) + if (!MEMBER_NAME.matches(slot) || slot in KEYWORDS) { + fail(where, "{$slot} in \"$text\" must be one word, or words run together, starting lowercase, such as {method}") + } + val param = if (slot in used) { + params.find(slot)!! + } else { + params.add(Param(slot, placeholderTypes[slot] ?: ParamType.Text, false), "{$slot}") + } + used += slot + parts += Template.Part.Slot(param) + index = end + 1 + } + return Template(parts) + } + + /** Collects an entry's parameters in the order its name and properties produce them. */ + private class Params(private val where: String) { + val all = mutableListOf() + + fun find(name: String) = all.firstOrNull { it.name == name } + + fun add(param: Param, source: String): Param { + if (!MEMBER_NAME.matches(param.name) || param.name in KEYWORDS) { + fail(where, "$source can't be passed as `${param.name}`, so add `param = \"...\"`, a name such as \"choice\"") + } + find(param.name)?.let { + fail(where, "two properties would both be passed as `${param.name}`, so add `param = \"...\"` to one of them") + } + all += param + return param + } + } + + /** The type words and the catalogue's lists. */ + private class Types(private val lists: List) { + fun resolve(word: String, where: String, subject: String): ParamType { + TYPE_WORDS[word]?.let { return it } + if (word in lists) return ParamType.Enum(word) + val known = "The types are ${TYPE_WORDS.keys.joinToString()}, or one of the lists: " + + lists.joinToString().ifEmpty { "(none yet)" } + if (word.firstOrNull()?.isUpperCase() == true) { + fail(where, "$subject uses the list \"$word\", which doesn't exist. Add it as [lists.$word], or check the spelling. $known") + } + fail(where, "$subject has the type \"$word\", which isn't a type. $known") + } + } + + private fun words(text: String) = text.split(WORD_BREAK).filter { it.isNotEmpty() } + + /** `CHAT_GATE` → `Chat Gate`, as an example wire string. */ + private fun titleCase(name: String) = + words(name).joinToString(" ") { it.lowercase().replaceFirstChar(Char::uppercaseChar) } + + /** `Owner Public Key` → `ownerPublicKey`, `URL` → `url`. */ + private fun camelCase(text: String) = words(text).mapIndexed { index, word -> + val lower = word.lowercase() + if (index == 0) lower else lower.replaceFirstChar(Char::uppercaseChar) + }.joinToString("") + + /** `Add Money` → `AddMoneyEvents`; a domain already run together keeps its capitals. */ + private fun objectName(domain: String) = + words(domain).joinToString("") { it.replaceFirstChar(Char::uppercaseChar) } + "Events" + + private fun TomlTable.requireOnly(where: String, vararg allowed: String) { + val unknown = keySet() - allowed.toSet() + if (unknown.isNotEmpty()) { + fail( + where, + "${unknown.joinToString { "`$it`" }} isn't something this entry can have. " + + "Check the spelling; it can have ${allowed.joinToString { "`$it`" }}", + ) + } + } + + private fun TomlTable.string(key: String, where: String): String? = when (val value = get(listOf(key))) { + null -> null + is String -> value + else -> fail(where, "`$key` must be text in quotes") + } + + private fun TomlTable.boolean(key: String, where: String): Boolean = when (val value = get(listOf(key))) { + null -> false + is Boolean -> value + else -> fail(where, "`$key` must be true or false, without quotes") + } + + private fun TomlTable.table(key: String, where: String): TomlTable? = when (val value = get(listOf(key))) { + null -> null + is TomlTable -> value + else -> fail(where, "`$key` must be a table") + } + + private fun TomlTable.subtables(key: String, where: String): List> { + val parent = table(key, where) ?: return emptyList() + return parent.keySet().map { name -> + name to (parent.table(name, "$key.$name") ?: fail("$key.$name", "must be a table")) + } + } + + private fun TomlTable.array(key: String, where: String): TomlArray? = when (val value = get(listOf(key))) { + null -> null + is TomlArray -> value + else -> fail(where, "`$key` must be a list in square brackets") + } + + private fun TomlTable.tables(key: String, where: String): List = + array(key, where)?.toList()?.map { it as? TomlTable ?: fail(where, "every entry in `$key` must be { ... }") } + .orEmpty() + + private fun fail(where: String, message: String): Nothing = + throw CatalogueException(if (where == FILE) "$FILE: $message" else "$FILE: $where: $message") +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt new file mode 100644 index 0000000000..f5c8cfd45b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt @@ -0,0 +1,45 @@ +package com.getcode.buildlogic.analytics + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the analytics builders and value enums from `events.toml`. + * + * The output directory is cleared first, so an entry removed from the catalogue removes its + * generated file rather than leaving a stale builder behind. + */ +@CacheableTask +abstract class GenerateAnalyticsEvents : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val catalogue: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val sources = try { + AnalyticsCatalogue.emit(AnalyticsCatalogue.parse(catalogue.get().asFile.readText())) + } catch (malformed: CatalogueException) { + throw GradleException(malformed.message.orEmpty(), malformed) + } + + val root = outputDirectory.get().asFile + root.deleteRecursively() + root.mkdirs() + sources.forEach { (path, source) -> + root.resolve(path).apply { parentFile.mkdirs() }.writeText(source) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/KotlinEmitter.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/KotlinEmitter.kt new file mode 100644 index 0000000000..bff8f2814f --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/KotlinEmitter.kt @@ -0,0 +1,187 @@ +package com.getcode.buildlogic.analytics + +/** + * Writes a [Catalogue] as Kotlin, in the shapes the module's builders were first written in by + * hand: one `enum class X(val value: String)` per value enum, `PeopleCounter`, and one + * `object XEvents` per domain whose builders call the hand-written `event(name) { ... }` DSL. + * + * Output is keyed by path relative to the source root. Enums go in the catalogue's package and + * builder objects in its `events` subpackage. + */ +internal object KotlinEmitter { + + private const val HEADER = "// Generated from events.toml -- do not edit." + private const val INDENT = " " + private const val LINE_LIMIT = 100 + + fun emit(catalogue: Catalogue): Map { + val base = catalogue.packageName + val eventsPackage = "$base.events" + val files = linkedMapOf() + + catalogue.enums.forEach { enum -> + files[path(base, enum.name)] = enumFile(base, enum.name, "value", enum.doc, enum.cases) + } + if (catalogue.counters.isNotEmpty()) { + files[path(base, "PeopleCounter")] = enumFile( + packageName = base, + name = "PeopleCounter", + property = "key", + doc = "Cumulative per-user counters, stored as Mixpanel people properties.", + cases = catalogue.counters, + ) + } + catalogue.domains.forEach { domain -> + files[path(eventsPackage, domain.name)] = domainFile(base, eventsPackage, domain) + } + return files + } + + private fun path(packageName: String, type: String) = packageName.replace('.', '/') + "/$type.kt" + + private fun enumFile(packageName: String, name: String, property: String, doc: String?, cases: List) = + buildString { + appendLine("package $packageName") + appendLine() + appendLine(HEADER) + appendLine() + append(kdoc(listOfNotNull(doc), indent = "")) + appendLine("enum class $name(val $property: String) {") + cases.forEachIndexed { index, case -> + val documented = case.drift != null + val afterDocumented = index > 0 && cases[index - 1].drift != null + if (index > 0 && (documented || afterDocumented)) appendLine() + append(kdoc(listOfNotNull(case.drift?.let { "DRIFT: $it" }), INDENT)) + appendLine("$INDENT${case.name}(${literal(case.value)}),") + } + appendLine("}") + } + + private fun domainFile(base: String, packageName: String, domain: Domain) = buildString { + val imports = sortedSetOf("$base.AnalyticsEvent", "$base.event") + domain.builders.forEach { builder -> + builder.params.forEach { param -> + when (val type = param.type) { + is ParamType.Enum -> imports += "$base.${type.name}" + ParamType.Amount -> imports += "$base.Amount" + else -> Unit + } + } + if (builder.properties.any { it is Property.AmountBlock }) imports += "$base.amount" + } + + appendLine("package $packageName") + appendLine() + imports.forEach { appendLine("import $it") } + appendLine() + appendLine(HEADER) + appendLine() + append(kdoc(listOfNotNull(domain.doc), indent = "")) + appendLine("object ${domain.name} {") + domain.builders.forEachIndexed { index, builder -> + if (index > 0) appendLine() + append(builder(builder)) + } + appendLine("}") + } + + private fun builder(builder: Builder) = buildString { + append(kdoc(listOfNotNull(builder.doc, builder.drift?.let { "DRIFT: $it" }), INDENT)) + val params = builder.params.joinToString { "${it.name}: ${it.kotlinType}" } + val call = "event(${template(builder.event)})" + if (builder.properties.isEmpty()) { + appendLine("${INDENT}fun ${builder.name}($params): AnalyticsEvent = $call") + return@buildString + } + appendLine("${INDENT}fun ${builder.name}($params): AnalyticsEvent =") + appendLine("$INDENT$INDENT$call {") + builder.properties.forEach { appendLine("$INDENT$INDENT$INDENT${property(it)}") } + appendLine("$INDENT$INDENT}") + } + + private fun property(property: Property): String = when (property) { + is Property.AmountBlock -> "amount(${property.param.name})" + is Property.Format -> "text(${literal(property.key)}, ${template(property.template)})" + is Property.Value -> { + val param = property.param + val access = if (param.nullable) "?." else "." + val key = literal(property.key) + when (param.type) { + ParamType.Text -> "text($key, ${param.name})" + is ParamType.Enum -> "text($key, ${param.name}${access}value)" + ParamType.Decimal, ParamType.Whole -> "number($key, ${param.name})" + ParamType.SmallWhole -> "number($key, ${param.name}${access}toDouble())" + ParamType.Flag -> "flag($key, ${param.name})" + ParamType.Amount -> error("the parser sends an Amount only through the amount group") + } + } + } + + private fun template(template: Template): String = template.parts.joinToString("", "\"", "\"") { part -> + when (part) { + is Template.Part.Literal -> escape(part.text) + is Template.Part.Slot -> { + val param = part.param + if (param.type is ParamType.Enum) "\${${param.name}.value}" else "\${${param.name}}" + } + } + } + + private fun literal(text: String) = "\"${escape(text)}\"" + + private fun escape(text: String) = buildString(text.length) { + text.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '$' -> append("\\$") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) + } + } + } + + /** + * One paragraph that fits goes on one line; otherwise each paragraph is word-wrapped at + * [LINE_LIMIT] with a blank line between. A blank line inside a paragraph's text starts a + * new paragraph, so a catalogue entry can carry an `@param` line of its own. + */ + private fun kdoc(paragraphs: List, indent: String): String { + val blocks = paragraphs + .flatMap { it.split(Regex("""\n\s*\n""")) } + .map { it.trim().replace(Regex("""\s+"""), " ").replace("*/", "* /") } + .filter { it.isNotEmpty() } + if (blocks.isEmpty()) return "" + if (blocks.size == 1) { + val single = "$indent/** ${blocks[0]} */" + if (single.length <= LINE_LIMIT) return single + "\n" + } + + val prefix = "$indent * " + return buildString { + appendLine("$indent/**") + blocks.forEachIndexed { index, block -> + if (index > 0) appendLine("$indent *") + wrap(block, LINE_LIMIT - prefix.length).forEach { appendLine(prefix + it) } + } + appendLine("$indent */") + } + } + + private fun wrap(text: String, width: Int): List { + val lines = mutableListOf() + val line = StringBuilder() + text.split(' ').forEach { word -> + if (line.isNotEmpty() && line.length + 1 + word.length > width) { + lines += line.toString() + line.clear() + } + if (line.isNotEmpty()) line.append(' ') + line.append(word) + } + if (line.isNotEmpty()) lines += line.toString() + return lines + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt new file mode 100644 index 0000000000..5b21cadca3 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt @@ -0,0 +1,164 @@ +package com.getcode.buildlogic.analytics + +/** + * Writes a [Catalogue] as `EVENTS.md`: the same catalogue as `events.toml`, for readers who + * don't read TOML. Types are named with the catalogue's own words, so a reader can copy one + * back into an entry. + */ +internal object MarkdownEmitter { + + /** What `amount(...)` in the hand-written `Amount.kt` sends, in its order. */ + private val AMOUNT_ROWS = listOf( + Triple("Fiat", "decimal", false), + Triple("Currency", "text", false), + Triple("USDC", "decimal", false), + Triple("Quarks", "count", false), + Triple("Exchange Rate", "decimal", true), + Triple("Mint", "text", true), + ) + + fun emit(catalogue: Catalogue): String = buildString { + appendLine( + """ + # Analytics events + + + + Every event both apps can send to Mixpanel, and the properties each one carries. To add + or change an event, edit [events.toml](events.toml) in a pull request. + + Types: **text** is words, **yes/no** is true or false, **count** is a whole number, + **decimal** is a number with a fraction, **duration** is milliseconds, and a **list** + is one value from the [Lists](#lists) below. An optional property is left out when the + app has no value for it. + """.trimIndent(), + ) + + catalogue.domains.forEach { domain -> + appendLine() + appendLine("## ${domain.title}") + domain.doc?.let { appendLine(); appendLine(it) } + // Two builders may send one name; they share a heading so the name has one anchor. + domain.builders.groupBy { name(it.event) }.forEach { (name, forms) -> event(name, forms) } + } + + val usesAmount = catalogue.domains.any { domain -> + domain.builders.any { builder -> builder.properties.any { it is Property.AmountBlock } } + } + if (usesAmount) { + appendLine() + appendLine("## Groups") + appendLine() + appendLine("### amount") + appendLine() + appendLine("Sent by events that carry an amount of money. Defined in `Amount.kt`.") + appendLine() + appendLine("| Property | Type | Optional |") + appendLine("|---|---|---|") + AMOUNT_ROWS.forEach { (key, type, optional) -> appendLine(tableRow(key, type, yesNo(optional))) } + } + + if (catalogue.enums.isNotEmpty()) { + appendLine() + appendLine("## Lists") + catalogue.enums.forEach { list -> + appendLine() + appendLine("### ${list.name}") + list.doc?.let { appendLine(); appendLine(it) } + appendLine() + appendLine("| Value sent | Name in code | Drift |") + appendLine("|---|---|---|") + list.cases.forEach { case -> + appendLine(tableRow(cell(case.value), "`${case.name}`", cell(case.drift.orEmpty()))) + } + } + } + + if (catalogue.counters.isNotEmpty()) { + appendLine() + appendLine("## People counters") + appendLine() + appendLine("Cumulative per-user counters, stored as Mixpanel people properties.") + appendLine() + appendLine("| Counter | Name in code |") + appendLine("|---|---|") + catalogue.counters.forEach { appendLine(tableRow(cell(it.value), "`${it.name}`")) } + } + } + + private fun StringBuilder.event(name: String, forms: List) { + appendLine() + appendLine("### $name") + if (forms.size > 1) { + appendLine() + appendLine("Sent in ${forms.size} forms.") + } + forms.forEach { form(it) } + } + + private fun StringBuilder.form(builder: Builder) { + builder.doc?.let { appendLine(); appendLine(it) } + + val listSlots = builder.event.parts + .filterIsInstance() + .filter { it.param.type is ParamType.Enum } + listSlots.forEach { slot -> + val list = (slot.param.type as ParamType.Enum).name + appendLine() + appendLine("`{${slot.param.name}}` in the name is one of [$list](#${anchor(list)}).") + } + + appendLine() + if (builder.properties.isEmpty()) { + appendLine("No properties.") + } else { + appendLine("| Property | Type | Optional | Values |") + appendLine("|---|---|---|---|") + builder.properties.forEach { appendLine(row(it)) } + } + + builder.drift?.let { appendLine(); appendLine("> **Drift:** $it") } + } + + private fun row(property: Property): String = when (property) { + is Property.AmountBlock -> + tableRow("amount", "group", yesNo(property.param.nullable), "[amount](#amount)") + is Property.Format -> + tableRow(cell(property.key), "text", "no", "`${cell(name(property.template))}`") + is Property.Value -> { + val param = property.param + val values = (param.type as? ParamType.Enum)?.let { "[${it.name}](#${anchor(it.name)})" }.orEmpty() + tableRow(cell(property.key), word(param.type), yesNo(param.nullable), values) + } + } + + private fun word(type: ParamType): String = when (type) { + ParamType.Text -> "text" + ParamType.Flag -> "yes/no" + ParamType.SmallWhole -> "count" + ParamType.Decimal -> "decimal" + ParamType.Whole -> "duration" + is ParamType.Enum -> "list" + ParamType.Amount -> error("the parser sends an Amount only through the amount group") + } + + /** The template as the catalogue writes it, with `{placeholder}` slots. */ + private fun name(template: Template) = template.parts.joinToString("") { part -> + when (part) { + is Template.Part.Literal -> part.text + is Template.Part.Slot -> "{${part.param.name}}" + } + } + + /** An empty cell is written `|` alone, without the padding a filled one gets. */ + private fun tableRow(vararg cells: String) = + "|" + cells.joinToString("") { if (it.isEmpty()) " |" else " $it |" } + + private fun yesNo(value: Boolean) = if (value) "yes" else "no" + + /** GitHub's heading anchor for a list name, which is a plain identifier. */ + private fun anchor(name: String) = name.lowercase() + + private fun cell(text: String) = text.replace("|", "\\|").replace("\n", " ") +} diff --git a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt new file mode 100644 index 0000000000..02f2d83a5c --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt @@ -0,0 +1,583 @@ +package com.getcode.buildlogic.analytics + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AnalyticsCatalogueTest { + + private fun generate(toml: String): Map = + AnalyticsCatalogue.emit(AnalyticsCatalogue.parse("package = \"com.example\"\n$toml")) + + private fun rejected(toml: String): String = + assertFailsWith { generate(toml) }.message.orEmpty() + + @Test + fun listsKeepValueOrderAndCarryDescriptionAndDriftAsKDoc() { + val source = generate( + """ + [lists.Method] + description = "How the user paid." + [lists.Method.values] + ZULU = "Zulu" + ALPHA = { value = "Alpha", drift = "iOS has no Alpha." } + """.trimIndent(), + ).getValue("com/example/Method.kt") + + assertEquals( + """ + package com.example + + // Generated from events.toml -- do not edit. + + /** How the user paid. */ + enum class Method(val value: String) { + ZULU("Zulu"), + + /** DRIFT: iOS has no Alpha. */ + ALPHA("Alpha"), + } + + """.trimIndent(), + source, + ) + } + + @Test + fun eachTypeWordBecomesAParameterAndItsPropertyCall() { + val source = generate( + """ + [lists.Kind.values] + ONE = "One" + + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [ + { name = "Kind", type = "Kind" }, + { name = "Label", type = "text" }, + { name = "Rate", type = "decimal" }, + { name = "Time", type = "duration" }, + { name = "Tier", type = "count" }, + { name = "Done", type = "yes/no" }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertEquals( + """ + package com.example.events + + import com.example.AnalyticsEvent + import com.example.Kind + import com.example.event + + // Generated from events.toml -- do not edit. + + object ThingEvents { + fun thingHappened(kind: Kind, label: String, rate: Double, timeMillis: Long, tier: Int, done: Boolean): AnalyticsEvent = + event("Thing Happened") { + text("Kind", kind.value) + text("Label", label) + number("Rate", rate) + number("Time", timeMillis) + number("Tier", tier.toDouble()) + flag("Done", done) + } + } + + """.trimIndent(), + source, + ) + } + + @Test + fun optionalPropertiesPassNullThroughSoTheBuilderOmitsThem() { + val source = generate( + """ + [lists.Kind.values] + ONE = "One" + + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [ + { name = "Kind", type = "Kind", optional = true }, + { name = "Error", type = "text", optional = true }, + { name = "Tier", type = "count", optional = true }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "fun thingHappened(kind: Kind?, error: String?, tier: Int?): AnalyticsEvent =") + assertContains(source, "text(\"Kind\", kind?.value)") + assertContains(source, "text(\"Error\", error)") + assertContains(source, "number(\"Tier\", tier?.toDouble())") + } + + @Test + fun anEventWithNoPropertiesHasNoBlock() { + val source = generate( + """ + [[event]] + domain = "Thing" + name = "Thing Started" + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, " fun thingStarted(): AnalyticsEvent = event(\"Thing Started\")\n") + } + + @Test + fun theBuilderNameDropsTheDomainPrefixAndTheDomainNamesTheObject() { + val files = generate( + """ + [[event]] + domain = "Add Money" + name = "Add Money: Method Selected" + """.trimIndent(), + ) + + val source = files.getValue("com/example/events/AddMoneyEvents.kt") + assertContains(source, "object AddMoneyEvents {") + assertContains(source, "fun methodSelected(): AnalyticsEvent = event(\"Add Money: Method Selected\")") + } + + @Test + fun theParameterNameIsTheKeyInCamelCase() { + val source = generate( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [ + { name = "URL", type = "text" }, + { name = "Call Site", type = "text" }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "fun thingHappened(url: String, callSite: String): AnalyticsEvent =") + assertContains(source, "text(\"Call Site\", callSite)") + } + + @Test + fun builderParamAndOrderOverrideWhatIsDerived() { + val source = generate( + """ + [[event]] + domain = "Account" + name = "Create Account Payment" + builder = "payment" + order = ["owner", "price"] + properties = [ + { name = "Fiat", type = "decimal", param = "price" }, + { name = "Owner Public Key", type = "text", param = "owner" }, + ] + """.trimIndent(), + ).getValue("com/example/events/AccountEvents.kt") + + assertContains( + source, + """ + fun payment(owner: String, price: Double): AnalyticsEvent = + event("Create Account Payment") { + number("Fiat", price) + text("Owner Public Key", owner) + } + """.trimIndent().prependIndent(" "), + ) + } + + @Test + fun groupsExpandInPlaceAndTheAmountGroupCallsTheHandWrittenHelper() { + val source = generate( + """ + [lists.State.values] + SUCCESS = "Success" + + [groups.outcome] + properties = [ + { name = "State", type = "State" }, + { group = "amount", optional = true }, + { name = "Error", type = "text", optional = true }, + ] + + [[event]] + domain = "Thing" + name = "Grabbed" + properties = [ + { name = "Grab Time", type = "duration", optional = true }, + { group = "outcome" }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "import com.example.Amount\n") + assertContains(source, "import com.example.amount\n") + assertContains( + source, + """ + fun grabbed(grabTimeMillis: Long?, state: State, amount: Amount?, error: String?): AnalyticsEvent = + event("Grabbed") { + number("Grab Time", grabTimeMillis) + text("State", state.value) + amount(amount) + text("Error", error) + } + """.trimIndent().prependIndent(" "), + ) + } + + @Test + fun aPropertyMayRepeatOneTheAmountSendsOnlyWhenItHasIt() { + val source = generate( + """ + [[event]] + domain = "Swap" + name = "Token Sell" + properties = [{ name = "Mint", type = "text" }, { group = "amount" }] + """.trimIndent(), + ).getValue("com/example/events/SwapEvents.kt") + + assertContains(source, "fun tokenSell(mint: String, amount: Amount): AnalyticsEvent =") + assertContains(source, "text(\"Mint\", mint)\n amount(amount)") + } + + @Test + fun namePlaceholdersAreTextUnlessTypedAsAList() { + val source = generate( + """ + [lists.Method.values] + RESERVES = "Reserves" + + [[event]] + domain = "Swap" + name = "Token Purchase With {method} From {place}" + builder = "purchase" + placeholders = { method = "Method" } + """.trimIndent(), + ).getValue("com/example/events/SwapEvents.kt") + + assertContains( + source, + "fun purchase(method: Method, place: String): AnalyticsEvent = " + + "event(\"Token Purchase With \${method.value} From \${place}\")", + ) + } + + @Test + fun fixedFormatValuesInterpolateAndEscapeLiterals() { + val source = generate( + """ + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + properties = [{ name = "Error", format = "Failed to parse ${'$'}deeplink \"=>\" {url}" }] + """.trimIndent(), + ).getValue("com/example/events/DeeplinkEvents.kt") + + assertContains(source, "fun parse(url: String): AnalyticsEvent =") + assertContains(source, """text("Error", "Failed to parse \${'$'}deeplink \"=>\" ${'$'}{url}")""") + } + + @Test + fun twoBuildersMaySendOneEventName() { + val source = generate( + """ + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + builder = "parsed" + properties = [{ name = "Type", type = "text" }] + + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + builder = "parseFailed" + """.trimIndent(), + ).getValue("com/example/events/DeeplinkEvents.kt") + + assertContains(source, "fun parsed(type: String): AnalyticsEvent =") + assertContains(source, "fun parseFailed(): AnalyticsEvent = event(\"Deeplink: Parse\")") + } + + @Test + fun descriptionAndDriftBecomeKDocWrappedAtTheLineLimit() { + val source = generate( + """ + [domains] + Thing = "Things." + + [[event]] + domain = "Thing" + name = "Thing Happened" + description = "The user had no display name before this submission." + drift = "iOS sends Exchange Rate and no USDC, and fires on the request rather than after connecting to the wallet." + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains( + source, + """ + /** Things. */ + object ThingEvents { + /** + * The user had no display name before this submission. + * + * DRIFT: iOS sends Exchange Rate and no USDC, and fires on the request rather than after + * connecting to the wallet. + */ + fun thingHappened(): AnalyticsEvent = event("Thing Happened") + """.trimIndent(), + ) + } + + @Test + fun peopleCountersBecomeAKeyedEnum() { + val source = generate( + """ + [counters] + TIPS = "Tips Received" + MESSAGES = "Messages Received" + """.trimIndent(), + ).getValue("com/example/PeopleCounter.kt") + + assertContains( + source, + """ + /** Cumulative per-user counters, stored as Mixpanel people properties. */ + enum class PeopleCounter(val key: String) { + TIPS("Tips Received"), + MESSAGES("Messages Received"), + } + """.trimIndent(), + ) + } + + @Test + fun anEmptyCatalogueEmitsNothing() { + assertEquals(emptyMap(), generate("")) + } + + // Rejections. Each message names the event at fault and says what to change. + + @Test + fun rejectsTomlSyntaxErrors() { + assertContains(rejected("[lists.Method\n"), "events.toml") + } + + @Test + fun rejectsAnUnknownTypeWordAndListsTheWords() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Label", type = "strng" }] + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "strng") + assertContains(message, "text, yes/no, count, decimal, duration") + } + + @Test + fun rejectsAnUnknownListAndSaysWhereListsGo() { + val message = rejected( + """ + [lists.ChatType.values] + TIP = "Tip" + + [[event]] + domain = "Chat" + name = "Sent Message" + properties = [{ name = "Chat Type", type = "ChatTyp" }] + """.trimIndent(), + ) + assertContains(message, "\"Sent Message\"") + assertContains(message, "ChatTyp") + assertContains(message, "[lists.ChatTyp]") + assertContains(message, "ChatType") + } + + @Test + fun rejectsTwoEventsThatWouldGetTheSameBuilderName() { + val message = rejected( + """ + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + properties = [{ name = "Type", type = "text" }] + """.trimIndent(), + ) + assertContains(message, "\"Deeplink: Parse\"") + assertContains(message, "`parse`") + assertContains(message, "add `builder") + } + + @Test + fun rejectsANameThatGivesNoBuilderName() { + val message = rejected( + """ + [lists.Step.values] + ONE = "One" + + [[event]] + domain = "Onramp" + name = "Onramp: {step}" + placeholders = { step = "Step" } + """.trimIndent(), + ) + assertContains(message, "\"Onramp: {step}\"") + assertContains(message, "add `builder") + } + + @Test + fun rejectsTwoPropertiesThatWouldGetTheSameParameterName() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Call Site", type = "text" }, { name = "Call-Site", type = "text" }] + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "`callSite`") + assertContains(message, "add `param") + } + + @Test + fun rejectsAnOrderThatDoesNotNameEveryParameter() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + order = ["label", "labl"] + properties = [{ name = "Label", type = "text" }, { name = "Tier", type = "count" }] + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "missing tier") + assertContains(message, "labl") + } + + @Test + fun rejectsAListValueWithNoWireString() { + val empty = rejected("[lists.Method.values]\nRESERVES = \"\"") + assertContains(empty, "Method") + assertContains(empty, "RESERVES") + assertContains(empty, "the text Mixpanel receives") + + val missing = rejected("[lists.Method.values]\nRESERVES = { drift = \"iOS has none.\" }") + assertContains(missing, "RESERVES") + } + + @Test + fun rejectsAPlaceholderThatIsNotTextOrAList() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing {tier}" + builder = "thing" + placeholders = { tier = "count" } + """.trimIndent(), + ) + assertContains(message, "\"Thing {tier}\"") + assertContains(message, "{tier}") + } + + @Test + fun rejectsAPlaceholderTypeNothingUses() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + placeholders = { method = "text" } + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "method") + } + + @Test + fun rejectsAnUnknownKeySoATypoIsNotSilentlyIgnored() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + drfit = "iOS sends nothing." + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "drfit") + } + + @Test + fun rejectsAnEventWithNoDomain() { + val message = rejected("[[event]]\nname = \"Thing Happened\"") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "domain") + } + + @Test + fun rejectsAnUnknownGroup() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ group = "outcom" }] + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "outcom") + } + + @Test + fun rejectsAPropertyRepeatingOneTheAmountAlwaysSends() { + val message = rejected( + """ + [[event]] + domain = "Swap" + name = "Token Sell" + properties = [{ name = "Fiat", type = "decimal" }, { group = "amount" }] + """.trimIndent(), + ) + assertContains(message, "\"Token Sell\"") + assertContains(message, "Fiat") + } + + @Test + fun rejectsTheSameKeyTwiceInOneEvent() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Label", type = "text" }, { name = "Label", type = "text", param = "other" }] + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "Label") + } + + @Test + fun rejectsADomainDescriptionNoEventUses() { + val message = rejected("[domains]\nWalet = \"The Phantom flow.\"") + assertContains(message, "Walet") + } + + private fun assertContains(actual: String, expected: String) = + assertTrue(expected in actual, "expected to find:\n$expected\n\nin:\n$actual") +} diff --git a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt new file mode 100644 index 0000000000..e3cd8996bd --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt @@ -0,0 +1,215 @@ +package com.getcode.buildlogic.analytics + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EventsPageTest { + + private fun page(toml: String): String = + AnalyticsCatalogue.document(AnalyticsCatalogue.parse("package = \"com.example\"\n$toml")) + + @Test + fun thePageHasADomainSectionPerEventTablesListsGroupsAndCounters() { + val page = page( + """ + [lists.Method] + description = "How the user paid." + [lists.Method.values] + CARD = "Card" + BANK = { value = "Bank", drift = "iOS has no Bank." } + + [lists.State.values] + SUCCESS = "Success" + + [groups.outcome] + properties = [ + { name = "State", type = "State" }, + { group = "amount", optional = true }, + { name = "Error", type = "text", optional = true }, + ] + + [counters] + TIPS = "Tips Received" + + [domains] + Thing = "Things the user does." + + [[event]] + domain = "Thing" + name = "Thing: Bought With {method}" + placeholders = { method = "Method" } + description = "The user bought a thing." + drift = "iOS sends this without State." + properties = [ + { name = "Label", type = "text" }, + { name = "Tier", type = "count", optional = true }, + { name = "Rate", type = "decimal" }, + { name = "Time", type = "duration" }, + { name = "Done", type = "yes/no" }, + { group = "outcome" }, + ] + + [[event]] + domain = "Thing" + name = "Thing: Parse" + properties = [{ name = "Error", format = "Failed to parse => {url}" }] + + [[event]] + domain = "Thing" + name = "Thing: Started" + """.trimIndent(), + ) + + assertEquals( + """ + # Analytics events + + + + Every event both apps can send to Mixpanel, and the properties each one carries. To add + or change an event, edit [events.toml](events.toml) in a pull request. + + Types: **text** is words, **yes/no** is true or false, **count** is a whole number, + **decimal** is a number with a fraction, **duration** is milliseconds, and a **list** + is one value from the [Lists](#lists) below. An optional property is left out when the + app has no value for it. + + ## Thing + + Things the user does. + + ### Thing: Bought With {method} + + The user bought a thing. + + `{method}` in the name is one of [Method](#method). + + | Property | Type | Optional | Values | + |---|---|---|---| + | Label | text | no | | + | Tier | count | yes | | + | Rate | decimal | no | | + | Time | duration | no | | + | Done | yes/no | no | | + | State | list | no | [State](#state) | + | amount | group | yes | [amount](#amount) | + | Error | text | yes | | + + > **Drift:** iOS sends this without State. + + ### Thing: Parse + + | Property | Type | Optional | Values | + |---|---|---|---| + | Error | text | no | `Failed to parse => {url}` | + + ### Thing: Started + + No properties. + + ## Groups + + ### amount + + Sent by events that carry an amount of money. Defined in `Amount.kt`. + + | Property | Type | Optional | + |---|---|---| + | Fiat | decimal | no | + | Currency | text | no | + | USDC | decimal | no | + | Quarks | count | no | + | Exchange Rate | decimal | yes | + | Mint | text | yes | + + ## Lists + + ### Method + + How the user paid. + + | Value sent | Name in code | Drift | + |---|---|---| + | Card | `CARD` | | + | Bank | `BANK` | iOS has no Bank. | + + ### State + + | Value sent | Name in code | Drift | + |---|---|---| + | Success | `SUCCESS` | | + + ## People counters + + Cumulative per-user counters, stored as Mixpanel people properties. + + | Counter | Name in code | + |---|---| + | Tips Received | `TIPS` | + + """.trimIndent(), + page, + ) + } + + @Test + fun pipesInCellsAreEscapedSoTheTableHoldsItsShape() { + val page = page( + """ + [[event]] + domain = "Thing" + name = "Thing: Parse" + properties = [{ name = "Error", format = "a | b {url}" }] + """.trimIndent(), + ) + + assertTrue("| Error | text | no | `a \\| b {url}` |" in page, page) + } + + @Test + fun twoBuildersSendingOneNameShareOneHeadingSoItHasOneAnchor() { + val page = page( + """ + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + builder = "parsed" + description = "The link parsed." + properties = [{ name = "Type", type = "text" }] + + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + builder = "parseFailed" + description = "The link didn't parse." + """.trimIndent(), + ) + + assertTrue( + """ + ### Deeplink: Parse + + Sent in 2 forms. + + The link parsed. + + | Property | Type | Optional | Values | + |---|---|---|---| + | Type | text | no | | + + The link didn't parse. + + No properties. + + """.trimIndent() in page, + page, + ) + } + + @Test + fun anEmptyCatalogueStillWritesTheIntroduction() { + assertTrue(page("").startsWith("# Analytics events\n")) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 625977ef95..93b4573fc8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -88,4 +88,8 @@ tasks.register("flipcashTestDebug") { dependsOn(androidUnitTestModules.map { "$it:testDebugUnitTest" }) dependsOn(jvmUnitTestModules.map { "$it:test" }) dependsOn(kmpUnitTestModules.map { "$it:testAndroidHostTest" }) + // build-logic's own tests, which cover the analytics catalogue generator. + dependsOn(gradle.includedBuild("build-logic").task(":convention:test")) + // EVENTS.md is generated from events.toml but checked in; fail when someone edits one alone. + dependsOn(":libs:analytics-events:checkAnalyticsEventsPage") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c18427bbf9..1915e5496d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ androidx-benchmark-macro = "1.5.0" androidx-profileinstaller = "1.4.1" androidx-test-runner = "1.7.0" junit = "4.13.2" +tomlj = "1.1.1" androidx-junit = "1.3.0" espresso = "3.7.0" mockito-kotlin = "6.3.0" @@ -295,6 +296,7 @@ androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstall androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } junit = { module = "junit:junit", version.ref = "junit" } +tomlj = { module = "org.tomlj:tomlj", version.ref = "tomlj" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" } espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso" } @@ -345,6 +347,7 @@ flipcash-android-feature = { id = "flipcash.android.feature" } flipcash-android-ed25519-shadow = { id = "flipcash.android.ed25519.shadow" } flipcash-kmp-library = { id = "flipcash.kmp.library" } flipcash-kmp-test-fixtures = { id = "flipcash.kmp.test.fixtures" } +flipcash-analytics-catalogue = { id = "flipcash.analytics.catalogue" } android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index f115b5c75e..cb9fb0a06f 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { export(project(":libs:encryption:mnemonic")) export(project(":libs:currency-math:discrete-curve")) export(project(":libs:reporting")) + export(project(":libs:analytics-events")) } } @@ -61,6 +62,7 @@ kotlin { api(project(":libs:encryption:mnemonic")) api(project(":libs:currency-math:discrete-curve")) api(project(":libs:reporting")) + api(project(":libs:analytics-events")) } } } diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift new file mode 100644 index 0000000000..b4cf189e91 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -0,0 +1,62 @@ +import SharedCore + +/// One event, built by the shared Kotlin. Named `TrackedEvent` rather than re-exporting +/// `AnalyticsEvent` because the iOS app already declares `protocol AnalyticsEvent`. +public typealias TrackedEvent = SharedCore.AnalyticsEvent + +// The value vocabulary. Deliberately typealiases and not Swift enums, as with `ReportReason`: +// the wire strings are written down once, in +// `libs/analytics-events/events.toml`. Kotlin's `Button`, `State` and +// `Amount` are renamed here so they don't read as SwiftUI's `Button` and `@State`, or as +// one of the app's money types. +public typealias ChatType = SharedCore.ChatType +public typealias EventState = SharedCore.State +public typealias AddMoneySource = SharedCore.AddMoneySource +public typealias AddMoneyMethod = SharedCore.AddMoneyMethod +public typealias DisplayNameSource = SharedCore.DisplayNameSource +public typealias TokenInfoSource = SharedCore.TokenInfoSource +public typealias PurchaseMethod = SharedCore.PurchaseMethod +public typealias WalletProvider = SharedCore.WalletProvider +public typealias CashLinkChoice = SharedCore.CashLinkChoice +public typealias OnrampStep = SharedCore.OnrampStep +public typealias AnalyticsButton = SharedCore.Button +public typealias AnalyticsAmount = SharedCore.Amount +public typealias PeopleCounter = SharedCore.PeopleCounter + +// The builders. Each is a Kotlin `object`, so calls go through `.shared`. +public typealias ChatEvents = SharedCore.ChatEvents +public typealias TransferEvents = SharedCore.TransferEvents +public typealias AddMoneyEvents = SharedCore.AddMoneyEvents +public typealias OnrampEvents = SharedCore.OnrampEvents +public typealias WalletEvents = SharedCore.WalletEvents +public typealias TokenInfoEvents = SharedCore.TokenInfoEvents +public typealias SwapEvents = SharedCore.SwapEvents +public typealias ScanEvents = SharedCore.ScanEvents +public typealias DeeplinkEvents = SharedCore.DeeplinkEvents +public typealias DisplayNameEvents = SharedCore.DisplayNameEvents +public typealias ErrorModalEvents = SharedCore.ErrorModalEvents +public typealias AccountEvents = SharedCore.AccountEvents +public typealias ButtonEvents = SharedCore.ButtonEvents + +/// A property value as the iOS sender needs it. Mixpanel is not a dependency of this +/// package, so the app maps these to `MixpanelType` itself. +public enum AnalyticsScalar: Equatable, Sendable { + case text(String) + case number(Double) + case flag(Bool) +} + +public extension TrackedEvent { + /// Kotlin's sealed `PropertyValue` bridges as a protocol with no exhaustiveness, so a + /// fourth case crashes here, in the facade's own tests, rather than sending nothing. + var scalarProperties: [String: AnalyticsScalar] { + properties.mapValues { value in + switch value { + case let v as PropertyValueText: .text(v.value) + case let v as PropertyValueNumber: .number(v.value) + case let v as PropertyValueFlag: .flag(v.value) + default: preconditionFailure("PropertyValue gained a case the facade does not map: \(value)") + } + } + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift new file mode 100644 index 0000000000..9aa0c9147c --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import SharedCoreKit + +@Suite struct AnalyticsEventsTests { + + @Test func flattensEachValueTypeToASwiftScalar() { + let event: TrackedEvent = ChatEvents.shared.sentMessage(chatType: .group, error: "e") + #expect(event.name == "Sent Message") + #expect(event.scalarProperties == ["Chat Type": .text("Group"), "Error": .text("e")]) + } + + @Test func carriesNumbersAndFlagsAsTheirOwnTypes() { + let event = ScanEvents.shared.galleryFailed(timeMillis: 1500, exhausted: true) + #expect(event.scalarProperties["Time"] == .number(1500)) + #expect(event.scalarProperties["Exhausted"] == .flag(true)) + } + + @Test func omitsAbsentOptionalProperties() { + #expect(ChatEvents.shared.sentMessage(chatType: .tip, error: nil).scalarProperties.keys.sorted() == ["Chat Type"]) + } + + @Test func buildsAnAmountBearingEventFromSwift() { + let amount = AnalyticsAmount(fiat: 5, currency: "USD", usdc: 5, quarks: 5_000_000, exchangeRate: nil, mint: nil) + let event = TransferEvents.shared.sentTip(state: EventState.success, amount: amount, error: nil) + #expect(event.scalarProperties["State"] == .text("Success")) + #expect(event.scalarProperties["Quarks"] == .number(5_000_000)) + } + + @Test func tapsAButtonUnderItsFacadeName() { + #expect(ButtonEvents.shared.tapped(button: AnalyticsButton.shareTokenInfo).name == "Button: Share Token Info") + } + + /// A value added in Kotlin compiles on both sides and would be silently missing from any + /// Swift switch that lists the cases; this is the check that it is not. + @Test func tokenInfoSourcesAreTheFiveTheSpecNames() { + #expect(TokenInfoSource.entries.map(\.value) == ["Deeplink", "Wallet", "Discovery", "Chat", "Chat Gate"]) + } +} diff --git a/libs/analytics-events/.gitignore b/libs/analytics-events/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libs/analytics-events/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md new file mode 100644 index 0000000000..94a4dd5259 --- /dev/null +++ b/libs/analytics-events/EVENTS.md @@ -0,0 +1,549 @@ +# Analytics events + + + +Every event both apps can send to Mixpanel, and the properties each one carries. To add +or change an event, edit [events.toml](events.toml) in a pull request. + +Types: **text** is words, **yes/no** is true or false, **count** is a whole number, +**decimal** is a number with a fraction, **duration** is milliseconds, and a **list** +is one value from the [Lists](#lists) below. An optional property is left out when the +app has no value for it. + +## Chat + +### Sent Message + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| Error | text | yes | | + +### Tip Received + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Message Received + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | + +## Transfer + +### Grab Bill Start + +No properties. + +### Give Bill Start + +No properties. + +### Grab Bill + +| Property | Type | Optional | Values | +|---|---|---|---| +| Grab Time | duration | yes | | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Grab Time in seconds, not milliseconds, and on failure sends only Fiat and Currency of the amount (see Amount). + +### Give Bill + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Withdrawal + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Send Cash Link + +| Property | Type | Optional | Values | +|---|---|---|---| +| Cash Link Choice | list | yes | [CashLinkChoice](#cashlinkchoice) | +| App | text | yes | | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android never sends Failure. + +### Receive Cash Link + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Sent Cash + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Sent Tip + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +## Add Money + +### Add Money: Opened + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [AddMoneySource](#addmoneysource) | + +### Add Money: Method Selected + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | + +> **Drift:** iOS has no Reserves method. + +### Add Money: Amount Confirmed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Add Money: Payment Invoked + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the native amount here. + +### Add Money: Address Copied + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | + +### Add Money + +The outcome of an add-money flow, sent as the event named `Add Money`. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +## Onramp + +### Onramp: {step} + +Show Verification Info is Android only; part 2 decides whether iOS sends it. + +`{step}` in the name is one of [OnrampStep](#onrampstep). + +No properties. + +## Wallet + +The Phantom deeplink flow. + +### Wallet: Connect + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | + +> **Drift:** iOS sends no Provider, and fires on the request rather than after connecting. + +### Wallet: Request Amount + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends no Provider, and sends only Fiat (the native amount) and Currency where Android sends the token amount's block (see Amount). + +### Wallet: Transactions Failed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | + +> **Drift:** iOS declares this with no Provider and never sends it. + +### Wallet: Cancel + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | + +> **Drift:** iOS sends no Provider, and fires on any error code where Android fires only on a user reject. + +## Token Info + +### Token Info: Opened From {source} + +`{source}` in the name is one of [TokenInfoSource](#tokeninfosource). + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | + +## Swap + +Buying and selling a token. + +### Token Purchase With {method} + +Phantom and Coinbase are Android only; part 2 decides whether iOS sends them. + +`{method}` in the name is one of [PurchaseMethod](#purchasemethod). + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | +| amount | group | no | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** (Reserves) iOS also sends State, Payment Mint and Payment Token Symbol, and sends only Fiat and Currency of the amount, with no USDC or Quarks. + +### Token Sell + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | +| amount | group | no | [amount](#amount) | +| Fee | decimal | no | | +| Error | text | yes | | + +> **Drift:** iOS also sends State, and sends only Fiat and Currency of the amount, with no USDC, Quarks or Fee. + +## Scan + +The gallery scan path, and the tip card. + +### Gallery Scan: Image Picked + +No properties. + +### Gallery Scan: Succeeded + +@param tier which rung of the crop ladder decoded, 1 to 3. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Tier | count | no | | +| Zoom | decimal | no | | +| Time | duration | no | | + +> **Drift:** iOS sends this as Gallery Scan: Code Found, with Type {Kik, QR}, Tier as {wholeImage, quadrant, window}, Zoom, and Elapsed in seconds instead of Time in ms. + +### Gallery Scan: Failed + +@param exhausted true when the budget ran out rather than the ladder ending. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Time | duration | no | | +| Exhausted | yes/no | no | | + +> **Drift:** iOS sends this as Gallery Scan: Nothing Found, with State {Exhausted, Cancelled, Route Refused} and Elapsed in seconds instead of Time in ms and Exhausted. + +### Tip Card Scanned + +No properties. + +### Tip Card Presented + +No properties. + +## Deeplink + +[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ. + +### Deeplink: Open + +| Property | Type | Optional | Values | +|---|---|---|---| +| URL | text | no | | + +### Deeplink: Parse + +Sent in 2 forms. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Type | text | no | | + +> **Drift:** iOS's Type vocabulary is {Login, CashLink, EmailVerification, TokenInfo, Chat, ChatSendCash, Tip, Username, Wallet, DiscoverCurrencies, "Sheet:"}, and iOS sends this for every link where Android sends it only on the gallery QR path. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Error | text | no | `Failed to parse deeplink => {url}` | + +### Deeplink: Routed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Type | text | no | | +| Error | text | yes | | + +> **Drift:** iOS never passes Error, and sends this for every kind where Android sends it only for a cash link. + +## Display Name + +### Display Name Set + +The user had no display name before this submission. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [DisplayNameSource](#displaynamesource) | + +### Display Name Updated + +The user replaced an existing display name. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [DisplayNameSource](#displaynamesource) | + +## Error Modal + +### Error Modal Displayed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Title | text | no | | +| Message | text | no | | +| Screen | text | yes | | +| Call Site | text | yes | | + +> **Drift:** iOS's Screen is `presentedSheet.description` or "scan" where Android's is the back stack top's `screenName()`, and iOS never sends Call Site. + +## Account + +### Create Account Payment + +Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Fiat | decimal | no | | +| Currency | text | no | | +| Owner Public Key | text | no | | + +### Entered Phone Number + +No properties. + +### Verified Phone Number + +No properties. + +### Linked Phone Number + +No properties. + +### Complete Onboarding + +No properties. + +## Button + +### Button: {button} + +`{button}` in the name is one of [Button](#button). + +No properties. + +## Groups + +### amount + +Sent by events that carry an amount of money. Defined in `Amount.kt`. + +| Property | Type | Optional | +|---|---|---| +| Fiat | decimal | no | +| Currency | text | no | +| USDC | decimal | no | +| Quarks | count | no | +| Exchange Rate | decimal | yes | +| Mint | text | yes | + +## Lists + +### ChatType + +The `Chat Type` property. + +| Value sent | Name in code | Drift | +|---|---|---| +| Contact | `CONTACT` | | +| Tip | `TIP` | | +| Group | `GROUP` | | +| Unknown | `UNKNOWN` | | + +### State + +The `State` property on an event that reports an outcome. + +| Value sent | Name in code | Drift | +|---|---|---| +| Success | `SUCCESS` | | +| Failure | `FAILURE` | | + +### CashLinkChoice + +The `Cash Link Choice` property on Send Cash Link: how the user passed the link on. + +| Value sent | Name in code | Drift | +|---|---|---| +| Copied to clipboard | `COPIED` | | +| Shared to app | `SHARED` | | + +### AddMoneySource + +The `Source` property on Add Money: Opened: where the user opened Add Money from. + +| Value sent | Name in code | Drift | +|---|---|---| +| Menu | `MENU` | | +| Give Shortfall | `GIVE_SHORTFALL` | | +| Buy Shortfall | `BUY_SHORTFALL` | | +| Username Shortfall | `USERNAME_SHORTFALL` | | +| Chat | `CHAT` | | +| Scanner | `SCANNER` | | +| Balance | `BALANCE` | | + +### AddMoneyMethod + +The `Method` property on the Add Money events: how the user adds money. + +| Value sent | Name in code | Drift | +|---|---|---| +| Coinbase | `COINBASE` | | +| Phantom | `PHANTOM` | | +| Other Wallet | `OTHER_WALLET` | | +| Reserves | `RESERVES` | iOS has no Reserves method. | + +### OnrampStep + +The verification screen shown, sent in the event name `Onramp: {step}`. + +| Value sent | Name in code | Drift | +|---|---|---| +| Show Verification Info | `SHOW_INFO` | | +| Show Enter Phone | `ENTER_PHONE` | | +| Show Confirm Phone | `CONFIRM_PHONE` | | +| Show Enter Email | `ENTER_EMAIL` | | +| Show Confirm Email | `CONFIRM_EMAIL` | | + +### WalletProvider + +The `Provider` property on the Wallet events: the wallet app the flow goes through. + +| Value sent | Name in code | Drift | +|---|---|---| +| Phantom | `PHANTOM` | | + +### TokenInfoSource + +Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2. + +| Value sent | Name in code | Drift | +|---|---|---| +| Deeplink | `DEEPLINK` | | +| Wallet | `WALLET` | | +| Discovery | `DISCOVERY` | | +| Chat | `CHAT` | | +| Chat Gate | `CHAT_GATE` | | + +### PurchaseMethod + +How a token is paid for, sent in the event name `Token Purchase With {method}`. + +| Value sent | Name in code | Drift | +|---|---|---| +| Reserves | `RESERVES` | | +| Phantom | `PHANTOM` | | +| Coinbase | `COINBASE` | | + +### DisplayNameSource + +The `Source` property on the Display Name events: the screen the name was set on. + +| Value sent | Name in code | Drift | +|---|---|---| +| Onboarding | `ONBOARDING` | | +| My Account | `MY_ACCOUNT` | | +| Tip Card Setup | `TIP_CARD_SETUP` | | + +### Button + +The button tapped, sent in the event name `Button: {button}`. + +| Value sent | Name in code | Drift | +|---|---|---| +| Create Account | `CREATE_ACCOUNT` | | +| Save Access Key | `SAVE_ACCESS_KEY` | | +| Wrote Access Key | `WROTE_ACCESS_KEY` | | +| Allow Push | `ALLOW_PUSH` | | +| Skip Push | `SKIP_PUSH` | | +| Allow Contacts | `ALLOW_CONTACTS` | | +| Skip Contacts | `SKIP_CONTACTS` | | +| Buy With Reserves | `BUY_WITH_RESERVES` | | +| Buy With Phantom | `BUY_WITH_PHANTOM` | | +| Buy With Coinbase | `BUY_WITH_COINBASE` | | +| Buy With Other Wallet | `BUY_WITH_OTHER_WALLET` | | +| Share Token Info | `SHARE_TOKEN_INFO` | | + +## People counters + +Cumulative per-user counters, stored as Mixpanel people properties. + +| Counter | Name in code | +|---|---| +| Tips Received | `TIPS` | +| Tips Received Value | `TIPS_VALUE` | +| Messages Received | `MESSAGES` | diff --git a/libs/analytics-events/build.gradle.kts b/libs/analytics-events/build.gradle.kts new file mode 100644 index 0000000000..194f8071e1 --- /dev/null +++ b/libs/analytics-events/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") + // Generates the builders and value enums from events.toml into commonMain. + alias(libs.plugins.flipcash.analytics.catalogue) +} + +kotlin { + android { + namespace = "com.flipcash.analytics" + compileSdk { + version = release(libs.versions.android.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() + } + } + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() + macosArm64() + macosX64() + + sourceSets { + commonMain { + // Pure Kotlin -- no external dependencies needed. + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml new file mode 100644 index 0000000000..090384e8fb --- /dev/null +++ b/libs/analytics-events/events.toml @@ -0,0 +1,529 @@ +# The analytics event catalogue: every event both apps can send to Mixpanel, and the +# properties each one carries. EVENTS.md, next to this file, is the same catalogue as a +# readable page. To add or change an event, edit this file in a pull request. If you'd +# rather not, ask an engineer to make the change. +# +# To add an event, copy an [[event]] entry below and change it: +# +# [[event]] +# domain = "Transfer" the part of the app it belongs to +# name = "Grab Bill" the event name exactly as Mixpanel shows it +# properties = [ +# { name = "Grab Time", type = "duration", optional = true }, +# { group = "outcome" }, +# ] +# +# Each property has a `name` (the key Mixpanel shows) and a `type`: +# text words, such as a mint address or an error message +# yes/no true or false +# count a whole number +# decimal a number with a fraction, such as a zoom level or a fee +# duration a length of time, sent in milliseconds +# a list one value from a list in the Lists section, such as ChatType +# +# `optional = true` means the app may leave the property out; without it, the property +# is sent every time. `{ group = "outcome" }` and `{ group = "amount" }` add the same +# block of properties every outcome or amount event sends (see Groups). +# +# An event name can include a list value, such as "Token Purchase With {method}". Say +# which list with `placeholders = { method = "PurchaseMethod" }`; a {placeholder} that +# isn't listed there is text. A property can be fixed text around a value with +# `format`, such as { name = "Error", format = "Failed to parse deeplink => {url}" }. +# +# `description` explains the event. `drift` records how iOS differs today, until the two +# apps agree. +# +# The build checks every entry and stops with a message naming the event and what to +# change. One it may ask for is `builder`, the name engineers call the event by. It +# comes from the event name, so "Add Money: Opened" becomes `opened`; set `builder` +# when two events would get the same one, or when the name gives none. +# +# For engineers: the build generates the builder objects and value enums from this file +# into build/generated/analytics/commonMain (see build-logic/.../analytics). Nothing +# generated is checked in except EVENTS.md. AnalyticsEvent.kt and Amount.kt stay +# hand-written. `param` renames a property's parameter, and `order` lists the parameters +# in a different order than the properties give them. +# 1. Add a test to src/commonTest that asserts the event name and properties as string +# literals. Never read the expected strings back from the enums: the literal is what +# stops a catalogue edit from renaming an event by accident. +# 2. A new domain or list also needs a typealias in +# kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift. +# 3. iOS gets the event only from a flipcash-shared-core-spm release: after the change +# merges, run the Publish SharedCore workflow with the next minor version. +# The generator's tests are in build-logic/convention/src/test. + +package = "com.flipcash.analytics" + +# --------------------------------------------------------------------------------------- +# Lists: the values a list-typed property can take. The left side is the name engineers +# use; the right side is the text Mixpanel receives. +# --------------------------------------------------------------------------------------- + +[lists.ChatType] +description = "The `Chat Type` property." +[lists.ChatType.values] +CONTACT = "Contact" +TIP = "Tip" +GROUP = "Group" +UNKNOWN = "Unknown" + +[lists.State] +description = "The `State` property on an event that reports an outcome." +[lists.State.values] +SUCCESS = "Success" +FAILURE = "Failure" + +[lists.CashLinkChoice] +description = "The `Cash Link Choice` property on Send Cash Link: how the user passed the link on." +[lists.CashLinkChoice.values] +COPIED = "Copied to clipboard" +SHARED = "Shared to app" + +[lists.AddMoneySource] +description = "The `Source` property on Add Money: Opened: where the user opened Add Money from." +[lists.AddMoneySource.values] +MENU = "Menu" +GIVE_SHORTFALL = "Give Shortfall" +BUY_SHORTFALL = "Buy Shortfall" +USERNAME_SHORTFALL = "Username Shortfall" +CHAT = "Chat" +SCANNER = "Scanner" +BALANCE = "Balance" + +[lists.AddMoneyMethod] +description = "The `Method` property on the Add Money events: how the user adds money." +[lists.AddMoneyMethod.values] +COINBASE = "Coinbase" +PHANTOM = "Phantom" +OTHER_WALLET = "Other Wallet" +RESERVES = { value = "Reserves", drift = "iOS has no Reserves method." } + +[lists.OnrampStep] +description = "The verification screen shown, sent in the event name `Onramp: {step}`." +[lists.OnrampStep.values] +SHOW_INFO = "Show Verification Info" +ENTER_PHONE = "Show Enter Phone" +CONFIRM_PHONE = "Show Confirm Phone" +ENTER_EMAIL = "Show Enter Email" +CONFIRM_EMAIL = "Show Confirm Email" + +[lists.WalletProvider] +description = "The `Provider` property on the Wallet events: the wallet app the flow goes through." +[lists.WalletProvider.values] +PHANTOM = "Phantom" + +[lists.TokenInfoSource] +description = "Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2." +[lists.TokenInfoSource.values] +DEEPLINK = "Deeplink" +WALLET = "Wallet" +DISCOVERY = "Discovery" +CHAT = "Chat" +CHAT_GATE = "Chat Gate" + +[lists.PurchaseMethod] +description = "How a token is paid for, sent in the event name `Token Purchase With {method}`." +[lists.PurchaseMethod.values] +RESERVES = "Reserves" +PHANTOM = "Phantom" +COINBASE = "Coinbase" + +[lists.DisplayNameSource] +description = "The `Source` property on the Display Name events: the screen the name was set on." +[lists.DisplayNameSource.values] +ONBOARDING = "Onboarding" +MY_ACCOUNT = "My Account" +TIP_CARD_SETUP = "Tip Card Setup" + +[lists.Button] +description = "The button tapped, sent in the event name `Button: {button}`." +[lists.Button.values] +CREATE_ACCOUNT = "Create Account" +SAVE_ACCESS_KEY = "Save Access Key" +WROTE_ACCESS_KEY = "Wrote Access Key" +ALLOW_PUSH = "Allow Push" +SKIP_PUSH = "Skip Push" +ALLOW_CONTACTS = "Allow Contacts" +SKIP_CONTACTS = "Skip Contacts" +BUY_WITH_RESERVES = "Buy With Reserves" +BUY_WITH_PHANTOM = "Buy With Phantom" +BUY_WITH_COINBASE = "Buy With Coinbase" +BUY_WITH_OTHER_WALLET = "Buy With Other Wallet" +SHARE_TOKEN_INFO = "Share Token Info" + +# --------------------------------------------------------------------------------------- +# Groups: blocks of properties several events send the same way. +# +# `amount` is built in. It sends Fiat, Currency, USDC and Quarks, plus Exchange Rate and +# Mint when the amount has them. +# --------------------------------------------------------------------------------------- + +# State, then the amount, then Error: the block every outcome event ends with. +[groups.outcome] +properties = [ + { name = "State", type = "State" }, + { group = "amount", optional = true }, + { name = "Error", type = "text", optional = true }, +] + +# --------------------------------------------------------------------------------------- +# People counters: cumulative per-user totals, sent as Mixpanel people properties. +# --------------------------------------------------------------------------------------- + +[counters] +TIPS = "Tips Received" +TIPS_VALUE = "Tips Received Value" +MESSAGES = "Messages Received" + +# --------------------------------------------------------------------------------------- +# Domains that need a word of explanation. +# --------------------------------------------------------------------------------------- + +[domains] +Wallet = "The Phantom deeplink flow." +Swap = "Buying and selling a token." +Scan = "The gallery scan path, and the tip card." +Deeplink = "[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ." + +# --------------------------------------------------------------------------------------- +# Events +# --------------------------------------------------------------------------------------- + +# Chat + +[[event]] +domain = "Chat" +name = "Sent Message" +properties = [ + { name = "Chat Type", type = "ChatType" }, + { name = "Error", type = "text", optional = true }, +] + +[[event]] +domain = "Chat" +name = "Tip Received" +properties = [ + { name = "Chat Type", type = "ChatType" }, + { group = "amount" }, +] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[event]] +domain = "Chat" +name = "Message Received" +properties = [{ name = "Chat Type", type = "ChatType" }] + +# Transfers + +[[event]] +domain = "Transfer" +name = "Grab Bill Start" + +[[event]] +domain = "Transfer" +name = "Give Bill Start" + +[[event]] +domain = "Transfer" +name = "Grab Bill" +order = ["state", "amount", "grabTimeMillis", "error"] +properties = [ + { name = "Grab Time", type = "duration", optional = true }, + { group = "outcome" }, +] +drift = "iOS sends Grab Time in seconds, not milliseconds, and on failure sends only Fiat and Currency of the amount (see Amount)." + +[[event]] +domain = "Transfer" +name = "Give Bill" +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[event]] +domain = "Transfer" +name = "Withdrawal" +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[event]] +domain = "Transfer" +name = "Send Cash Link" +order = ["state", "amount", "choice", "app", "error"] +properties = [ + { name = "Cash Link Choice", type = "CashLinkChoice", optional = true, param = "choice" }, + { name = "App", type = "text", optional = true }, + { group = "outcome" }, +] +drift = "iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android never sends Failure." + +[[event]] +domain = "Transfer" +name = "Receive Cash Link" +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[event]] +domain = "Transfer" +name = "Sent Cash" +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[event]] +domain = "Transfer" +name = "Sent Tip" +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +# Add money + +[[event]] +domain = "Add Money" +name = "Add Money: Opened" +properties = [{ name = "Source", type = "AddMoneySource" }] + +[[event]] +domain = "Add Money" +name = "Add Money: Method Selected" +properties = [{ name = "Method", type = "AddMoneyMethod" }] +drift = "iOS has no Reserves method." + +[[event]] +domain = "Add Money" +name = "Add Money: Amount Confirmed" +properties = [ + { name = "Method", type = "AddMoneyMethod" }, + { group = "amount" }, +] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[event]] +domain = "Add Money" +name = "Add Money: Payment Invoked" +properties = [ + { name = "Method", type = "AddMoneyMethod" }, + { group = "amount" }, +] +drift = "iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the native amount here." + +[[event]] +domain = "Add Money" +name = "Add Money: Address Copied" +properties = [{ name = "Mint", type = "text" }] + +[[event]] +domain = "Add Money" +name = "Add Money" +builder = "result" +properties = [ + { name = "Method", type = "AddMoneyMethod" }, + { group = "outcome" }, +] +description = "The outcome of an add-money flow, sent as the event named `Add Money`." +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +# Onramp + +[[event]] +domain = "Onramp" +name = "Onramp: {step}" +builder = "step" +placeholders = { step = "OnrampStep" } +description = "Show Verification Info is Android only; part 2 decides whether iOS sends it." + +# Wallet + +[[event]] +domain = "Wallet" +name = "Wallet: Connect" +properties = [{ name = "Provider", type = "WalletProvider" }] +drift = "iOS sends no Provider, and fires on the request rather than after connecting." + +[[event]] +domain = "Wallet" +name = "Wallet: Request Amount" +properties = [ + { name = "Provider", type = "WalletProvider" }, + { group = "amount" }, +] +drift = "iOS sends no Provider, and sends only Fiat (the native amount) and Currency where Android sends the token amount's block (see Amount)." + +[[event]] +domain = "Wallet" +name = "Wallet: Transactions Failed" +properties = [{ name = "Provider", type = "WalletProvider" }] +drift = "iOS declares this with no Provider and never sends it." + +[[event]] +domain = "Wallet" +name = "Wallet: Cancel" +properties = [{ name = "Provider", type = "WalletProvider" }] +drift = "iOS sends no Provider, and fires on any error code where Android fires only on a user reject." + +# Token info + +[[event]] +domain = "Token Info" +name = "Token Info: Opened From {source}" +builder = "opened" +placeholders = { source = "TokenInfoSource" } +properties = [{ name = "Mint", type = "text" }] + +# Swap + +[[event]] +domain = "Swap" +name = "Token Purchase With {method}" +builder = "purchase" +placeholders = { method = "PurchaseMethod" } +properties = [ + { name = "Mint", type = "text" }, + { group = "amount" }, + { name = "Error", type = "text", optional = true }, +] +description = "Phantom and Coinbase are Android only; part 2 decides whether iOS sends them." +drift = "(Reserves) iOS also sends State, Payment Mint and Payment Token Symbol, and sends only Fiat and Currency of the amount, with no USDC or Quarks." + +[[event]] +domain = "Swap" +name = "Token Sell" +builder = "sell" +properties = [ + { name = "Mint", type = "text" }, + { group = "amount" }, + { name = "Fee", type = "decimal" }, + { name = "Error", type = "text", optional = true }, +] +drift = "iOS also sends State, and sends only Fiat and Currency of the amount, with no USDC, Quarks or Fee." + +# Scan + +[[event]] +domain = "Scan" +name = "Gallery Scan: Image Picked" +builder = "galleryImagePicked" + +[[event]] +domain = "Scan" +name = "Gallery Scan: Succeeded" +builder = "gallerySucceeded" +properties = [ + { name = "Tier", type = "count" }, + { name = "Zoom", type = "decimal" }, + { name = "Time", type = "duration" }, +] +description = "@param tier which rung of the crop ladder decoded, 1 to 3." +drift = "iOS sends this as Gallery Scan: Code Found, with Type {Kik, QR}, Tier as {wholeImage, quadrant, window}, Zoom, and Elapsed in seconds instead of Time in ms." + +[[event]] +domain = "Scan" +name = "Gallery Scan: Failed" +builder = "galleryFailed" +properties = [ + { name = "Time", type = "duration" }, + { name = "Exhausted", type = "yes/no" }, +] +description = "@param exhausted true when the budget ran out rather than the ladder ending." +drift = "iOS sends this as Gallery Scan: Nothing Found, with State {Exhausted, Cancelled, Route Refused} and Elapsed in seconds instead of Time in ms and Exhausted." + +[[event]] +domain = "Scan" +name = "Tip Card Scanned" + +[[event]] +domain = "Scan" +name = "Tip Card Presented" + +# Deeplink + +[[event]] +domain = "Deeplink" +name = "Deeplink: Open" +properties = [{ name = "URL", type = "text" }] + +[[event]] +domain = "Deeplink" +name = "Deeplink: Parse" +builder = "parsed" +properties = [{ name = "Type", type = "text" }] +drift = "iOS's Type vocabulary is {Login, CashLink, EmailVerification, TokenInfo, Chat, ChatSendCash, Tip, Username, Wallet, DiscoverCurrencies, \"Sheet:\"}, and iOS sends this for every link where Android sends it only on the gallery QR path." + +[[event]] +domain = "Deeplink" +name = "Deeplink: Parse" +builder = "parseFailed" +properties = [{ name = "Error", format = "Failed to parse deeplink => {url}" }] + +[[event]] +domain = "Deeplink" +name = "Deeplink: Routed" +properties = [ + { name = "Type", type = "text" }, + { name = "Error", type = "text", optional = true }, +] +drift = "iOS never passes Error, and sends this for every kind where Android sends it only for a cash link." + +# Display name + +[[event]] +domain = "Display Name" +name = "Display Name Set" +builder = "set" +properties = [{ name = "Source", type = "DisplayNameSource" }] +description = "The user had no display name before this submission." + +[[event]] +domain = "Display Name" +name = "Display Name Updated" +builder = "updated" +properties = [{ name = "Source", type = "DisplayNameSource" }] +description = "The user replaced an existing display name." + +# Error modal + +[[event]] +domain = "Error Modal" +name = "Error Modal Displayed" +builder = "displayed" +properties = [ + { name = "Title", type = "text" }, + { name = "Message", type = "text" }, + { name = "Screen", type = "text", optional = true }, + { name = "Call Site", type = "text", optional = true }, +] +drift = "iOS's Screen is `presentedSheet.description` or \"scan\" where Android's is the back stack top's `screenName()`, and iOS never sends Call Site." + +# Account + +[[event]] +domain = "Account" +name = "Create Account Payment" +properties = [ + { name = "Fiat", type = "decimal", param = "price" }, + { name = "Currency", type = "text" }, + { name = "Owner Public Key", type = "text", param = "owner" }, +] +description = "Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment." + +[[event]] +domain = "Account" +name = "Entered Phone Number" + +[[event]] +domain = "Account" +name = "Verified Phone Number" + +[[event]] +domain = "Account" +name = "Linked Phone Number" + +[[event]] +domain = "Account" +name = "Complete Onboarding" + +# Buttons + +[[event]] +domain = "Button" +name = "Button: {button}" +builder = "tapped" +placeholders = { button = "Button" } diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Amount.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Amount.kt new file mode 100644 index 0000000000..a0d9b65e46 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Amount.kt @@ -0,0 +1,29 @@ +package com.flipcash.analytics + +/** + * Android's amount properties, as sent today. + * + * DRIFT: iOS sends Fiat, Currency, Exchange Rate, Quarks and Mint, never USDC. Android sends + * USDC always, and Exchange Rate and Mint only for a `LocalFiat`. Part 2 settles one block + * for every amount-bearing event; until then, only Android builds this. + * + * [quarks] crosses as a `Double`, which is exact below 2^53. + */ +data class Amount( + val fiat: Double, + val currency: String, + val usdc: Double, + val quarks: Long, + val exchangeRate: Double? = null, + val mint: String? = null, +) + +internal fun PropertiesBuilder.amount(amount: Amount?) { + amount ?: return + number("Fiat", amount.fiat) + text("Currency", amount.currency) + number("USDC", amount.usdc) + number("Quarks", amount.quarks) + number("Exchange Rate", amount.exchangeRate) + text("Mint", amount.mint) +} diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/AnalyticsEvent.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/AnalyticsEvent.kt new file mode 100644 index 0000000000..c39b8246ce --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/AnalyticsEvent.kt @@ -0,0 +1,30 @@ +package com.flipcash.analytics + +/** + * One event, as both apps hand it to Mixpanel. The builders in `events/` are the only + * place a name or a property key is written; nothing else in either app should spell one. + */ +data class AnalyticsEvent(val name: String, val properties: Map = emptyMap()) + +/** + * A property's value with its type. Mixpanel treats `"12"` and `12` as different + * values, so the type is part of the contract, not a detail of either platform's sender. + */ +sealed interface PropertyValue { + data class Text(val value: String) : PropertyValue + data class Number(val value: Double) : PropertyValue + data class Flag(val value: Boolean) : PropertyValue +} + +/** A null value omits the key rather than sending an empty value. */ +internal class PropertiesBuilder { + private val map = LinkedHashMap() + fun text(key: String, value: String?) { if (value != null) map[key] = PropertyValue.Text(value) } + fun number(key: String, value: Double?) { if (value != null) map[key] = PropertyValue.Number(value) } + fun number(key: String, value: Long?) = number(key, value?.toDouble()) + fun flag(key: String, value: Boolean?) { if (value != null) map[key] = PropertyValue.Flag(value) } + fun build(): Map = map +} + +internal fun event(name: String, block: PropertiesBuilder.() -> Unit = {}) = + AnalyticsEvent(name, PropertiesBuilder().apply(block).build()) diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AccountEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AccountEventsTest.kt new file mode 100644 index 0000000000..1afc5a8151 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AccountEventsTest.kt @@ -0,0 +1,41 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.AccountEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class AccountEventsTest { + + @Test + fun createAccountPaymentCarriesPriceCurrencyAndOwner() { + assertEquals( + AnalyticsEvent( + "Create Account Payment", + mapOf( + "Fiat" to PropertyValue.Number(4.99), + "Currency" to PropertyValue.Text("USD"), + "Owner Public Key" to PropertyValue.Text("owner"), + ), + ), + AccountEvents.createAccountPayment(4.99, "USD", "owner"), + ) + } + + @Test + fun propertylessEvents() { + assertEquals( + listOf( + AnalyticsEvent("Entered Phone Number", emptyMap()), + AnalyticsEvent("Verified Phone Number", emptyMap()), + AnalyticsEvent("Linked Phone Number", emptyMap()), + AnalyticsEvent("Complete Onboarding", emptyMap()), + ), + listOf( + AccountEvents.enteredPhoneNumber(), + AccountEvents.verifiedPhoneNumber(), + AccountEvents.linkedPhoneNumber(), + AccountEvents.completeOnboarding(), + ), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt new file mode 100644 index 0000000000..b4f7bae783 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.AddMoneyEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class AddMoneyEventsTest { + + private val amount = Amount(fiat = 20.0, currency = "USD", usdc = 20.0, quarks = 2_000) + private val amountProperties = mapOf( + "Fiat" to PropertyValue.Number(20.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(20.0), + "Quarks" to PropertyValue.Number(2_000.0), + ) + + @Test + fun openedCarriesEverySourceByItsWireString() { + assertEquals( + listOf("Menu", "Give Shortfall", "Buy Shortfall", "Username Shortfall", "Chat", "Scanner", "Balance"), + AddMoneySource.entries.map { + val event = AddMoneyEvents.opened(it) + assertEquals("Add Money: Opened", event.name) + (event.properties.getValue("Source") as PropertyValue.Text).value + }, + ) + } + + @Test + fun methodSelectedCarriesEveryMethodByItsWireString() { + assertEquals( + listOf("Coinbase", "Phantom", "Other Wallet", "Reserves"), + AddMoneyMethod.entries.map { + val event = AddMoneyEvents.methodSelected(it) + assertEquals(AnalyticsEvent("Add Money: Method Selected", mapOf("Method" to event.properties.getValue("Method"))), event) + (event.properties.getValue("Method") as PropertyValue.Text).value + }, + ) + } + + @Test + fun amountConfirmedAndPaymentInvokedCarryMethodAndAmount() { + val expected = mapOf("Method" to PropertyValue.Text("Phantom")) + amountProperties + assertEquals( + AnalyticsEvent("Add Money: Amount Confirmed", expected), + AddMoneyEvents.amountConfirmed(AddMoneyMethod.PHANTOM, amount), + ) + assertEquals( + AnalyticsEvent("Add Money: Payment Invoked", expected), + AddMoneyEvents.paymentInvoked(AddMoneyMethod.PHANTOM, amount), + ) + } + + @Test + fun addressCopiedCarriesTheMint() { + assertEquals( + AnalyticsEvent("Add Money: Address Copied", mapOf("Mint" to PropertyValue.Text("mint"))), + AddMoneyEvents.addressCopied("mint"), + ) + } + + @Test + fun resultIsTheEventNamedAddMoney() { + assertEquals( + AnalyticsEvent( + "Add Money", + mapOf("Method" to PropertyValue.Text("Coinbase"), "State" to PropertyValue.Text("Success")) + + amountProperties, + ), + AddMoneyEvents.result(AddMoneyMethod.COINBASE, State.SUCCESS, amount, null), + ) + assertEquals( + AnalyticsEvent( + "Add Money", + mapOf( + "Method" to PropertyValue.Text("Other Wallet"), + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("declined"), + ), + ), + AddMoneyEvents.result(AddMoneyMethod.OTHER_WALLET, State.FAILURE, null, "declined"), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ButtonEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ButtonEventsTest.kt new file mode 100644 index 0000000000..10595c14ef --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ButtonEventsTest.kt @@ -0,0 +1,29 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ButtonEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ButtonEventsTest { + + @Test + fun tappedNamesEveryButtonByItsWireString() { + assertEquals( + listOf( + "Button: Create Account", + "Button: Save Access Key", + "Button: Wrote Access Key", + "Button: Allow Push", + "Button: Skip Push", + "Button: Allow Contacts", + "Button: Skip Contacts", + "Button: Buy With Reserves", + "Button: Buy With Phantom", + "Button: Buy With Coinbase", + "Button: Buy With Other Wallet", + "Button: Share Token Info", + ).map { AnalyticsEvent(it, emptyMap()) }, + Button.entries.map(ButtonEvents::tapped), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt new file mode 100644 index 0000000000..91c1acd9b9 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt @@ -0,0 +1,75 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ChatEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChatEventsTest { + + @Test + fun sentMessageCarriesTheChatType() { + assertEquals( + AnalyticsEvent("Sent Message", mapOf("Chat Type" to PropertyValue.Text("Group"))), + ChatEvents.sentMessage(ChatType.GROUP, null), + ) + } + + @Test + fun sentMessageCarriesTheError() { + assertEquals( + AnalyticsEvent( + "Sent Message", + mapOf("Chat Type" to PropertyValue.Text("Contact"), "Error" to PropertyValue.Text("boom")), + ), + ChatEvents.sentMessage(ChatType.CONTACT, "boom"), + ) + } + + @Test + fun chatTypesKeepTheirWireStrings() { + assertEquals( + listOf("Contact", "Tip", "Group", "Unknown"), + listOf(ChatType.CONTACT, ChatType.TIP, ChatType.GROUP, ChatType.UNKNOWN) + .map { (ChatEvents.messageReceived(it).properties.getValue("Chat Type") as PropertyValue.Text).value }, + ) + } + + @Test + fun tipReceivedCarriesTheAmountBlock() { + assertEquals( + AnalyticsEvent( + "Tip Received", + mapOf( + "Chat Type" to PropertyValue.Text("Tip"), + "Fiat" to PropertyValue.Number(1.5), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(1.5), + "Quarks" to PropertyValue.Number(25_000_000.0), + "Mint" to PropertyValue.Text("mint"), + ), + ), + ChatEvents.tipReceived( + ChatType.TIP, + Amount(fiat = 1.5, currency = "USD", usdc = 1.5, quarks = 25_000_000, mint = "mint"), + ), + ) + } + + @Test + fun amountBlockCarriesExchangeRateWhenGiven() { + val properties = ChatEvents.tipReceived( + ChatType.TIP, + Amount(fiat = 2.0, currency = "CAD", usdc = 1.4, quarks = 1, exchangeRate = 1.43, mint = null), + ).properties + assertEquals(PropertyValue.Number(1.43), properties["Exchange Rate"]) + assertEquals(null, properties["Mint"]) + } + + @Test + fun messageReceivedCarriesOnlyTheChatType() { + assertEquals( + AnalyticsEvent("Message Received", mapOf("Chat Type" to PropertyValue.Text("Unknown"))), + ChatEvents.messageReceived(ChatType.UNKNOWN), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DeeplinkEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DeeplinkEventsTest.kt new file mode 100644 index 0000000000..2c49783fe7 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DeeplinkEventsTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.DeeplinkEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeeplinkEventsTest { + + @Test + fun openCarriesTheUrl() { + assertEquals( + AnalyticsEvent("Deeplink: Open", mapOf("URL" to PropertyValue.Text("https://app.flipcash.com/c"))), + DeeplinkEvents.open("https://app.flipcash.com/c"), + ) + } + + @Test + fun parsedCarriesTheType() { + assertEquals( + AnalyticsEvent("Deeplink: Parse", mapOf("Type" to PropertyValue.Text("CashLink"))), + DeeplinkEvents.parsed("CashLink"), + ) + } + + @Test + fun parseFailedCarriesTheUrlInTheError() { + assertEquals( + AnalyticsEvent( + "Deeplink: Parse", + mapOf("Error" to PropertyValue.Text("Failed to parse deeplink => https://example.com/x")), + ), + DeeplinkEvents.parseFailed("https://example.com/x"), + ) + } + + @Test + fun routedCarriesTypeAndError() { + assertEquals( + AnalyticsEvent("Deeplink: Routed", mapOf("Type" to PropertyValue.Text("CashLink"))), + DeeplinkEvents.routed("CashLink", null), + ) + assertEquals( + AnalyticsEvent( + "Deeplink: Routed", + mapOf("Type" to PropertyValue.Text("CashLink"), "Error" to PropertyValue.Text("e")), + ), + DeeplinkEvents.routed("CashLink", "e"), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DisplayNameEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DisplayNameEventsTest.kt new file mode 100644 index 0000000000..ff712ea3dc --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DisplayNameEventsTest.kt @@ -0,0 +1,29 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.DisplayNameEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class DisplayNameEventsTest { + + @Test + fun setCarriesEverySourceByItsWireString() { + assertEquals( + listOf( + AnalyticsEvent("Display Name Set", mapOf("Source" to PropertyValue.Text("Onboarding"))), + AnalyticsEvent("Display Name Set", mapOf("Source" to PropertyValue.Text("My Account"))), + AnalyticsEvent("Display Name Set", mapOf("Source" to PropertyValue.Text("Tip Card Setup"))), + ), + listOf(DisplayNameSource.ONBOARDING, DisplayNameSource.MY_ACCOUNT, DisplayNameSource.TIP_CARD_SETUP) + .map(DisplayNameEvents::set), + ) + } + + @Test + fun updatedCarriesTheSource() { + assertEquals( + AnalyticsEvent("Display Name Updated", mapOf("Source" to PropertyValue.Text("My Account"))), + DisplayNameEvents.updated(DisplayNameSource.MY_ACCOUNT), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ErrorModalEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ErrorModalEventsTest.kt new file mode 100644 index 0000000000..587169a72c --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ErrorModalEventsTest.kt @@ -0,0 +1,35 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ErrorModalEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ErrorModalEventsTest { + + @Test + fun displayedCarriesEverythingItIsGiven() { + assertEquals( + AnalyticsEvent( + "Error Modal Displayed", + mapOf( + "Title" to PropertyValue.Text("t"), + "Message" to PropertyValue.Text("m"), + "Screen" to PropertyValue.Text("s"), + "Call Site" to PropertyValue.Text("c"), + ), + ), + ErrorModalEvents.displayed("t", "m", "s", "c"), + ) + } + + @Test + fun displayedOmitsAbsentScreenAndCallSite() { + assertEquals( + AnalyticsEvent( + "Error Modal Displayed", + mapOf("Title" to PropertyValue.Text("t"), "Message" to PropertyValue.Text("m")), + ), + ErrorModalEvents.displayed("t", "m", null, null), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/OnrampEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/OnrampEventsTest.kt new file mode 100644 index 0000000000..b3b4f818f0 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/OnrampEventsTest.kt @@ -0,0 +1,28 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.OnrampEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class OnrampEventsTest { + + @Test + fun eachStepIsItsOwnPropertylessEvent() { + assertEquals( + listOf( + AnalyticsEvent("Onramp: Show Verification Info", emptyMap()), + AnalyticsEvent("Onramp: Show Enter Phone", emptyMap()), + AnalyticsEvent("Onramp: Show Confirm Phone", emptyMap()), + AnalyticsEvent("Onramp: Show Enter Email", emptyMap()), + AnalyticsEvent("Onramp: Show Confirm Email", emptyMap()), + ), + listOf( + OnrampStep.SHOW_INFO, + OnrampStep.ENTER_PHONE, + OnrampStep.CONFIRM_PHONE, + OnrampStep.ENTER_EMAIL, + OnrampStep.CONFIRM_EMAIL, + ).map(OnrampEvents::step), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ScanEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ScanEventsTest.kt new file mode 100644 index 0000000000..4305964efe --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ScanEventsTest.kt @@ -0,0 +1,41 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ScanEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ScanEventsTest { + + @Test + fun propertylessEvents() { + assertEquals(AnalyticsEvent("Gallery Scan: Image Picked", emptyMap()), ScanEvents.galleryImagePicked()) + assertEquals(AnalyticsEvent("Tip Card Scanned", emptyMap()), ScanEvents.tipCardScanned()) + assertEquals(AnalyticsEvent("Tip Card Presented", emptyMap()), ScanEvents.tipCardPresented()) + } + + @Test + fun gallerySucceededCarriesNumbers() { + assertEquals( + AnalyticsEvent( + "Gallery Scan: Succeeded", + mapOf( + "Tier" to PropertyValue.Number(2.0), + "Zoom" to PropertyValue.Number(1.5), + "Time" to PropertyValue.Number(840.0), + ), + ), + ScanEvents.gallerySucceeded(2, 1.5, 840), + ) + } + + @Test + fun galleryFailedCarriesExhaustedAsAFlag() { + assertEquals( + AnalyticsEvent( + "Gallery Scan: Failed", + mapOf("Time" to PropertyValue.Number(1500.0), "Exhausted" to PropertyValue.Flag(true)), + ), + ScanEvents.galleryFailed(1500, true), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SwapEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SwapEventsTest.kt new file mode 100644 index 0000000000..6d56dec442 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SwapEventsTest.kt @@ -0,0 +1,49 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.SwapEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class SwapEventsTest { + + private val amount = Amount(fiat = 3.0, currency = "USD", usdc = 3.0, quarks = 300) + private val properties = mapOf( + "Mint" to PropertyValue.Text("mint"), + "Fiat" to PropertyValue.Number(3.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(3.0), + "Quarks" to PropertyValue.Number(300.0), + ) + + @Test + fun purchaseIsNamedForItsMethod() { + assertEquals( + listOf( + AnalyticsEvent("Token Purchase With Reserves", properties), + AnalyticsEvent("Token Purchase With Phantom", properties), + AnalyticsEvent("Token Purchase With Coinbase", properties), + ), + listOf(PurchaseMethod.RESERVES, PurchaseMethod.PHANTOM, PurchaseMethod.COINBASE) + .map { SwapEvents.purchase(it, "mint", amount, null) }, + ) + } + + @Test + fun purchaseCarriesTheError() { + assertEquals( + AnalyticsEvent("Token Purchase With Coinbase", properties + ("Error" to PropertyValue.Text("e"))), + SwapEvents.purchase(PurchaseMethod.COINBASE, "mint", amount, "e"), + ) + } + + @Test + fun sellCarriesTheFeeAsANumber() { + assertEquals( + AnalyticsEvent( + "Token Sell", + properties + mapOf("Fee" to PropertyValue.Number(0.03), "Error" to PropertyValue.Text("e")), + ), + SwapEvents.sell("mint", amount, 0.03, "e"), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt new file mode 100644 index 0000000000..9ea7a76b07 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt @@ -0,0 +1,30 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.TokenInfoEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class TokenInfoEventsTest { + + private val mint = mapOf("Mint" to PropertyValue.Text("mint")) + + @Test + fun eachSourceIsItsOwnEvent() { + assertEquals( + listOf( + AnalyticsEvent("Token Info: Opened From Deeplink", mint), + AnalyticsEvent("Token Info: Opened From Wallet", mint), + AnalyticsEvent("Token Info: Opened From Discovery", mint), + AnalyticsEvent("Token Info: Opened From Chat", mint), + AnalyticsEvent("Token Info: Opened From Chat Gate", mint), + ), + listOf( + TokenInfoSource.DEEPLINK, + TokenInfoSource.WALLET, + TokenInfoSource.DISCOVERY, + TokenInfoSource.CHAT, + TokenInfoSource.CHAT_GATE, + ).map { TokenInfoEvents.opened(it, "mint") }, + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt new file mode 100644 index 0000000000..508d4eb286 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt @@ -0,0 +1,86 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.TransferEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class TransferEventsTest { + + private val amount = Amount(fiat = 5.0, currency = "USD", usdc = 5.0, quarks = 500, exchangeRate = 1.0, mint = "mint") + private val amountProperties = mapOf( + "Fiat" to PropertyValue.Number(5.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(5.0), + "Quarks" to PropertyValue.Number(500.0), + "Exchange Rate" to PropertyValue.Number(1.0), + "Mint" to PropertyValue.Text("mint"), + ) + + @Test + fun startsCarryNoProperties() { + assertEquals(AnalyticsEvent("Grab Bill Start", emptyMap()), TransferEvents.grabBillStart()) + assertEquals(AnalyticsEvent("Give Bill Start", emptyMap()), TransferEvents.giveBillStart()) + } + + @Test + fun grabBillCarriesGrabTimeAsANumber() { + assertEquals( + AnalyticsEvent( + "Grab Bill", + mapOf("Grab Time" to PropertyValue.Number(1200.0), "State" to PropertyValue.Text("Success")) + amountProperties, + ), + TransferEvents.grabBill(State.SUCCESS, amount, 1200, null), + ) + } + + @Test + fun grabBillFailureOmitsWhatItDoesNotHave() { + assertEquals( + AnalyticsEvent( + "Grab Bill", + mapOf("State" to PropertyValue.Text("Failure"), "Error" to PropertyValue.Text("timed out")), + ), + TransferEvents.grabBill(State.FAILURE, null, null, "timed out"), + ) + } + + @Test + fun outcomeEventsCarryStateAmountAndError() { + val expected = mapOf("State" to PropertyValue.Text("Failure")) + amountProperties + + ("Error" to PropertyValue.Text("e")) + assertEquals(AnalyticsEvent("Give Bill", expected), TransferEvents.giveBill(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Withdrawal", expected), TransferEvents.withdrawal(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Receive Cash Link", expected), TransferEvents.receiveCashLink(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Sent Cash", expected), TransferEvents.sentCash(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Sent Tip", expected), TransferEvents.sentTip(State.FAILURE, amount, "e")) + } + + @Test + fun sendCashLinkToClipboard() { + assertEquals( + AnalyticsEvent( + "Send Cash Link", + mapOf( + "Cash Link Choice" to PropertyValue.Text("Copied to clipboard"), + "State" to PropertyValue.Text("Success"), + ) + amountProperties, + ), + TransferEvents.sendCashLink(State.SUCCESS, amount, CashLinkChoice.COPIED, null, null), + ) + } + + @Test + fun sendCashLinkToAnApp() { + assertEquals( + AnalyticsEvent( + "Send Cash Link", + mapOf( + "Cash Link Choice" to PropertyValue.Text("Shared to app"), + "App" to PropertyValue.Text("com.whatsapp"), + "State" to PropertyValue.Text("Success"), + ), + ), + TransferEvents.sendCashLink(State.SUCCESS, null, CashLinkChoice.SHARED, "com.whatsapp", null), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/WalletEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/WalletEventsTest.kt new file mode 100644 index 0000000000..bcc3133d9f --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/WalletEventsTest.kt @@ -0,0 +1,39 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.WalletEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class WalletEventsTest { + + private val phantom = mapOf("Provider" to PropertyValue.Text("Phantom")) + + @Test + fun providerOnlyEvents() { + assertEquals(AnalyticsEvent("Wallet: Connect", phantom), WalletEvents.connect(WalletProvider.PHANTOM)) + assertEquals( + AnalyticsEvent("Wallet: Transactions Failed", phantom), + WalletEvents.transactionsFailed(WalletProvider.PHANTOM), + ) + assertEquals(AnalyticsEvent("Wallet: Cancel", phantom), WalletEvents.cancel(WalletProvider.PHANTOM)) + } + + @Test + fun requestAmountCarriesProviderAndAmount() { + assertEquals( + AnalyticsEvent( + "Wallet: Request Amount", + phantom + mapOf( + "Fiat" to PropertyValue.Number(10.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(10.0), + "Quarks" to PropertyValue.Number(1_000_000.0), + ), + ), + WalletEvents.requestAmount( + WalletProvider.PHANTOM, + Amount(fiat = 10.0, currency = "USD", usdc = 10.0, quarks = 1_000_000), + ), + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ff1f3436f6..0fa147ef21 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -196,6 +196,7 @@ include( // Internal libs ":libs:analytics", + ":libs:analytics-events", ":libs:biometrics", ":libs:code-detection", ":libs:codes:kikcode", @@ -327,6 +328,7 @@ val kmpUnitTestModules = setOf( // (macOS .dylib / Linux .so); it can't load on the Linux CI runner. Its ed25519.json // parity is gated via the iOS cinterop path (macOS) instead — so it's excluded here. ":libs:encryption:utils", + ":libs:analytics-events", ":libs:reporting", ) // ed25519 and mnemonic excluded: both pull in the JNI-backed Ed25519Kmp Android actual for their