The shared analytics event that meant something different on each platform
Each SDK truncates names, coerces types and limits parameters differently.
TL;DR
- A shared
checkout_step_completedevent, emitted identically on four platforms, arrived in the warehouse as four different event names — and the funnel silently stopped comparing them, so a 3-week-old conversion regression was invisible on three platforms. - Each SDK applies its own limits: name length, parameter count, allowed characters and type coercion. None of them errors; all of them truncate or rename.
- The fix is to validate against the intersection of the four SDKs' limits at the shared call site, so a name that one SDK will mangle fails a test rather than a dashboard.
- What reading on buys: the four limit sets measured by round-trip, the validator, and the parameter that was being silently dropped on one platform for a year.
The case this post is about
One event, defined once:
// commonMain/kotlin/analytics/Events.kt
fun trackCheckoutStep(step: Int, paymentMethodType: String, cartValueMinor: Long, isGuest: Boolean) =
analytics.log(
name = "checkout_step_completed",
params = mapOf(
"step_number" to step,
"payment_method_type" to paymentMethodType,
"cart_value_minor" to cartValueMinor,
"is_guest_checkout" to isGuest,
"experiment_variant" to currentVariant(),
),
)
// commonMain
expect object Analytics {
fun log(name: String, params: Map<String, Any>)
}
Four actuals, each handing the name and the map to its platform SDK. Nothing transforms anything.
The symptom
A conversion regression found on Android and nowhere else:
# checkout completion rate, 21-day window
android 4.1% → 3.2% ← flagged by the funnel
ios (no funnel data for this step)
web (no funnel data for this step)
desktop (no funnel data for this step)
Three platforms had no data for a step they were emitting. The events were arriving — the raw event stream had them — under names nobody had built a funnel on:
# distinct event names in the warehouse, last 30 days
checkout_step_completed 1,204,118 android + desktop
checkout_step_comple 882,004 ios ← truncated at 20
checkoutStepCompleted 412,330 web ← camelCased
Three names for one event. iOS truncated at 20 characters. The web SDK camel-cased it. Desktop matched Android, which is why the first row carries both. The funnel had been built on the Android name, so three platforms' events existed and counted toward nothing.
And a parameter, separately:
experiment_variant present on android ✓ ios ✗ web ✓ desktop ✓
iOS was dropping the fifth parameter, because its SDK caps custom parameters at four.

Why the obvious fix failed
The first fix was to build three more funnels, one per name:
funnel_checkout_android
funnel_checkout_ios
funnel_checkout_web
Which works until the next event, and it makes every cross-platform comparison a join on a mapping table somebody maintains. We had 61 events; this was 61 × 4 definitions and a convention that the next person must follow.
The second fix was to shorten the name to fit the smallest limit:
name = "ckt_step_done" // 13 chars, fits everywhere
That fixes the name and loses the readability every other platform could have had — and it does nothing about the camel-casing, the parameter cap or the type coercion. One limit of four.
The third attempt was to normalise in each actual, which is closer and put the knowledge in the wrong place:
// iosMain
actual fun log(name: String, params: Map<String, Any>) =
firebase.logEvent(name.take(20), params.entries.take(4).toMap())
Now the truncation is explicit and still happens, so the warehouse still receives a different name from iOS — and the shared code has no idea, so a developer reading the shared call site cannot tell which parameters will survive. The limit has to be enforced where the name is chosen.
The actual mechanism
Four SDKs with four limit sets, applied silently.
name length name charset params type coercion
Firebase (android) 40 [a-z0-9_] 25 Long→Long
Firebase (ios, via Obj-C) 20 [a-z0-9_] 4 Long→NSNumber (Double)
Amplitude (web) 1024 any → camelCased 1000 all → JSON
our own sink (desktop) 256 any none all → JSON
Name length differs by a factor of fifty. checkout_step_completed is 23 characters: fine on
Android's 40, truncated by iOS's 20.
The charset handling differs. Firebase requires [a-z0-9_] and rejects a non-conforming name;
Amplitude accepts anything and normalises to camelCase, which is a rename rather than a rejection.
The parameter cap differs by a factor of 250. iOS's Obj-C Firebase binding caps custom parameters at 4 — and silently drops the rest, in map iteration order, which is not stable.
And the type coercion loses precision. cart_value_minor is a Long. Through the Obj-C
binding it becomes an NSNumber backed by a Double, so a cart value above 2^53 minor units is
approximated — which no cart reaches, and the same path makes a user id above that threshold lose
its last digits, which one of our id spaces does.
None of these produces an error. Every SDK accepts the call and does something reasonable by its own rules, and the result is four slightly different events that no longer join.
So the shared event name is not a name — it is a name per SDK, derived by four sets of rules the shared call site cannot see.

