diff --git a/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt index 6f2decaa6c..a7dec4ab57 100644 --- a/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt @@ -1,4 +1,6 @@ +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 @@ -21,6 +23,9 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension * 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) { @@ -30,6 +35,18 @@ class AnalyticsCatalogueConventionPlugin : Plugin { 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) } 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 index 6dda6971b7..6473259f2e 100644 --- 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 @@ -1,9 +1,12 @@ package com.getcode.buildlogic.analytics -/** Reads `events.toml` and writes the builders and value enums it describes. */ +/** 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 index 8003d97aae..dd9973111f 100644 --- 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 @@ -11,13 +11,13 @@ internal data class Catalogue( val domains: List, ) -/** A value enum: a Kotlin enum whose cases carry the string sent to Mixpanel. */ +/** 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`. */ -internal data class Domain(val name: String, val doc: String?, val builders: List) +/** 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, 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 index ad6a99082f..9258d6bd21 100644 --- 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 @@ -5,12 +5,17 @@ import org.tomlj.TomlArray import org.tomlj.TomlTable /** - * Reads `events.toml` into a [Catalogue], rejecting anything the generator would otherwise have - * to guess at: unknown keys, types and groups, parameters nothing reads, templates naming a - * parameter that isn't there, and duplicates. + * 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. * - * Keys that come from the data (enum, domain and group names) are always looked up as one-element - * paths, since tomlj reads a plain string key as a dotted path. + * 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 { @@ -27,17 +32,22 @@ internal object CatalogueParser { */ private val AMOUNT_KEYS = listOf("Fiat", "Currency", "USDC", "Quarks") - private val PRIMITIVES = mapOf( - "String" to ParamType.Text, - "Double" to ParamType.Decimal, - "Long" to ParamType.Whole, - "Int" to ParamType.SmallWhole, - "Boolean" to ParamType.Flag, - "Amount" to ParamType.Amount, + /** 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, ) - /** Types the module already declares by hand, which an enum must not shadow. */ - private val RESERVED_TYPES = PRIMITIVES.keys + setOf("AnalyticsEvent", "PropertyValue", "PeopleCounter") + /** 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", @@ -48,112 +58,125 @@ internal object CatalogueParser { 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 PARAM = Regex("""\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)(\?)?\s*""") + 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", "counters", "enums", "groups", "domains") + root.requireOnly(FILE, "package", "lists", "counters", "groups", "domains", "event") val packageName = root.string("package", FILE) ?: fail(FILE, "`package` is required") - val enums = root.subtables("enums", FILE).map { (name, table) -> enumDef(name, table) } - enums.groupBy { it.name }.forEach { (name, defs) -> - if (defs.size > 1) fail("enums.$name", "declared more than once") - } - val types = PRIMITIVES + enums.associate { it.name to ParamType.Enum(it.name) } + 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 -> enumCase("PeopleCounter", name, table.get(listOf(name))) } + 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 = "groups.$name" - if (name == AMOUNT_GROUP) fail(where, "`$AMOUNT_GROUP` is built in and cannot be redefined") + 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 domains = root.subtables("domains", FILE).map { (name, table) -> domain(name, table, types, groups) } + 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\"`") + } - return Catalogue(packageName, enums, counters, domains) + 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 enumDef(name: String, table: TomlTable): EnumDef { - val where = "enums.$name" - if (!TYPE_NAME.matches(name)) fail(where, "an enum name must be UpperCamelCase") - if (name in RESERVED_TYPES) fail(where, "`$name` is already a type in the module") - table.requireOnly(where, "doc", "cases") - val cases = table.table("cases", where) ?: fail(where, "`cases` is required") - if (cases.isEmpty) fail(where, "an enum needs at least one case") + 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("doc", where), - cases = cases.keySet().map { case -> enumCase(name, case, cases.get(listOf(case))) }, + doc = table.string("description", where), + cases = values.keySet().map { case -> value(where, case, values.get(listOf(case))) }, ) } - private fun enumCase(enum: String, name: String, raw: Any?): EnumCase { - val where = "$enum.$name" - if (!CASE_NAME.matches(name)) fail(where, "a case name must be UPPER_SNAKE_CASE") - val (value, drift) = when (raw) { + 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, "a case is a wire string or `{ value = \"...\", drift = \"...\" }`") + else -> fail(where, "write it as $name = \"...\" or $name = { value = \"...\", drift = \"...\" }") } - if (value.isNullOrEmpty()) fail(where, "a case needs a non-empty wire string") - return EnumCase(name, value, drift) + if (wire.isNullOrEmpty()) fail(where, "give it the text Mixpanel receives, such as $name = \"${titleCase(name)}\"") + return EnumCase(name, wire, drift) } - private fun domain( - name: String, - table: TomlTable, - types: Map, - groups: Map, - ): Domain { - val where = "domains.$name" - if (!TYPE_NAME.matches(name)) fail(where, "a domain name must be UpperCamelCase") - table.requireOnly(where, "doc", "events") - val entries = table.tables("events", where) - val builders = entries.mapIndexed { index, entry -> builder(name, index, entry, types, groups) } - builders.groupBy { it.name }.forEach { (builder, found) -> - if (found.size > 1) fail("$name.$builder", "builder is declared more than once") + /** 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\"") } - return Domain(name, table.string("doc", where), builders) - } - private fun builder( - domain: String, - index: Int, - table: TomlTable, - types: Map, - groups: Map, - ): Builder { - val name = table.string("builder", "$domain event #${index + 1}") - ?: fail("$domain event #${index + 1}", "`builder` is required") - val where = "$domain.$name" - if (!MEMBER_NAME.matches(name) || name in KEYWORDS) fail(where, "`$name` is not a usable function name") - table.requireOnly(where, "builder", "event", "params", "properties", "doc", "drift") - - val params = table.strings("params", where).map { param(it, where, types) } - params.groupBy { it.name }.forEach { (param, found) -> - if (found.size > 1) fail(where, "parameter `$param` is declared more than once") - } - val byName = params.associateBy { it.name } - val used = mutableSetOf() + 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 eventName = table.string("event", where) ?: fail(where, "`event` is required") - val event = template(eventName, where, byName, used) + 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 { entry -> - expand(entry, where, byName, groups, used, properties, groupStack = emptyList()) + 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) @@ -162,120 +185,226 @@ internal object CatalogueParser { } } keys.groupBy { it }.forEach { (key, found) -> - if (found.size > 1) fail(where, "property \"$key\" is sent more than once") + 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") + } } - params.firstOrNull { it.name !in used }?.let { fail(where, "parameter `${it.name}` is never sent") } + 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\"") + } - return Builder( - name = name, + val ordered = table.array("order", where)?.let { order(it, where, params.all) } ?: params.all + + val builder = Builder( + name = builderName, event = event, - params = params, + params = ordered, properties = properties, - doc = table.string("doc", where), + doc = table.string("description", where), drift = table.string("drift", where), ) + return domain to Entry(eventName, builder) } - private fun param(declaration: String, where: String, types: Map): Param { - val match = PARAM.matchEntire(declaration) - ?: fail(where, "parameter \"$declaration\" is not `name: Type` or `name: Type?`") - val (name, typeName, optional) = match.destructured - if (!MEMBER_NAME.matches(name) || name in KEYWORDS) fail(where, "`$name` is not a usable parameter name") - val type = types[typeName] ?: fail(where, "parameter `$name` has unknown type `$typeName`") - return Param(name, type, nullable = optional.isNotEmpty()) + 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, - params: Map, + types: Types, groups: Map, - used: MutableSet, + params: Params, + template: (String) -> Template, into: MutableList, groupStack: List, ) { val group = entry.string("group", where) if (group != null) { - entry.requireOnly(where, "group") + entry.requireOnly(where, "group", "optional") + val optional = entry.boolean("optional", where) when { group == AMOUNT_GROUP -> { - val param = params[AMOUNT_GROUP] - ?: fail(where, "the `amount` group reads a parameter `amount: Amount`, which isn't declared") - if (param.type != ParamType.Amount) fail(where, "the `amount` group needs `amount` to be an Amount") - used += param.name + val param = params.add(Param(AMOUNT_GROUP, ParamType.Amount, optional), "the amount group") into += Property.AmountBlock(param) } - group in groupStack -> fail(where, "group `$group` includes itself") + 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, "unknown group `$group`") - table.tables("properties", "groups.$group").forEach { nested -> - expand(nested, where, params, groups, used, into, groupStack + group) + 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, "key", "value", "format") - val key = entry.string("key", where) ?: fail(where, "a property needs a `key`, or a `group`") - val value = entry.string("value", where) + 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) - into += when { - value != null && format != null -> fail(where, "property \"$key\" has both `value` and `format`") - value != null -> { - val param = params[value] ?: fail(where, "property \"$key\" reads `$value`, which isn't a parameter") - if (param.type == ParamType.Amount) { - fail(where, "property \"$key\": an Amount is sent through the amount group, not as a property") - } - used += param.name - Property.Value(key, param) + 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`") } - format != null -> Property.Format(key, template(format, where, params, used)) - else -> fail(where, "property \"$key\" needs a `value` or a `format`") + 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. */ - private fun template(text: String, where: String, params: Map, used: MutableSet): Template { + /** + * 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 `{`") + 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 `{`") + 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 `}`") + 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) - val param = params[slot] ?: fail(where, "\"$text\" names {$slot}, which isn't a parameter") - if (param.nullable) fail(where, "\"$text\" names {$slot}, which is optional and would print \"null\"") - if (param.type != ParamType.Text && param.type !is ParamType.Enum) { - fail(where, "\"$text\" names {$slot}; only String and enum parameters can appear in text") + 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 += param.name + 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 key(s) ${unknown.joinToString { "`$it`" }}; expected ${allowed.joinToString { "`$it`" }}") + 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 a string") + 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))) { @@ -294,15 +423,11 @@ internal object CatalogueParser { 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 an array") + 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 a table") } - .orEmpty() - - private fun TomlTable.strings(key: String, where: String): List = - array(key, where)?.toList()?.map { it as? String ?: fail(where, "every entry in `$key` must be a string") } + 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 = 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 index 9fa93cda7d..02f2d83a5c 100644 --- 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 @@ -14,12 +14,12 @@ class AnalyticsCatalogueTest { assertFailsWith { generate(toml) }.message.orEmpty() @Test - fun valueEnumsKeepCaseOrderAndCarryDriftAsKDoc() { + fun listsKeepValueOrderAndCarryDescriptionAndDriftAsKDoc() { val source = generate( """ - [enums.Method] - doc = "How the user paid." - [enums.Method.cases] + [lists.Method] + description = "How the user paid." + [lists.Method.values] ZULU = "Zulu" ALPHA = { value = "Alpha", drift = "iOS has no Alpha." } """.trimIndent(), @@ -45,23 +45,22 @@ class AnalyticsCatalogueTest { } @Test - fun eventMapsEachParameterTypeToItsPropertyCall() { + fun eachTypeWordBecomesAParameterAndItsPropertyCall() { val source = generate( """ - [enums.Kind.cases] + [lists.Kind.values] ONE = "One" - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["kind: Kind", "label: String", "rate: Double", "millis: Long", "tier: Int", "done: Boolean"] + [[event]] + domain = "Thing" + name = "Thing Happened" properties = [ - { key = "Kind", value = "kind" }, - { key = "Label", value = "label" }, - { key = "Rate", value = "rate" }, - { key = "Time", value = "millis" }, - { key = "Tier", value = "tier" }, - { key = "Done", value = "done" }, + { 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") @@ -77,12 +76,12 @@ class AnalyticsCatalogueTest { // Generated from events.toml -- do not edit. object ThingEvents { - fun happened(kind: Kind, label: String, rate: Double, millis: Long, tier: Int, done: Boolean): AnalyticsEvent = + 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", millis) + number("Time", timeMillis) number("Tier", tier.toDouble()) flag("Done", done) } @@ -97,22 +96,21 @@ class AnalyticsCatalogueTest { fun optionalPropertiesPassNullThroughSoTheBuilderOmitsThem() { val source = generate( """ - [enums.Kind.cases] + [lists.Kind.values] ONE = "One" - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["kind: Kind?", "error: String?", "tier: Int?"] + [[event]] + domain = "Thing" + name = "Thing Happened" properties = [ - { key = "Kind", value = "kind" }, - { key = "Error", value = "error" }, - { key = "Tier", value = "tier" }, + { 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 happened(kind: Kind?, error: String?, tier: Int?): AnalyticsEvent =") + 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())") @@ -122,35 +120,95 @@ class AnalyticsCatalogueTest { fun anEventWithNoPropertiesHasNoBlock() { val source = generate( """ - [[domains.ThingEvents.events]] - builder = "started" - event = "Thing Started" + [[event]] + domain = "Thing" + name = "Thing Started" """.trimIndent(), ).getValue("com/example/events/ThingEvents.kt") - assertContains(source, " fun started(): AnalyticsEvent = event(\"Thing Started\")\n") + 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( """ - [enums.State.cases] + [lists.State.values] SUCCESS = "Success" [groups.outcome] properties = [ - { key = "State", value = "state" }, - { group = "amount" }, - { key = "Error", value = "error" }, + { name = "State", type = "State" }, + { group = "amount", optional = true }, + { name = "Error", type = "text", optional = true }, ] - [[domains.ThingEvents.events]] - builder = "grabbed" - event = "Grabbed" - params = ["state: State", "amount: Amount?", "millis: Long?", "error: String?"] + [[event]] + domain = "Thing" + name = "Grabbed" properties = [ - { key = "Time", value = "millis" }, + { name = "Grab Time", type = "duration", optional = true }, { group = "outcome" }, ] """.trimIndent(), @@ -161,9 +219,9 @@ class AnalyticsCatalogueTest { assertContains( source, """ - fun grabbed(state: State, amount: Amount?, millis: Long?, error: String?): AnalyticsEvent = + fun grabbed(grabTimeMillis: Long?, state: State, amount: Amount?, error: String?): AnalyticsEvent = event("Grabbed") { - number("Time", millis) + number("Grab Time", grabTimeMillis) text("State", state.value) amount(amount) text("Error", error) @@ -176,61 +234,51 @@ class AnalyticsCatalogueTest { fun aPropertyMayRepeatOneTheAmountSendsOnlyWhenItHasIt() { val source = generate( """ - [[domains.ThingEvents.events]] - builder = "sell" - event = "Token Sell" - params = ["mint: String", "amount: Amount"] - properties = [{ key = "Mint", value = "mint" }, { group = "amount" }] + [[event]] + domain = "Swap" + name = "Token Sell" + properties = [{ name = "Mint", type = "text" }, { group = "amount" }] """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).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 rejectsAPropertyRepeatingOneTheAmountAlwaysSends() { - val message = rejected( - """ - [[domains.ThingEvents.events]] - builder = "sell" - event = "Token Sell" - params = ["fiat: Double", "amount: Amount"] - properties = [{ key = "Fiat", value = "fiat" }, { group = "amount" }] - """.trimIndent(), - ) - assertContains(message, "ThingEvents.sell") - assertContains(message, "Fiat") - } - - @Test - fun nameTemplatesInterpolateEnumWireValues() { + fun namePlaceholdersAreTextUnlessTypedAsAList() { val source = generate( """ - [enums.Method.cases] + [lists.Method.values] RESERVES = "Reserves" - [[domains.ThingEvents.events]] + [[event]] + domain = "Swap" + name = "Token Purchase With {method} From {place}" builder = "purchase" - event = "Token Purchase With {method}" - params = ["method: Method"] + placeholders = { method = "Method" } """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).getValue("com/example/events/SwapEvents.kt") - assertContains(source, "fun purchase(method: Method): AnalyticsEvent = event(\"Token Purchase With \${method.value}\")") + assertContains( + source, + "fun purchase(method: Method, place: String): AnalyticsEvent = " + + "event(\"Token Purchase With \${method.value} From \${place}\")", + ) } @Test fun fixedFormatValuesInterpolateAndEscapeLiterals() { val source = generate( """ - [[domains.ThingEvents.events]] - builder = "parseFailed" - event = "Deeplink: Parse" - params = ["url: String"] - properties = [{ key = "Error", format = "Failed to parse ${'$'}deeplink \"=>\" {url}" }] + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + properties = [{ name = "Error", format = "Failed to parse ${'$'}deeplink \"=>\" {url}" }] """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).getValue("com/example/events/DeeplinkEvents.kt") + assertContains(source, "fun parse(url: String): AnalyticsEvent =") assertContains(source, """text("Error", "Failed to parse \${'$'}deeplink \"=>\" ${'$'}{url}")""") } @@ -238,33 +286,34 @@ class AnalyticsCatalogueTest { fun twoBuildersMaySendOneEventName() { val source = generate( """ - [[domains.ThingEvents.events]] + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" builder = "parsed" - event = "Deeplink: Parse" - params = ["type: String"] - properties = [{ key = "Type", value = "type" }] + properties = [{ name = "Type", type = "text" }] - [[domains.ThingEvents.events]] + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" builder = "parseFailed" - event = "Deeplink: Parse" """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).getValue("com/example/events/DeeplinkEvents.kt") assertContains(source, "fun parsed(type: String): AnalyticsEvent =") assertContains(source, "fun parseFailed(): AnalyticsEvent = event(\"Deeplink: Parse\")") } @Test - fun docAndDriftBecomeKDocWrappedAtTheLineLimit() { + fun descriptionAndDriftBecomeKDocWrappedAtTheLineLimit() { val source = generate( """ - [domains.ThingEvents] - doc = "Things." + [domains] + Thing = "Things." - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - doc = "The user had no display name before this submission." + [[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") @@ -280,7 +329,7 @@ class AnalyticsCatalogueTest { * DRIFT: iOS sends Exchange Rate and no USDC, and fires on the request rather than after * connecting to the wallet. */ - fun happened(): AnalyticsEvent = event("Thing Happened") + fun thingHappened(): AnalyticsEvent = event("Thing Happened") """.trimIndent(), ) } @@ -312,180 +361,221 @@ class AnalyticsCatalogueTest { assertEquals(emptyMap(), generate("")) } - // Rejections. Each message names the entry at fault. + // Rejections. Each message names the event at fault and says what to change. @Test fun rejectsTomlSyntaxErrors() { - assertContains(rejected("[enums.Method\n"), "events.toml") + assertContains(rejected("[lists.Method\n"), "events.toml") } @Test - fun rejectsAnUnknownParameterType() { + fun rejectsAnUnknownTypeWordAndListsTheWords() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["kind: Knid"] - properties = [{ key = "Kind", value = "kind" }] + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Label", type = "strng" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "Knid") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "strng") + assertContains(message, "text, yes/no, count, decimal, duration") } @Test - fun rejectsADuplicateBuilderInADomain() { + fun rejectsAnUnknownListAndSaysWhereListsGo() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" + [lists.ChatType.values] + TIP = "Tip" - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened Again" + [[event]] + domain = "Chat" + name = "Sent Message" + properties = [{ name = "Chat Type", type = "ChatTyp" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "more than once") + assertContains(message, "\"Sent Message\"") + assertContains(message, "ChatTyp") + assertContains(message, "[lists.ChatTyp]") + assertContains(message, "ChatType") } @Test - fun rejectsATemplateNamingAMissingParameter() { + fun rejectsTwoEventsThatWouldGetTheSameBuilderName() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "purchase" - event = "Token Purchase With {method}" + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + properties = [{ name = "Type", type = "text" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.purchase") - assertContains(message, "{method}") + assertContains(message, "\"Deeplink: Parse\"") + assertContains(message, "`parse`") + assertContains(message, "add `builder") } @Test - fun rejectsAFormatNamingAMissingParameter() { + fun rejectsANameThatGivesNoBuilderName() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "parseFailed" - event = "Deeplink: Parse" - properties = [{ key = "Error", format = "Failed => {url}" }] + [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, "ThingEvents.parseFailed") - assertContains(message, "{url}") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "`callSite`") + assertContains(message, "add `param") } @Test - fun rejectsAnEnumCaseWithNoWireString() { - val empty = rejected("[enums.Method.cases]\nRESERVES = \"\"") - assertContains(empty, "Method.RESERVES") + 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") + } - val missing = rejected("[enums.Method.cases]\nRESERVES = { drift = \"iOS has none.\" }") - assertContains(missing, "Method.RESERVES") + @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 rejectsAPropertyReadingAnUndeclaredParameter() { + fun rejectsAPlaceholderThatIsNotTextOrAList() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - properties = [{ key = "Label", value = "label" }] + [[event]] + domain = "Thing" + name = "Thing {tier}" + builder = "thing" + placeholders = { tier = "count" } """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "label") + assertContains(message, "\"Thing {tier}\"") + assertContains(message, "{tier}") } @Test - fun rejectsAParameterNoPropertyReads() { + fun rejectsAPlaceholderTypeNothingUses() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["label: String"] + [[event]] + domain = "Thing" + name = "Thing Happened" + placeholders = { method = "text" } """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "label") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "method") } @Test fun rejectsAnUnknownKeySoATypoIsNotSilentlyIgnored() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" + [[event]] + domain = "Thing" + name = "Thing Happened" drfit = "iOS sends nothing." """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") + 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( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" + [[event]] + domain = "Thing" + name = "Thing Happened" properties = [{ group = "outcom" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") + assertContains(message, "\"Thing Happened\"") assertContains(message, "outcom") } @Test - fun rejectsAnAmountSentAsAPlainProperty() { + fun rejectsAPropertyRepeatingOneTheAmountAlwaysSends() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["amount: Amount"] - properties = [{ key = "Amount", value = "amount" }] + [[event]] + domain = "Swap" + name = "Token Sell" + properties = [{ name = "Fiat", type = "decimal" }, { group = "amount" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "amount group") + assertContains(message, "\"Token Sell\"") + assertContains(message, "Fiat") } @Test fun rejectsTheSameKeyTwiceInOneEvent() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["a: String", "b: String"] - properties = [{ key = "Label", value = "a" }, { key = "Label", value = "b" }] + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Label", type = "text" }, { name = "Label", type = "text", param = "other" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") + assertContains(message, "\"Thing Happened\"") assertContains(message, "Label") } @Test - fun rejectsANullableParameterInATemplate() { - val message = rejected( - """ - [[domains.ThingEvents.events]] - builder = "parseFailed" - event = "Deeplink: Parse" - params = ["url: String?"] - properties = [{ key = "Error", format = "Failed => {url}" }] - """.trimIndent(), - ) - assertContains(message, "ThingEvents.parseFailed") - assertContains(message, "url") + fun rejectsADomainDescriptionNoEventUses() { + val message = rejected("[domains]\nWalet = \"The Phantom flow.\"") + assertContains(message, "Walet") } private fun assertContains(actual: String, expected: String) = 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 9918bba0f6..93b4573fc8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -90,4 +90,6 @@ tasks.register("flipcashTestDebug") { 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/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/events.toml b/libs/analytics-events/events.toml index 80f77d739e..090384e8fb 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -1,483 +1,529 @@ -# The analytics event catalogue: every event both apps can send, and the value -# vocabularies they use. The build generates the builder objects and value enums from -# this file into build/generated/analytics/commonMain (see -# build-logic/.../analytics/KotlinEmitter.kt); nothing generated is checked in. -# AnalyticsEvent.kt and Amount.kt stay hand-written. +# 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: -# 1. Add a [[domains.Events.events]] entry: `builder`, `event`, `params`, -# `properties`, and `doc` or `drift` where they apply. `event` and a `format` -# value may name a parameter as {param}; an enum parameter sends its wire value. -# `{ group = "amount" }` and `{ group = "outcome" }` expand in place. -# 2. 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. -# 3. A new domain or enum also needs a typealias in -# kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift. -# 4. 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. +# 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}" }. # -# A `drift` note records how iOS differs today and is emitted as a "DRIFT:" KDoc line. -# The generator rejects unknown keys, unsent parameters and duplicate properties or -# builders; its tests are in build-logic/convention/src/test. +# `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" -# Chat - -[enums.ChatType] -doc = "The `Chat Type` property." +# --------------------------------------------------------------------------------------- +# 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. +# --------------------------------------------------------------------------------------- -[enums.ChatType.cases] +[lists.ChatType] +description = "The `Chat Type` property." +[lists.ChatType.values] CONTACT = "Contact" TIP = "Tip" GROUP = "Group" UNKNOWN = "Unknown" -[[domains.ChatEvents.events]] -builder = "sentMessage" -event = "Sent Message" -params = ["chatType: ChatType", "error: String?"] -properties = [ - { key = "Chat Type", value = "chatType" }, - { key = "Error", value = "error" }, -] +[lists.State] +description = "The `State` property on an event that reports an outcome." +[lists.State.values] +SUCCESS = "Success" +FAILURE = "Failure" -[[domains.ChatEvents.events]] -builder = "tipReceived" -event = "Tip Received" -params = ["chatType: ChatType", "amount: Amount"] -properties = [ - { key = "Chat Type", value = "chatType" }, - { group = "amount" }, -] -drift = "iOS sends Exchange Rate and no USDC (see Amount)." +[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" -[[domains.ChatEvents.events]] -builder = "messageReceived" -event = "Message Received" -params = ["chatType: ChatType"] -properties = [{ key = "Chat Type", value = "chatType" }] +[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" -# Transfers +[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." } -[enums.State] -doc = "The `State` property on an event that reports an outcome." +[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" -[enums.State.cases] -SUCCESS = "Success" -FAILURE = "Failure" +[lists.WalletProvider] +description = "The `Provider` property on the Wallet events: the wallet app the flow goes through." +[lists.WalletProvider.values] +PHANTOM = "Phantom" -[enums.CashLinkChoice.cases] -COPIED = "Copied to clipboard" -SHARED = "Shared to app" +[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" -[groups.outcome] +# --------------------------------------------------------------------------------------- +# 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 = [ - { key = "State", value = "state" }, + { 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" }, - { key = "Error", value = "error" }, ] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "grabBillStart" -event = "Grab Bill Start" +[[event]] +domain = "Chat" +name = "Message Received" +properties = [{ name = "Chat Type", type = "ChatType" }] -[[domains.TransferEvents.events]] -builder = "giveBillStart" -event = "Give Bill Start" +# Transfers + +[[event]] +domain = "Transfer" +name = "Grab Bill Start" -[[domains.TransferEvents.events]] -builder = "grabBill" -event = "Grab Bill" -params = ["state: State", "amount: Amount?", "grabTimeMillis: Long?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Give Bill Start" + +[[event]] +domain = "Transfer" +name = "Grab Bill" +order = ["state", "amount", "grabTimeMillis", "error"] properties = [ - { key = "Grab Time", value = "grabTimeMillis" }, + { 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)." -[[domains.TransferEvents.events]] -builder = "giveBill" -event = "Give Bill" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Give Bill" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "withdrawal" -event = "Withdrawal" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Withdrawal" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "sendCashLink" -event = "Send Cash Link" -params = ["state: State", "amount: Amount?", "choice: CashLinkChoice?", "app: String?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Send Cash Link" +order = ["state", "amount", "choice", "app", "error"] properties = [ - { key = "Cash Link Choice", value = "choice" }, - { key = "App", value = "app" }, + { 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." -[[domains.TransferEvents.events]] -builder = "receiveCashLink" -event = "Receive Cash Link" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Receive Cash Link" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "sentCash" -event = "Sent Cash" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Sent Cash" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "sentTip" -event = "Sent Tip" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Sent Tip" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." # Add money -[enums.AddMoneySource.cases] -MENU = "Menu" -GIVE_SHORTFALL = "Give Shortfall" -BUY_SHORTFALL = "Buy Shortfall" -USERNAME_SHORTFALL = "Username Shortfall" -CHAT = "Chat" -SCANNER = "Scanner" -BALANCE = "Balance" - -[enums.AddMoneyMethod.cases] -COINBASE = "Coinbase" -PHANTOM = "Phantom" -OTHER_WALLET = "Other Wallet" -RESERVES = { value = "Reserves", drift = "iOS has no Reserves method." } +[[event]] +domain = "Add Money" +name = "Add Money: Opened" +properties = [{ name = "Source", type = "AddMoneySource" }] -[[domains.AddMoneyEvents.events]] -builder = "opened" -event = "Add Money: Opened" -params = ["source: AddMoneySource"] -properties = [{ key = "Source", value = "source" }] - -[[domains.AddMoneyEvents.events]] -builder = "methodSelected" -event = "Add Money: Method Selected" -params = ["method: AddMoneyMethod"] -properties = [{ key = "Method", value = "method" }] +[[event]] +domain = "Add Money" +name = "Add Money: Method Selected" +properties = [{ name = "Method", type = "AddMoneyMethod" }] drift = "iOS has no Reserves method." -[[domains.AddMoneyEvents.events]] -builder = "amountConfirmed" -event = "Add Money: Amount Confirmed" -params = ["method: AddMoneyMethod", "amount: Amount"] +[[event]] +domain = "Add Money" +name = "Add Money: Amount Confirmed" properties = [ - { key = "Method", value = "method" }, + { name = "Method", type = "AddMoneyMethod" }, { group = "amount" }, ] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.AddMoneyEvents.events]] -builder = "paymentInvoked" -event = "Add Money: Payment Invoked" -params = ["method: AddMoneyMethod", "amount: Amount"] +[[event]] +domain = "Add Money" +name = "Add Money: Payment Invoked" properties = [ - { key = "Method", value = "method" }, + { name = "Method", type = "AddMoneyMethod" }, { group = "amount" }, ] drift = "iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the native amount here." -[[domains.AddMoneyEvents.events]] -builder = "addressCopied" -event = "Add Money: Address Copied" -params = ["mint: String"] -properties = [{ key = "Mint", value = "mint" }] +[[event]] +domain = "Add Money" +name = "Add Money: Address Copied" +properties = [{ name = "Mint", type = "text" }] -[[domains.AddMoneyEvents.events]] +[[event]] +domain = "Add Money" +name = "Add Money" builder = "result" -event = "Add Money" -params = ["method: AddMoneyMethod", "state: State", "amount: Amount?", "error: String?"] properties = [ - { key = "Method", value = "method" }, + { name = "Method", type = "AddMoneyMethod" }, { group = "outcome" }, ] -doc = "The outcome of an add-money flow, sent as the event named `Add Money`." +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 -[enums.OnrampStep.cases] -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" - -[[domains.OnrampEvents.events]] +[[event]] +domain = "Onramp" +name = "Onramp: {step}" builder = "step" -event = "Onramp: {step}" -params = ["step: OnrampStep"] -doc = "Show Verification Info is Android only; part 2 decides whether iOS sends it." +placeholders = { step = "OnrampStep" } +description = "Show Verification Info is Android only; part 2 decides whether iOS sends it." # Wallet -[enums.WalletProvider.cases] -PHANTOM = "Phantom" - -[domains.WalletEvents] -doc = "The Phantom deeplink flow." - -[[domains.WalletEvents.events]] -builder = "connect" -event = "Wallet: Connect" -params = ["provider: WalletProvider"] -properties = [{ key = "Provider", value = "provider" }] +[[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." -[[domains.WalletEvents.events]] -builder = "requestAmount" -event = "Wallet: Request Amount" -params = ["provider: WalletProvider", "amount: Amount"] +[[event]] +domain = "Wallet" +name = "Wallet: Request Amount" properties = [ - { key = "Provider", value = "provider" }, + { 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)." -[[domains.WalletEvents.events]] -builder = "transactionsFailed" -event = "Wallet: Transactions Failed" -params = ["provider: WalletProvider"] -properties = [{ key = "Provider", value = "provider" }] +[[event]] +domain = "Wallet" +name = "Wallet: Transactions Failed" +properties = [{ name = "Provider", type = "WalletProvider" }] drift = "iOS declares this with no Provider and never sends it." -[[domains.WalletEvents.events]] -builder = "cancel" -event = "Wallet: Cancel" -params = ["provider: WalletProvider"] -properties = [{ key = "Provider", value = "provider" }] +[[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 -[enums.TokenInfoSource] -doc = "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." - -[enums.TokenInfoSource.cases] -DEEPLINK = "Deeplink" -WALLET = "Wallet" -DISCOVERY = "Discovery" -CHAT = "Chat" -CHAT_GATE = "Chat Gate" - -[[domains.TokenInfoEvents.events]] +[[event]] +domain = "Token Info" +name = "Token Info: Opened From {source}" builder = "opened" -event = "Token Info: Opened From {source}" -params = ["source: TokenInfoSource", "mint: String"] -properties = [{ key = "Mint", value = "mint" }] +placeholders = { source = "TokenInfoSource" } +properties = [{ name = "Mint", type = "text" }] # Swap -[enums.PurchaseMethod.cases] -RESERVES = "Reserves" -PHANTOM = "Phantom" -COINBASE = "Coinbase" - -[domains.SwapEvents] -doc = "Buying and selling a token." - -[[domains.SwapEvents.events]] +[[event]] +domain = "Swap" +name = "Token Purchase With {method}" builder = "purchase" -event = "Token Purchase With {method}" -params = ["method: PurchaseMethod", "mint: String", "amount: Amount", "error: String?"] +placeholders = { method = "PurchaseMethod" } properties = [ - { key = "Mint", value = "mint" }, + { name = "Mint", type = "text" }, { group = "amount" }, - { key = "Error", value = "error" }, + { name = "Error", type = "text", optional = true }, ] -doc = "Phantom and Coinbase are Android only; part 2 decides whether iOS sends them." +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." -[[domains.SwapEvents.events]] +[[event]] +domain = "Swap" +name = "Token Sell" builder = "sell" -event = "Token Sell" -params = ["mint: String", "amount: Amount", "fee: Double", "error: String?"] properties = [ - { key = "Mint", value = "mint" }, + { name = "Mint", type = "text" }, { group = "amount" }, - { key = "Fee", value = "fee" }, - { key = "Error", value = "error" }, + { 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 -[domains.ScanEvents] -doc = "The gallery scan path, and the tip card." - -[[domains.ScanEvents.events]] +[[event]] +domain = "Scan" +name = "Gallery Scan: Image Picked" builder = "galleryImagePicked" -event = "Gallery Scan: Image Picked" -[[domains.ScanEvents.events]] +[[event]] +domain = "Scan" +name = "Gallery Scan: Succeeded" builder = "gallerySucceeded" -event = "Gallery Scan: Succeeded" -params = ["tier: Int", "zoom: Double", "timeMillis: Long"] properties = [ - { key = "Tier", value = "tier" }, - { key = "Zoom", value = "zoom" }, - { key = "Time", value = "timeMillis" }, + { name = "Tier", type = "count" }, + { name = "Zoom", type = "decimal" }, + { name = "Time", type = "duration" }, ] -doc = "@param tier which rung of the crop ladder decoded, 1 to 3." +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." -[[domains.ScanEvents.events]] +[[event]] +domain = "Scan" +name = "Gallery Scan: Failed" builder = "galleryFailed" -event = "Gallery Scan: Failed" -params = ["timeMillis: Long", "exhausted: Boolean"] properties = [ - { key = "Time", value = "timeMillis" }, - { key = "Exhausted", value = "exhausted" }, + { name = "Time", type = "duration" }, + { name = "Exhausted", type = "yes/no" }, ] -doc = "@param exhausted true when the budget ran out rather than the ladder ending." +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." -[[domains.ScanEvents.events]] -builder = "tipCardScanned" -event = "Tip Card Scanned" +[[event]] +domain = "Scan" +name = "Tip Card Scanned" -[[domains.ScanEvents.events]] -builder = "tipCardPresented" -event = "Tip Card Presented" +[[event]] +domain = "Scan" +name = "Tip Card Presented" # Deeplink -[domains.DeeplinkEvents] -doc = "[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ." +[[event]] +domain = "Deeplink" +name = "Deeplink: Open" +properties = [{ name = "URL", type = "text" }] -[[domains.DeeplinkEvents.events]] -builder = "open" -event = "Deeplink: Open" -params = ["url: String"] -properties = [{ key = "URL", value = "url" }] - -[[domains.DeeplinkEvents.events]] +[[event]] +domain = "Deeplink" +name = "Deeplink: Parse" builder = "parsed" -event = "Deeplink: Parse" -params = ["type: String"] -properties = [{ key = "Type", value = "type" }] +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." -[[domains.DeeplinkEvents.events]] +[[event]] +domain = "Deeplink" +name = "Deeplink: Parse" builder = "parseFailed" -event = "Deeplink: Parse" -params = ["url: String"] -properties = [{ key = "Error", format = "Failed to parse deeplink => {url}" }] - -[[domains.DeeplinkEvents.events]] -builder = "routed" -event = "Deeplink: Routed" -params = ["type: String", "error: String?"] +properties = [{ name = "Error", format = "Failed to parse deeplink => {url}" }] + +[[event]] +domain = "Deeplink" +name = "Deeplink: Routed" properties = [ - { key = "Type", value = "type" }, - { key = "Error", value = "error" }, + { 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 -[enums.DisplayNameSource.cases] -ONBOARDING = "Onboarding" -MY_ACCOUNT = "My Account" -TIP_CARD_SETUP = "Tip Card Setup" - -[[domains.DisplayNameEvents.events]] +[[event]] +domain = "Display Name" +name = "Display Name Set" builder = "set" -event = "Display Name Set" -params = ["source: DisplayNameSource"] -properties = [{ key = "Source", value = "source" }] -doc = "The user had no display name before this submission." +properties = [{ name = "Source", type = "DisplayNameSource" }] +description = "The user had no display name before this submission." -[[domains.DisplayNameEvents.events]] +[[event]] +domain = "Display Name" +name = "Display Name Updated" builder = "updated" -event = "Display Name Updated" -params = ["source: DisplayNameSource"] -properties = [{ key = "Source", value = "source" }] -doc = "The user replaced an existing display name." +properties = [{ name = "Source", type = "DisplayNameSource" }] +description = "The user replaced an existing display name." # Error modal -[[domains.ErrorModalEvents.events]] +[[event]] +domain = "Error Modal" +name = "Error Modal Displayed" builder = "displayed" -event = "Error Modal Displayed" -params = ["title: String", "message: String", "screen: String?", "callSite: String?"] properties = [ - { key = "Title", value = "title" }, - { key = "Message", value = "message" }, - { key = "Screen", value = "screen" }, - { key = "Call Site", value = "callSite" }, + { 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 -[[domains.AccountEvents.events]] -builder = "createAccountPayment" -event = "Create Account Payment" -params = ["price: Double", "currency: String", "owner: String"] +[[event]] +domain = "Account" +name = "Create Account Payment" properties = [ - { key = "Fiat", value = "price" }, - { key = "Currency", value = "currency" }, - { key = "Owner Public Key", value = "owner" }, + { name = "Fiat", type = "decimal", param = "price" }, + { name = "Currency", type = "text" }, + { name = "Owner Public Key", type = "text", param = "owner" }, ] -doc = "Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment." +description = "Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment." -[[domains.AccountEvents.events]] -builder = "enteredPhoneNumber" -event = "Entered Phone Number" +[[event]] +domain = "Account" +name = "Entered Phone Number" -[[domains.AccountEvents.events]] -builder = "verifiedPhoneNumber" -event = "Verified Phone Number" +[[event]] +domain = "Account" +name = "Verified Phone Number" -[[domains.AccountEvents.events]] -builder = "linkedPhoneNumber" -event = "Linked Phone Number" +[[event]] +domain = "Account" +name = "Linked Phone Number" -[[domains.AccountEvents.events]] -builder = "completeOnboarding" -event = "Complete Onboarding" +[[event]] +domain = "Account" +name = "Complete Onboarding" # Buttons -[enums.Button.cases] -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" - -[[domains.ButtonEvents.events]] +[[event]] +domain = "Button" +name = "Button: {button}" builder = "tapped" -event = "Button: {button}" -params = ["button: Button"] +placeholders = { button = "Button" }