RORK LABJP
DEADLINE — From August 31, every new app and app update on Google Play must target Android 16 (API level 36). Eight days to goEXTENSION — Eligible developers can request an extension through November 1 via a form in Play Console, but the request itself has to be filed before the deadlineEXISTING — Even apps you leave alone need at least API level 35 to stay discoverable to new users on recent Android devices. Standing still quietly cuts off new installsEXPO — Expo SDK 57 moves React Native from 0.85 to 0.86 as a small, one-command upgrade with no breaking changes. React stays at 19.2, same as SDK 56MEMORY — expo@57.0.9 bumps React Native to 0.86.2 and clears the Hermes V1 memory regression that inflated usage in apps importing reanimated or workletsPREBUILD — expo prebuild now clears and regenerates the native directories by default, which collides easily with hand-edited config from your API 36 migrationDEADLINE — From August 31, every new app and app update on Google Play must target Android 16 (API level 36). Eight days to goEXTENSION — Eligible developers can request an extension through November 1 via a form in Play Console, but the request itself has to be filed before the deadlineEXISTING — Even apps you leave alone need at least API level 35 to stay discoverable to new users on recent Android devices. Standing still quietly cuts off new installsEXPO — Expo SDK 57 moves React Native from 0.85 to 0.86 as a small, one-command upgrade with no breaking changes. React stays at 19.2, same as SDK 56MEMORY — expo@57.0.9 bumps React Native to 0.86.2 and clears the Hermes V1 memory regression that inflated usage in apps importing reanimated or workletsPREBUILD — expo prebuild now clears and regenerates the native directories by default, which collides easily with hand-edited config from your API 36 migration
Articles/Dev Tools
Dev Tools/2026-07-09Advanced

When Amplitude's Funnel Improved Overnight — Field Notes on Catching Event Schema Drift With a Self-Audit

In a Rork-built app, the Amplitude funnel showed signup-to-purchase conversion jumping from 8% to 19% overnight, yet revenue stayed flat. The cause was a property-name drift that inflated the count. Field notes on measuring the drift with an event contract and a runtime validator, then tightening it in stages.

Amplitudeanalytics5React Native229event designdata qualityExpo179funnel analysis

Premium Article

One morning the funnel showed signup-to-purchase conversion leaping from 8.1% to 19.4%. I had shipped nothing the night before. Something tightened in my chest. Then I checked Stripe — revenue had barely moved.

Conversion doubled while deposits stayed flat. That gap is the classic tell that your event tracking has quietly broken.

These are field notes on a schema drift I hit in a Rork-built React Native / Expo app instrumented with Amplitude — how I surfaced it with a runtime validator, measured the drift rate, and tightened it back in stages. This is not about installing the SDK. It is about what to do when the numbers start lying after you install it.

When numbers lie, the SDK throws nothing

The awkward thing about product analytics is that nothing fails when it breaks. Write track('Purchase Completed', { price: '¥580' }) and the SDK happily sends price as a string. Rows keep landing on the dashboard, looking perfectly ordinary.

Tracing it back, the cause was simple. From one update onward, the purchase event's property drifted from plan_id to planId (camelCase). Amplitude treats those as two different properties. On top of that, a duplicated launch path was firing Purchase Completed twice, and the funnel segment I was reading counted totals rather than uniques — so the number inflated.

Drift comes in more than one shape. The four I actually hit sort out like this.

Drift typeSymptomFunnel impact
Property name driftplan_id and planId mixedFilter conditions half-miss
Type driftprice sometimes number, sometimes stringSums and averages break
Double firingSame event on resume and on mountConversion reads higher than real
Event name driftPurchase Completed vs purchase_completedOne side vanishes from the funnel

All of these look fine to the SDK. That is precisely why the correctness of measurement is something you have to measure yourself.

Declare the event contract first

The turning point was fixing, in a single "contract," which events may fire and what properties they carry. Staring at scattered track() calls reveals nothing. You declare the intended shape first, then reconcile against it at runtime. The order matters.

I wrote the contract as types. Since TypeScript types vanish at runtime, I derive both the type and the validation schema from one object.

// analytics/contract.ts
// Event contract — the source of truth for allowed events and properties
export const EVENT_CONTRACT = {
  'Purchase Completed': {
    plan_id: 'string',
    price: 'number',
    currency: 'string',
  },
  'Registration Completed': {
    auth_method: 'string', // 'email' | 'apple' | 'google'
  },
  'Paywall Viewed': {
    source: 'string',
  },
} as const;
 
export type EventName = keyof typeof EVENT_CONTRACT;
export type PropSpec = Record<string, 'string' | 'number' | 'boolean'>;

The key decision is to treat any event name or property not in the contract as an error. To add a new event, you add it to the contract first. That small friction stops naming drift and missing properties at the entrance.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
A concrete runtime validator that turns silent schema drift — events firing but lying — into a countable Event Contract Violation
How I measured the contamination rate and walked a fake 19% conversion back to the real 8%
A CI check that freezes the event contract so a Rork or Expo update can't quietly break it before release
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

Related Articles

Dev Tools2026-08-23
Predictive back on Android 16: what to fix before August 31, and what can wait
Targeting API 36 turns predictive back on by default and stops onBackPressed from firing. Here is how I split the work that the deadline actually requires from the work that does not, and how I kept the opt-out from disappearing on the next prebuild.
Dev Tools2026-08-22
Every bulk replace exited zero. The damage was in the lines I did not delete
Run a bulk replace over generated code and the breakage lands on the neighbouring lines, not the matched ones. Here is what broke in a live project, and a dependency-free guard that checks the invariants a replace must preserve.
Dev Tools2026-08-17
When an Expo UI drop-in swap actually removes a dependency
Expo UI went stable in SDK 56 with drop-in replacements for eight community packages. Swapping one import does not always shrink your dependency list. Here is how to decide which swaps actually pay off, straight from the dependency graph.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →