Sitemap

Three analytics events are enough

10 min readAug 13, 2026

--

How we rebuilt our mobile analytics event taxonomy in React Native: typed events, per-destination adapters for Mixpanel, MoEngage and BigQuery, and an offline event queue that survives a bad network.

By Balamurugan V · Habuild Engineering · 13-Aug-2026

Press enter or click to view image in full size

Every mobile analytics implementation starts clean and ends the same way. One event per feature, then one per experiment, then one because a PM asked for it on a Thursday. Two years later nobody can answer a simple question — which event fires when a user pauses their subscription? — without grepping the codebase.

The standard fix is a naming convention document. We tried that. It does not work, and the reason it does not work is structural: a convention is advisory, and the compiler does not read Confluence.

So we did the opposite thing. We made the event taxonomy small enough to fit in a sentence, and we made it a type.

Our entire mobile app emits three analytics events. A screen impression, a tap, and a catch-all log that carries a typed name. There is no fourth, and adding one is a compile error.

The symptom: an analytics dashboard nobody trusts

Before the rewrite we had [N] distinct event names across the app. The exact number matters less than what it produced:

  • Three names for the same behaviour. A tap on the same button had been instrumented separately by three people over two years, with three different property shapes.
  • Dashboards that silently broke. Someone renamed a property to match a refactor. The Mixpanel dashboard reading that property kept rendering — it just showed zero.
  • Nobody could add a destination. Sending events to a warehouse meant touching every call site, because every call site decided for itself what to send and where.
  • No way to audit. “Do we track X?” required a grep, and the answer was often “yes, twice, differently.”

The underlying problem is that an event name is an API. Every name you add is a schema you are now committed to maintaining across every destination, forever, with no compiler checking that you kept your promise.

Every event name you add is a schema you now maintain forever. Most teams add them like they’re free.

Why analytics naming conventions always fail

Naming conventions fail for the same reason style guides failed before linters existed. They rely on every engineer, on every PR, remembering a rule that lives somewhere else and is enforced by nobody.

The instinct is then to add review process. That scales badly and it targets the wrong layer. The actual fix is to shrink the decision space until there is nothing left to get wrong.

If there are only three events, an engineer instrumenting a new feature is not choosing a name. They are choosing between “impression”, “tap”, and “something else” — and if it’s something else, they add one entry to a typed union that shows up in code review as a deliberate, visible act.

The three canonical events

Press enter or click to view image in full size

The first two cover the overwhelming majority of instrumentation in any consumer app. The third is the pressure valve, and the important part is that it is not a free-text escape hatch — event_name is itself a closed union.

Making the event taxonomy a compile-time contract

This is the piece that makes it hold. In TypeScript:

// The complete set. Adding a fourth is a compile error,
// not a code-review conversation.
export type AnalyticsEvent = 'screen_viewed' | 'button_clicked' | 'log';

// Everything that used to be its own event becomes a name here.
export type LogEventName =
| 'session_completed'
| 'subscription_paused'
| 'reminder_scheduled'
| 'streak_broken';
// ...one line per behaviour, reviewed like any other API change

export interface AnalyticsPayload {
event_name?: LogEventName;
[key: string]: unknown;
}

export function trackEvent(
event: AnalyticsEvent,
payload: AnalyticsPayload,
): void;

Two consequences worth calling out.

Adding a behaviour is a one-line diff in a file people watch. The union becomes a registry. A reviewer looking at a PR that adds 'paywall_dismissed' to LogEventName knows immediately that a new thing is being tracked, and can ask whether it duplicates something.

Removing one fails the build in every place that referenced it. We lean on this hard — see the drop lists below.

Routing belongs in the adapter, not the call site

We send events to three kinds of destination: a product analytics tool for funnels and retention (Mixpanel), an engagement platform that drives push and lifecycle campaigns (MoEngage), and a warehouse for SQL and joins against the rest of the business (BigQuery).

They want different things. The product tool doesn’t want debug noise. The engagement platform only cares about events that trigger campaigns. The warehouse wants everything, flat.

The instinct every team has is to encode that at the call site:

// Don't. This is how you get a taxonomy nobody can audit.
if (destination !== 'crm') {
trackEvent('log', { event_name: 'streak_broken', ... });
}

Six months of that and the routing rules are smeared across two hundred files. Instead, the client is deliberately dumb: it fans out to every adapter, and each adapter decides what it accepts and what shape it wants.

