Engineering

The shared analytics event that meant something different on each platform

Each SDK truncates names, coerces types and limits parameters differently.

MobileByteSensei 11 min read
kmpobservabilityarchitecture

TL;DR

  • A shared checkout_step_completed event, 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.

One shared event arriving in the warehouse under three different names, with a truncation on one platform and a camel-casing on another, so a funnel built on one name counted only one platform

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 four SDKs limits measured by round-trip rather than read from documentation, with name length differing by a factor of fifty and the parameter cap by a factor of two hundred and fifty

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.

Three findings: which four parameters survived the cap varying between runs, two parameters that did not belong on the event at all, and a user identifier losing precision through a double-backed numeric type

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.


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".

Follow by RSS