From 6edef42c0851f1f2c356abe88cefb38a11147f6a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:59:15 -0400 Subject: [PATCH 1/6] feat(build-logic): derive analytics builder signatures from plain-word types An events.toml entry is now an [[event]] with a domain, the event name, and properties typed as text, yes/no, count, decimal, duration or a list name. The generator derives the builder name from the event name, each parameter name from its property key, and the parameter order from where the properties sit. builder, param and order override what is derived. Value enums move to [lists.X] with a description. The old domains.*.events, params and enums shape is gone. Error messages name the event and say what to change in the file. --- .../getcode/buildlogic/analytics/Catalogue.kt | 6 +- .../buildlogic/analytics/CatalogueParser.kt | 397 ++++++++++------ .../analytics/AnalyticsCatalogueTest.kt | 442 +++++++++++------- 3 files changed, 530 insertions(+), 315 deletions(-) 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/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) = From 07754bcd3292aa644c88b2c8184879c1b3817440 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 20:01:07 -0400 Subject: [PATCH 2/6] refactor(analytics-events): write the catalogue in plain-word types Every entry is now an [[event]] with a domain, the event name and typed properties. builder is set on the 14 entries whose name doesn't give the builder they had, param on Cash Link Choice, Fiat and Owner Public Key, and order on Grab Bill and Send Cash Link. The value lists move to their own section at the top. The outcome group's comment moves from above [counters] to the group. The header now explains the format to someone who doesn't write Kotlin, with the engineer steps in their own paragraph. The generated sources are byte-identical to the ones the old schema produced. --- libs/analytics-events/events.toml | 666 ++++++++++++++++-------------- 1 file changed, 348 insertions(+), 318 deletions(-) diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 80f77d739e..1c492a1f5b 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -1,483 +1,513 @@ -# 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 ask for an event without editing this file, open an issue with the +# "Analytics event" form. # -# 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.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.values] +MENU = "Menu" +GIVE_SHORTFALL = "Give Shortfall" +BUY_SHORTFALL = "Buy Shortfall" +USERNAME_SHORTFALL = "Username Shortfall" +CHAT = "Chat" +SCANNER = "Scanner" +BALANCE = "Balance" -# Transfers +[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.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.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.values] +RESERVES = "Reserves" +PHANTOM = "Phantom" +COINBASE = "Coinbase" + +[lists.DisplayNameSource.values] +ONBOARDING = "Onboarding" +MY_ACCOUNT = "My Account" +TIP_CARD_SETUP = "Tip Card Setup" + +[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" } From 1af99ad418fcb8090c3d9e8f895e6ce45fe32d50 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 20:01:25 -0400 Subject: [PATCH 3/6] docs(analytics-events): describe every value list Eight lists had no description. Each now says which property or event name carries it. The generated enums gain the same text as KDoc; nothing else in the generated sources changes. --- libs/analytics-events/events.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 1c492a1f5b..b366bc5bdc 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -73,10 +73,14 @@ description = "The `State` property on an event that reports an outcome." SUCCESS = "Success" FAILURE = "Failure" +[lists.CashLinkChoice] +description = "The `Cash Link Choice` property on Send Cash Link: how the user passed the link on." [lists.CashLinkChoice.values] COPIED = "Copied to clipboard" SHARED = "Shared to app" +[lists.AddMoneySource] +description = "The `Source` property on Add Money: Opened: where the user opened Add Money from." [lists.AddMoneySource.values] MENU = "Menu" GIVE_SHORTFALL = "Give Shortfall" @@ -86,12 +90,16 @@ CHAT = "Chat" SCANNER = "Scanner" BALANCE = "Balance" +[lists.AddMoneyMethod] +description = "The `Method` property on the Add Money events: how the user adds money." [lists.AddMoneyMethod.values] COINBASE = "Coinbase" PHANTOM = "Phantom" OTHER_WALLET = "Other Wallet" RESERVES = { value = "Reserves", drift = "iOS has no Reserves method." } +[lists.OnrampStep] +description = "The verification screen shown, sent in the event name `Onramp: {step}`." [lists.OnrampStep.values] SHOW_INFO = "Show Verification Info" ENTER_PHONE = "Show Enter Phone" @@ -99,6 +107,8 @@ CONFIRM_PHONE = "Show Confirm Phone" ENTER_EMAIL = "Show Enter Email" CONFIRM_EMAIL = "Show Confirm Email" +[lists.WalletProvider] +description = "The `Provider` property on the Wallet events: the wallet app the flow goes through." [lists.WalletProvider.values] PHANTOM = "Phantom" @@ -111,16 +121,22 @@ 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" From 509a58c21500fabecdcc95ca49f79e2767d60520 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 20:27:54 -0400 Subject: [PATCH 4/6] feat(analytics-events): generate a readable EVENTS.md from the catalogue EVENTS.md lists every event with a table of its properties: the key, the type in events.toml's own words, whether it's optional, and the list it draws from. Lists, the amount group, drift notes and people counters get sections of their own. Two builders that send one name share a heading, so the name has one anchor to link to. The page is checked in. writeAnalyticsEventsPage rewrites it, and checkAnalyticsEventsPage, now part of flipcashTestDebug, fails with the command to run when the page and events.toml disagree. --- .../AnalyticsCatalogueConventionPlugin.kt | 17 + .../analytics/AnalyticsCatalogue.kt | 5 +- .../analytics/AnalyticsEventsPage.kt | 60 ++ .../buildlogic/analytics/MarkdownEmitter.kt | 165 ++++++ .../buildlogic/analytics/EventsPageTest.kt | 216 +++++++ build.gradle.kts | 2 + libs/analytics-events/EVENTS.md | 550 ++++++++++++++++++ 7 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt create mode 100644 build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt create mode 100644 libs/analytics-events/EVENTS.md 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/MarkdownEmitter.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt new file mode 100644 index 0000000000..44bc094603 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt @@ -0,0 +1,165 @@ +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, or open an issue + with the "Analytics event" form. + + 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/EventsPageTest.kt b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt new file mode 100644 index 0000000000..aaf4d850aa --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt @@ -0,0 +1,216 @@ +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, or open an issue + with the "Analytics event" form. + + 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..2dd0bb8cda --- /dev/null +++ b/libs/analytics-events/EVENTS.md @@ -0,0 +1,550 @@ +# 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, or open an issue +with the "Analytics event" form. + +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` | From 1b26ffca168f92ac6f4e5327569e94715465d8b7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 20:27:55 -0400 Subject: [PATCH 5/6] chore(github): add an optional analytics event request form The form asks for the event name, when it's sent, its properties with a type word and an example value, the platforms and why it's needed. Its intro says it's optional: anyone can edit events.toml in a pull request instead. It applies an analytics label, which the repo doesn't have yet; GitHub skips a form label that doesn't exist. --- .github/ISSUE_TEMPLATE/analytics-event.yml | 78 ++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/analytics-event.yml diff --git a/.github/ISSUE_TEMPLATE/analytics-event.yml b/.github/ISSUE_TEMPLATE/analytics-event.yml new file mode 100644 index 0000000000..1c76ff2f39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/analytics-event.yml @@ -0,0 +1,78 @@ +name: Analytics event +description: Ask for a new analytics event, or a change to one we already send. +title: "Analytics: " +labels: ["analytics"] +body: + - type: markdown + attributes: + value: | + This form is optional. Every event lives in + [`libs/analytics-events/events.toml`](https://github.com/code-payments/code-android-app/blob/code/cash/libs/analytics-events/events.toml), + and anyone can add or change one there in a pull request instead; the comments at the top + of that file say how. [`EVENTS.md`](https://github.com/code-payments/code-android-app/blob/code/cash/libs/analytics-events/EVENTS.md), + next to it, lists every event we send today. + + - type: dropdown + id: kind + attributes: + label: New or changed? + options: + - A new event + - A change to an event we already send + validations: + required: true + + - type: input + id: existing + attributes: + label: Existing event + description: For a change, a link to the event's entry in EVENTS.md. + placeholder: https://github.com/code-payments/code-android-app/blob/code/cash/libs/analytics-events/EVENTS.md#... + + - type: input + id: name + attributes: + label: Event name + description: The name exactly as it should appear in Mixpanel. + placeholder: "Chat: Sent Message" + validations: + required: true + + - type: textarea + id: when + attributes: + label: When it's sent + description: What the user does, or what happens in the app, that sends it. + validations: + required: true + + - type: textarea + id: properties + attributes: + label: Properties + description: | + One per line: the name Mixpanel shows, a type, and an example value. Types are text, + yes/no, count (a whole number), decimal, duration (milliseconds), or the name of a list + in EVENTS.md. Say if the app may leave one out. + placeholder: | + Chat Type | ChatType | Group + Message Length | count | 42 + Error | text | Network timeout (optional) + validations: + required: false + + - type: checkboxes + id: platforms + attributes: + label: Platforms + options: + - label: Android + - label: iOS + + - type: textarea + id: why + attributes: + label: Why it's needed + description: The question this event would answer. + validations: + required: true From b94950d2c3a0052d84b48bd51e8936c2a59cd8f8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 21:07:48 -0400 Subject: [PATCH 6/6] chore(github): drop the analytics event request form This repo is public, and GitHub can't limit an issue template to org members, so anyone could have filed requests through it. To add or change an event, edit events.toml in a pull request; the header says to ask an engineer otherwise. EVENTS.md and its generator no longer mention the form. --- .github/ISSUE_TEMPLATE/analytics-event.yml | 78 ------------------- .../buildlogic/analytics/MarkdownEmitter.kt | 3 +- .../buildlogic/analytics/EventsPageTest.kt | 3 +- libs/analytics-events/EVENTS.md | 3 +- libs/analytics-events/events.toml | 4 +- 5 files changed, 5 insertions(+), 86 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/analytics-event.yml diff --git a/.github/ISSUE_TEMPLATE/analytics-event.yml b/.github/ISSUE_TEMPLATE/analytics-event.yml deleted file mode 100644 index 1c76ff2f39..0000000000 --- a/.github/ISSUE_TEMPLATE/analytics-event.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Analytics event -description: Ask for a new analytics event, or a change to one we already send. -title: "Analytics: " -labels: ["analytics"] -body: - - type: markdown - attributes: - value: | - This form is optional. Every event lives in - [`libs/analytics-events/events.toml`](https://github.com/code-payments/code-android-app/blob/code/cash/libs/analytics-events/events.toml), - and anyone can add or change one there in a pull request instead; the comments at the top - of that file say how. [`EVENTS.md`](https://github.com/code-payments/code-android-app/blob/code/cash/libs/analytics-events/EVENTS.md), - next to it, lists every event we send today. - - - type: dropdown - id: kind - attributes: - label: New or changed? - options: - - A new event - - A change to an event we already send - validations: - required: true - - - type: input - id: existing - attributes: - label: Existing event - description: For a change, a link to the event's entry in EVENTS.md. - placeholder: https://github.com/code-payments/code-android-app/blob/code/cash/libs/analytics-events/EVENTS.md#... - - - type: input - id: name - attributes: - label: Event name - description: The name exactly as it should appear in Mixpanel. - placeholder: "Chat: Sent Message" - validations: - required: true - - - type: textarea - id: when - attributes: - label: When it's sent - description: What the user does, or what happens in the app, that sends it. - validations: - required: true - - - type: textarea - id: properties - attributes: - label: Properties - description: | - One per line: the name Mixpanel shows, a type, and an example value. Types are text, - yes/no, count (a whole number), decimal, duration (milliseconds), or the name of a list - in EVENTS.md. Say if the app may leave one out. - placeholder: | - Chat Type | ChatType | Group - Message Length | count | 42 - Error | text | Network timeout (optional) - validations: - required: false - - - type: checkboxes - id: platforms - attributes: - label: Platforms - options: - - label: Android - - label: iOS - - - type: textarea - id: why - attributes: - label: Why it's needed - description: The question this event would answer. - validations: - required: true 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 index 44bc094603..5b21cadca3 100644 --- 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 @@ -26,8 +26,7 @@ internal object MarkdownEmitter { ./gradlew :libs:analytics-events:writeAnalyticsEventsPage --> 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, or open an issue - with the "Analytics event" form. + 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** 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 index aaf4d850aa..e3cd8996bd 100644 --- 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 @@ -69,8 +69,7 @@ class EventsPageTest { ./gradlew :libs:analytics-events:writeAnalyticsEventsPage --> 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, or open an issue - with the "Analytics event" form. + 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** diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 2dd0bb8cda..94a4dd5259 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -4,8 +4,7 @@ ./gradlew :libs:analytics-events:writeAnalyticsEventsPage --> 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, or open an issue -with the "Analytics event" form. +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** diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index b366bc5bdc..090384e8fb 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -1,7 +1,7 @@ # 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 ask for an event without editing this file, open an issue with the -# "Analytics event" form. +# readable page. To add or change an event, edit this file in a pull request. If you'd +# rather not, ask an engineer to make the change. # # To add an event, copy an [[event]] entry below and change it: #