Press enter or click to view image in full size
The whole architecture in one picture. Call sites choose between three shapes and nothing else. Every decision about which destination receives an event — and in what shape — lives inside that destination’s adapter, so silencing an event for one vendor is a one-file change instead of a conditional scattered across the app.

The adapter interface is small:

interface AnalyticsAdapter {
name: string;
accepts(event: AnalyticsEvent, logName?: LogEventName): boolean;
shape(payload: AnalyticsPayload): unknown;
send(event: string, payload: unknown): Promise<void>;
}

async function dispatch(event: AnalyticsEvent, payload: AnalyticsPayload) {
for (const adapter of adapters) {
if (!adapter.accepts(event, payload.event_name)) continue;
try {
await adapter.send(event, adapter.shape(payload));
} catch (error) {
// One vendor being down must never break the others,
// and must never surface to the user.
breadcrumb(adapter.name, error);
}
}
}

Each adapter keeps its own list. Two are drop lists — send everything except these. One is an allowlist, because the warehouse bills by volume and we’d rather add deliberately than pay for noise.

The trick that keeps the lists honest

A hardcoded list of event names rots the moment someone removes an event from the union. The list still compiles, still references a name that no longer exists, and quietly does nothing.

TypeScript’s satisfies operator fixes this:

const CRM_BLOCKED_EVENTS = [
'streak_broken',
'paywall_dismissed',
] satisfies LogEventName[];

Now deleting 'streak_broken' from LogEventName fails the build in the adapter that referenced it. The drop lists cannot drift from the taxonomy, because the compiler ties them together.

Shaping per destination: flattening for the warehouse

The product analytics tool is happy with nested JSON. The warehouse is not — nested objects mean either a JSON column you have to unpack in every query, or a schema migration per shape.

Get Habuild Engineering’s stories in your inbox

Join Medium for free to get updates from this writer.

So the warehouse adapter’s shape() flattens everything into a single level of scalar columns.

Press enter or click to view image in full size
One transform, five rules. Flattening looks trivial until you meet production data. Each rule here exists because something broke: a null that became the string “null”, a chart that silently died on NaN, and a circular reference in a payload someone built from a Redux slice that blew the stack on a user's phone.

That last one deserves emphasis. If you write a recursive flattener without a visited set, someone will eventually pass you an object with a cycle — most easily by spreading a state object that has a parent reference — and your analytics call will crash the app. Guard it:

function flatten(
obj: Record<string, unknown>,
prefix: string,
out: Record<string, string | number | boolean>,
visited: WeakSet<object>,
): void {
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) continue;

const flatKey = prefix ? `${prefix}_${key}` : key;

if (Array.isArray(value)) {
out[flatKey] = JSON.stringify(value);
} else if (typeof value === 'object') {
if (visited.has(value as object)) continue; // cycle guard
visited.add(value as object);
flatten(value as Record<string, unknown>, flatKey, out, visited);
} else if (typeof value === 'number') {
if (Number.isFinite(value)) out[flatKey] = value; // no NaN/Infinity
} else {
out[flatKey] = value as string | boolean;
}
}
}

Delivery: batching and the offline event queue

Taxonomy is only half the problem. The other half is that a meaningful share of our users are on unreliable mobile data, and an analytics event that fails to send is a user who silently disappears from your funnel.

If you drop events on network failure, every retention number you report is biased against exactly the users you most need to understand.

Press enter or click to view image in full size
Delivery is a separate concern from taxonomy. Events batch in memory and flush on a size or time threshold. If the network is unavailable the batch persists to an on-device key-value store and replays on reconnect. We use MMKV for this — it’s synchronous, which matters when you’re persisting during an app-background transition and don’t have time for an async write to settle.

Three rules the queue follows, each learned the hard way:

The analytics path never throws. Every queue operation is wrapped. Losing one event is strictly better than crashing the app — and an exception thrown inside a tracking call will propagate into whatever user action triggered it.

Cap the queue. A user offline for a week should not accumulate an unbounded blob in storage. Drop oldest beyond a ceiling.

Deduplicate on flush by event id. Two flushes can race — one triggered by reconnect, one by a timer — and without an id you double-count exactly the events belonging to users with the worst connectivity.

Analytics property keys are wire contracts

This rule saved us more pain than anything else on this page, and it is one line:

Never rename an emitted property key to match a code refactor.

If a constant is called WR_VERSION and you rename it to WATER_REMINDER_VERSION, that is a good refactor. But if it emits a property called wr_version, that string stays. It is referenced by dashboards, saved cohorts, campaign triggers and warehouse queries — none of which are in your repository, and none of which will fail loudly when the property stops arriving. They just quietly show zero.