The fix
Validate against the intersection at the point the event is defined, so a name or parameter set that any SDK will mangle fails a test.
// commonMain/kotlin/analytics/EventContract.kt
//
// The INTERSECTION of the four SDKs' limits. Validating against the smallest of each means the
// name that reaches the warehouse is identical on every platform — which is the only property
// that makes a cross-platform funnel possible, and it is not a property any single SDK has.
object EventLimits {
const val MAX_NAME_LENGTH = 20 // iOS Firebase, the smallest
const val MAX_PARAMS = 4 // iOS Firebase, the smallest
const val MAX_PARAM_NAME_LENGTH = 24 // iOS Firebase
val NAME_PATTERN = Regex("^[a-z][a-z0-9_]*$") // Firebase's charset; Amplitude passes it through
const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L // 2^53 - 1, the Double-backed ceiling
}
/**
* Constructed once per event type, validated at construction. A failure here is a developer
* error at build time (see the test below), not a silently-renamed event in the warehouse.
*/
class Event private constructor(val name: String, val params: Map<String, Any>) {
companion object {
fun of(name: String, params: Map<String, Any>): Event {
val problems = validate(name, params)
require(problems.isEmpty()) {
"event '$name' will be mangled by at least one SDK:\n" +
problems.joinToString("\n") { " $it" }
}
return Event(name, params)
}
fun validate(name: String, params: Map<String, Any>): List<String> = buildList {
if (name.length > EventLimits.MAX_NAME_LENGTH) {
// Truncation, not rejection — which is why this is invisible in production.
add("name is ${name.length} chars; iOS Firebase truncates at " +
"${EventLimits.MAX_NAME_LENGTH}, producing '${name.take(20)}' there and " +
"the full name elsewhere. The two do not join.")
}
if (!EventLimits.NAME_PATTERN.matches(name)) {
add("name must match ${EventLimits.NAME_PATTERN.pattern}; Firebase rejects " +
"non-conforming names and Amplitude silently camelCases them.")
}
if (params.size > EventLimits.MAX_PARAMS) {
// Map iteration order is not stable, so WHICH parameters survive varies
// between runs on the capped platform. That is worse than losing a fixed set.
add("${params.size} parameters; iOS Firebase keeps ${EventLimits.MAX_PARAMS} " +
"and drops the rest in map order, which is not stable.")
}
params.forEach { (k, v) ->
if (k.length > EventLimits.MAX_PARAM_NAME_LENGTH) {
add("parameter '$k' is ${k.length} chars; truncated at " +
"${EventLimits.MAX_PARAM_NAME_LENGTH} on iOS.")
}
if (v is Long && v > EventLimits.MAX_SAFE_INTEGER) {
add("parameter '$k' = $v exceeds 2^53-1; the Obj-C binding stores it as a " +
"Double, so the value is approximated on iOS only.")
}
}
}
}
}
// commonMain/kotlin/analytics/Events.kt — the event, now within the intersection
fun trackCheckoutStep(step: Int, method: String, cartValueMinor: Long, isGuest: Boolean) =
analytics.log(
Event.of(
// 18 characters, fits iOS's 20.
name = "checkout_step_done",
params = mapOf(
"step" to step,
"method" to method,
"cart_minor" to cartValueMinor,
// Four parameters, so nothing is dropped. `is_guest` and `experiment_variant`
// moved to USER PROPERTIES, which have their own much larger budget and are
// the right home for something that does not vary per event anyway.
"variant" to currentVariant(),
),
)
)
And the test that makes the whole corpus checkable, because 61 events validated one at a time by hand is 61 chances to miss one:
// commonTest/kotlin/analytics/EventContractTest.kt
class EventContractTest {
@Test
fun everyDeclaredEventSurvivesEverySdk() {
// The registry is the list of every event the app emits — the same list the schema
// doc is generated from, so a new event that is not in it fails a different test.
val problems = EventRegistry.all.flatMap { e ->
Event.validate(e.name, e.exampleParams).map { "${e.name}: $it" }
}
assertTrue(problems.isEmpty(),
"these events will arrive under different names or with missing parameters " +
"depending on the platform:\n" + problems.joinToString("\n"))
}
}
And the output it produced, run against the corpus as it stood:
$ ./gradlew :core:analytics:allTests
EventContractTest > everyDeclaredEventSurvivesEverySdk FAILED
these events will arrive under different names or with missing parameters depending
on the platform:
checkout_step_completed: name is 23 chars; iOS Firebase truncates at 20, producing
'checkout_step_comple' there and the full name elsewhere. The two do not join.
payment_method_selected: name is 23 chars; …
onboarding_step_viewed: name is 22 chars; …
checkout_step_completed: 5 parameters; iOS Firebase keeps 4 and drops the rest in
map order, which is not stable.
user_profile_updated: parameter 'subscription_tier_identifier' is 28 chars; truncated
at 24 on iOS.
session_started: parameter 'user_id' = 14223372036854775 exceeds 2^53-1; the
Obj-C binding stores it as a Double, so the value is approximated on iOS only.
14 problem(s) across 9 of 61 events
Fourteen problems, nine events — including the user_id precision loss, which nobody had suspected
and which had been truncating iOS user ids for about a year.
How we proved it
That three platforms' events existed under different names — counted in the warehouse rather than inferred from a missing funnel:
distinct names matching 'checkout%step%', 30 days
checkout_step_completed 1,204,118 android, desktop
checkout_step_comple 882,004 ios ← truncated at 20
checkoutStepCompleted 412,330 web ← camelCased
The events were never lost. They were in the warehouse under names the funnel did not select.
That the limits are what the SDKs do, measured by round-trip rather than read from documentation — because the Obj-C binding's parameter cap is not in Firebase's own docs as a silent drop:
sent arrived
a 23-char name, ios 20 chars, truncated
a 23-char name, web camelCased, full length
5 params, ios 4 params — which 4 varied across 3 runs
5 params, android 5 params
a Long of 2^53+1, ios off by 1
a Long of 2^53+1, android exact
The varying-which-four is the finding that made the cap unacceptable rather than merely lossy.
That moving two parameters to user properties was not a workaround. Checked, because "move it somewhere with a bigger budget" sounds like dodging the limit:
property varies per event? belongs on
is_guest_checkout no — per session a user property
experiment_variant no — per session a user property
step yes the event
cart_minor yes the event
Neither varied per event, so both were being re-sent on every event for no reason. The limit forced a correction we should have made anyway.
That the validator fails for the right reason. Two fixtures, because a validator that only checks length would pass the two worse cases:
$ ./gradlew :core:analytics:allTests --tests '*EventContract*'
fixture: a 19-char name, 5 params FAILED — parameter cap ← length alone would pass
fixture: a camelCase 12-char name FAILED — charset ← length alone would pass
fixture: within every limit PASSED
What I cannot tell you is whether the limits are current. They are measured against the SDK versions we ship, by round-trip, and an SDK update can change them in either direction — a raised cap would make the validator needlessly strict and a lowered one would make it wrong. The round-trip probe is a script we re-run on SDK upgrades, which is a manual step and the honest state of it.
And an honest consequence. checkout_step_done is less readable than checkout_step_completed,
and every event name in the corpus is now constrained by the smallest SDK — so the names are
shorter than anyone would choose for a single-platform app. That is the price of a name that joins,
and renaming 9 events meant a one-off migration in the warehouse with a dual-write window.

What this generalizes to
When one call fans out to several vendor SDKs, the effective contract is the intersection of their limits, and none of them will tell you where that is because each is individually within its own rules. The failure is not an error but a divergence, which means it surfaces as missing data rather than as a broken build — so the check has to live at the point of definition and encode the smallest limit of each kind. And when a limit forces you to drop something, check whether the thing belonged there at all: two of our five parameters were per-session values being re-sent per event.
Related
- Four crash reporters, one shared module — the same four-SDK problem where the divergence is a symbol table rather than a name.
- The metric that went up for the wrong reason — what happens downstream when the events do arrive and the proxy is wrong.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".