●MAX — Rork Max builds native Swift apps instead of React Native for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — Rork Max unlocks native features like AR/LiDAR scanning, Metal 3D games, widgets, and Dynamic Island●APPLE — It also reaches advanced Apple capabilities such as HealthKit, HomeKit, NFC, App Clips, and on-device Core ML●RN — Standard Rork builds iOS and Android from a single prompt using React Native (Expo)●PRICE — Rork is free to start, with paid plans beginning at $25 per month●GROWTH — Rork now sees 743,000+ monthly visits, 85% growth, and recently raised $15M●MAX — Rork Max builds native Swift apps instead of React Native for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — Rork Max unlocks native features like AR/LiDAR scanning, Metal 3D games, widgets, and Dynamic Island●APPLE — It also reaches advanced Apple capabilities such as HealthKit, HomeKit, NFC, App Clips, and on-device Core ML●RN — Standard Rork builds iOS and Android from a single prompt using React Native (Expo)●PRICE — Rork is free to start, with paid plans beginning at $25 per month●GROWTH — Rork now sees 743,000+ monthly visits, 85% growth, and recently raised $15M
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.
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 type
Symptom
Funnel impact
Property name drift
plan_id and planId mixed
Filter conditions half-miss
Type drift
price sometimes number, sometimes string
Sums and averages break
Double firing
Same event on resume and on mount
Conversion reads higher than real
Event name drift
Purchase Completed vs purchase_completed
One 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 propertiesexport 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.
Turn drift into a violation event with a runtime validator
I reconciled against the contract inside a thin wrapper that every call must pass through, instead of calling track() directly. When it detects a violation, it warns in development and, in production, sends the violation itself to Amplitude as a single Event Contract Violation event — turning drift from a vanishing defect into a measurable number.
// analytics/trackSafe.tsimport { track } from '@amplitude/analytics-react-native';import { EVENT_CONTRACT, EventName } from './contract';export function trackSafe(name: EventName, props: Record<string, unknown> = {}) { const spec = EVENT_CONTRACT[name]; const violations: string[] = []; if (!spec) { // Event name not in contract = suspected naming drift violations.push(`unknown_event:${name}`); } else { for (const [key, type] of Object.entries(spec)) { if (!(key in props)) violations.push(`missing:${key}`); else if (typeof props[key] !== type) { violations.push(`type:${key}=${typeof props[key]}!=${type}`); } } // Properties outside the contract (catches drift like planId) for (const key of Object.keys(props)) { if (!(key in spec)) violations.push(`extra:${key}`); } } if (violations.length > 0) { if (__DEV__) console.warn(`[contract] ${name}`, violations); // Emit the violation as an event so we can aggregate the drift rate later track('Event Contract Violation', { event_name: name, violations, violation_count: violations.length, }); } track(name, props);}
Passing through this wrapper, a line like trackSafe('Purchase Completed', { planId: 'pro', price: '¥580' }) records three violations: extra:planId, missing:plan_id, and type:price=string!=number. Drift becomes a visible row you can count on the dashboard.
In the first week, Event Contract Violation reached 6.2% of all events. The top contributors were double firing (not unknown, but an inflator of totals) and mixed camelCase — exactly where the inflated conversion came from.
Make the drift rate a KPI and tighten in stages
Trying to fix everything at once leaves you unsure where you started. I watched the drift rate — the share of events carrying a violation — as a single daily number, and worked down from the top offenders.
Point
Drift rate
Main cause
Action taken
Week 1
6.2%
Double firing, mixed camelCase
Applied contract and wrapper to all tracks
Week 2
2.4%
Duplicate resume events
Single firing path, idempotency key
Week 4
0.5%
Rare type drift (price as string)
Normalized amounts to number before send
The double firing came from sending App Opened separately on AppState resume and on component mount. I consolidated the origin to one place and dropped short-window duplicates with an idempotency key.
// analytics/dedupe.tsconst recent = new Map<string, number>();const WINDOW_MS = 2000;export function isDuplicate(key: string): boolean { const now = Date.now(); const last = recent.get(key); recent.set(key, now); if (last && now - last < WINDOW_MS) return true; return false;}// Usage: put `if (isDuplicate('App Opened')) return;` just before trackSafe
Once the drift rate fell below 0.5%, funnel conversion settled back near 8.1%. The number dropped, and I felt relief instead of disappointment. An honest 8% is far more useful for the next move than a lying 19%.
Freeze the contract in CI before a Rork/Expo update breaks it
Drift is not only human carelessness — tool updates cause it too. When you regenerate a screen in Rork, or an Expo SDK update shifts navigation behavior, firing paths slip quietly. So I promoted the contract into a check that a machine runs before release.
The idea is simple: a small script that scans the source and counts whether bare track( calls — ones that bypass the contract — have crept back in.
// scripts/check-tracking.ts (run in CI)import { readFileSync } from 'node:fs';import { globSync } from 'glob';const files = globSync('app/**/*.{ts,tsx}');let bareCalls = 0;for (const f of files) { const src = readFileSync(f, 'utf8'); // Detect bare track( that does not go through trackSafe const matches = src.match(/(?<!Safe|_)\btrack\(/g) ?? []; if (matches.length) { console.error(`bare track() in ${f}: ${matches.length}`); bareCalls += matches.length; }}if (bareCalls > 0) { console.error(`\n${bareCalls} bare track() calls found. Route them through trackSafe.`); process.exit(1);}console.log('tracking contract: clean');
Since adding this check, update-driven drift gets stopped ahead of time. In solo development, writing one of these unglamorous guards once pays back your future self. I have made a habit of building these "you'll notice when it breaks" mechanisms before the feature itself.
If I met the same situation again, this is the sequence I would follow.
Suspect the gap between conversion and real revenue (Stripe, etc.). The better the number looks, the more you should doubt the measurement.
Declare the event contract first — fix allowed events and properties on one page.
Surface violations as Event Contract Violation through a trackSafe wrapper.
Watch the drift rate as one daily number and work down from the top offenders.
Stop double firing with a single firing path and an idempotency key.
Promote the contract into a CI check to stop update-driven drift ahead of release.
Instrumentation isn't done when you install it; it becomes trustworthy only when you keep measuring whether it has drifted. It is not a flashy feature, but this quiet mechanism is what steadily holds up data-driven decisions. I hope it helps in your own build. Thank you for reading.
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.