Treat emitted keys the way you’d treat a public API response field: the internal symbol can be renamed freely, the wire format cannot.

The exceptions we kept

Every real system has grandfathered exceptions, and a post that pretends otherwise is a conference talk rather than an engineering post. We have two.

One event goes to the product tool unwrapped. session_completed existed as a standalone event before the consolidation, product had years of dashboards built on it in that shape, and asking them to rebuild for internal consistency would have been us imposing our tidiness on their work. So one adapter special-cases exactly one event. The warehouse still receives it as a normal log.

What matters is that this is a documented one-off, not a mechanism. There is no configuration for it, no second case that could be added easily. The next person who wants an exception has to write code and justify it, which is the correct amount of friction.

Automatic SDK events are off. Most analytics SDKs ship with automatic lifecycle events. They are one boolean, all-or-nothing, and they arrive with names you don’t control that sit next to your taxonomy looking like part of it. We turned them off. If we want app-open, we emit it ourselves, named our way.

The hole in the type system

Worth being honest about: our in-app web views can post analytics events across the bridge. Those arrive as parsed JSON at runtime, which means AnalyticsEvent constrains the people writing app code but does not constrain the wire.

A grep in the mobile repo will not find those events, because nothing in the repo emits them. The adapter drop lists are the only enforcement point that always runs — which is another argument for putting routing there rather than at call sites.

Diagnostics are not analytics

A common failure mode: using the analytics pipeline as a logging pipeline. A request failed, so somebody fires a tracking event. Multiply by every user on a bad network and you’ve spent your event budget on noise and made your funnels harder to read.

Diagnostics go to the error tracker instead, and even there with judgement:

  • Always add a breadcrumb. Breadcrumbs are free and attach to whatever real error surfaces later, which is when you actually want the context.
  • Only capture an exception when it’s a signal. A request failing while the device is offline is not a bug in your app — it’s a user in a lift. At that volume, raising an issue for each one exhausts your quota and buries the real crashes.

Migrating without breaking the dashboards

The part everyone asks about. You cannot flip a taxonomy overnight, because the historical data is in the old shape and the dashboards people run the business on are built on it.

What worked for us:

  1. Dual-emit first. New taxonomy alongside the old, for one full release cycle. Cheap, and it means the new pipeline is proven against real traffic before anything depends on it.
  2. Rebuild dashboards on the new shape while the old still flows. Data teams can validate the numbers match, on live data, with a rollback that costs nothing.
  3. Cut over reads, then stop the old writes. Two separate steps, ideally two separate releases.
  4. Keep a mapping document. Old name → new event + event_name. Someone will find a five-year-old dashboard six months later and need it.

The mobile-specific wrinkle: you cannot deprecate an event on your own schedule, because old app versions keep emitting the old taxonomy for as long as users don’t update. Plan for a long tail measured in months, and make the ingestion side tolerant of both shapes rather than trying to force the clients to converge.

What it cost, and what we’d do differently

Honest accounting:

  • The migration was the expensive part, not the design. The design fits on a page. Rewiring every call site and rebuilding dashboards took a meaningful chunk of a release cycle.
  • We under-invested in the property layer. The event names are rigorously typed; the property bags are still loosely typed. If we did it again we’d type per-event property shapes from the start, so a subscription_paused event that forgets plan_id fails to compile.
  • Three events is right; three is not magic. The number that matters is “small enough that nobody needs to think”. If a fourth genuinely earns its place in your product, add it — deliberately, once.

Takeaways

  • An event name is an API. Treat adding one as an API change, because that is what it is.
  • Naming conventions do not survive contact with a growing team. Types do.
  • Put routing and shaping inside per-destination adapters, never at the call site.
  • Tie filter lists to the taxonomy with satisfies so they cannot silently drift.
  • Flatten for the warehouse, and guard the flattener against cycles, nulls and non-finite numbers.
  • Queue events durably on-device, or your funnels will systematically under-count users on bad networks.
  • Never rename an emitted property key to match an internal refactor.
  • Diagnostics belong in your error tracker, not your analytics pipeline.

We’re a small engineering team building habit-forming health products for a few hundred thousand members. If you’ve been through an analytics migration and did it differently, we’d genuinely like to hear how.

--

--

Habuild Engineering
Habuild Engineering

The engineering team behind Habuild. We write about building health habits at scale — mobile, backend, data, and the systems that hold it together.