Essential Analytics Events Every Mobile App Should Track
A compact tracking plan for mobile apps: which events to send, when to fire them, what to attach, and how to name them so the data stays useful.
On this page
- The plan at a glance
- Installs, sessions and the app lifecycle
- First open
- Session start and end
- Onboarding and screen views
- Onboarding started, step viewed, completed
- Screen viewed
- Paywalls and subscriptions
- Paywall opened and closed
- Subscription started
- Value actions and errors
- The 2–4 actions that represent value
- Errors, crashes and failed requests
- Naming events so they stay useful
- Properties versus new events
- Versioning
- No personal data, anywhere
- Keep a tracking plan
- How Riveora maps to this plan
- Recorded automatically
- One line each
- What happens to your properties
Most apps end up with one of two analytics problems. Either almost nothing is tracked and every product question is a guess, or everything is tracked by hand under names three people invented, and nobody trusts the numbers. Both come from adding events one at a time without a plan.
This is that plan, kept deliberately small: about a dozen events that answer the questions almost every app has (are people arriving, finishing onboarding, seeing the paywall, paying, coming back, hitting errors?). For each: when exactly to fire it, which properties to attach, and the most common mistake. Then naming, and finally how Riveora maps onto the plan.
The plan at a glance
The names follow one convention (object_action, snake_case, past tense), explained in the naming
section. Treat the list as a floor: you’ll add a few events of your own, but rarely many.
| Event | Fire it when | Key properties |
|---|---|---|
app_first_opened | The first launch on an install, once | platform, app_version |
session_started | Launch, or coming back after a timeout | start_reason, away_ms, app_version, os_version |
app_backgrounded | The app goes to the background | none needed |
screen_viewed | A different screen becomes visible | screen (a route template), previous_screen |
onboarding_started | The first onboarding step is shown | flow_id |
onboarding_step_viewed | Each step is shown | flow_id, step_id, step_index |
onboarding_completed | The last step is done | flow_id |
paywall_opened | The paywall is actually on screen | paywall_id, source, trigger |
paywall_closed | The paywall goes away | paywall_id, reason, duration_ms |
subscription_started | The store confirms the purchase (server side) | product_id, period_type, store |
2–4 value actions, e.g. recipe_saved | The action succeeds | source, a category or type |
error_occurred | An unhandled error reaches your handler | error_type, message (scrubbed), is_fatal |
request_failed | A request errors or gets no response | method, host, path template, status |
Every event should also carry context, attached by whatever sends your events: a timestamp, an anonymous install ID, your user ID once known, a session ID, the current screen, the platform and the app version. Without the app version, you can’t tell whether a release fixed anything.
Installs, sessions and the app lifecycle
First open
app_first_opened separates new installs from returning ones; retention charts and onboarding funnels
lean on it. Fire it once, on the first launch where your app has no record of having run before.
That “no record” is harder than it looks. The only practical record is something saved on the device, usually a flag in AsyncStorage:
import AsyncStorage from '@react-native-async-storage/async-storage';
type Track = (name: string, props?: Record<string, string | number | boolean | null>) => void;
const FIRST_OPEN_KEY = 'analytics.first_open_at';
export async function trackFirstOpen(track: Track, appVersion: string): Promise<void> {
const seen = await AsyncStorage.getItem(FIRST_OPEN_KEY);
if (seen !== null) return;
// Track first, then save the flag. If saving fails you may get a duplicate,
// which is easier to spot than a first open that never arrived.
track('app_first_opened', { app_version: appVersion });
await AsyncStorage.setItem(FIRST_OPEN_KEY, new Date().toISOString());
}A flag on the device counts installs as the device sees them, which is not the same as people:
- Reinstalls. Deleting an app on iOS removes its files, so a reinstall looks brand new. On Android, Auto Backup is on by default for apps targeting Android 6.0 or later and restores app data when the app is installed again, so a reinstall can look like a returning user.
- Cleared storage. “Clear storage” in Android settings wipes the flag, and the next launch is a “first open”.
- Several devices. Emily with a phone and a tablet is two installs. Only your own user ID, once she signs in, can join them.
Common mistake: treating app_first_opened as “new people”, or expecting it to match
store downloads. It measures installs that actually launched. The other classic: firing it before the user has
given consent, so the event is thrown away while the flag says it was sent.
Session start and end
Fire session_started on a cold launch and when the app returns after being away longer than a
timeout. A couple of minutes is a reasonable default: shorter, and copying a code from Messages splits one task in
two; much longer, and separate uses merge. Attach why it started (cold_start or
resumed_after_timeout), how long the person was away, and OS and app version.
Don’t rely on a session_ended event: when the system kills a backgrounded app, your JavaScript doesn’t
run, so it goes missing exactly when it matters. Record app_backgrounded instead, and treat the last
event before the timeout as the end. React Native’s
AppState gives you what you need:
import { useEffect, useRef } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
type Track = (name: string, props?: Record<string, string | number | boolean | null>) => void;
const SESSION_TIMEOUT_MS = 2 * 60 * 1000;
export function useSessionTracking(track: Track): void {
const backgroundedAt = useRef<number | null>(null);
useEffect(() => {
const subscription = AppState.addEventListener('change', (next: AppStateStatus) => {
// 'inactive' (iOS) is a permission prompt or Control Center, not leaving the app.
if (next === 'background') {
backgroundedAt.current = Date.now();
track('app_backgrounded');
} else if (next === 'active' && backgroundedAt.current !== null) {
const awayMs = Date.now() - backgroundedAt.current;
backgroundedAt.current = null;
if (awayMs > SESSION_TIMEOUT_MS) {
track('session_started', { start_reason: 'resumed_after_timeout', away_ms: awayMs });
} else {
track('app_foregrounded', { away_ms: awayMs });
}
}
});
return () => subscription.remove();
}, [track]);
}Common mistake: treating iOS’s inactive state as leaving. Permission prompts and the
purchase sheet make the app briefly inactive, and counting them chops up exactly the flows you care about most.
Onboarding and screen views
Onboarding started, step viewed, completed
Three events give you a full onboarding funnel. Fire onboarding_started when the first step is
actually shown (not when the app launches), onboarding_step_viewed each time a step appears, and
onboarding_completed when the person finishes the last step. Attach a flow_id to all
three, plus a stable step_id (pick_goal, enable_notifications) and its
step_index on each step. The index lets you draw the funnel in order; the ID survives reordering.
Common mistake: changing the steps and keeping the same flow ID. Old and new versions then mix in
one funnel and the drop-off at “step 3” means two different things. Give each version of the flow its own ID, such as
new_user_v1 and new_user_v2. Skippable steps are the other trap: if a step can be skipped,
decide whether skipping fires the next step’s event or a property on the current one, and write the decision down.
For reading the funnel once it exists, see
How to Track Onboarding Drop-Off in Mobile Apps.
Screen viewed
screen_viewed is the backbone of every journey and path analysis. Fire it when a different screen
becomes visible: on navigation state changes, and once for the initial screen. React Navigation documents the
pattern in its screen tracking guide (note that
onStateChange doesn’t fire for the first screen, so you also handle onReady). Attach the
screen name and, if you want paths without reconstructing them later, the previous screen.
Common mistake: using the raw path as the name. /recipe/8812 and
/recipe/9031 become two screens, then ten thousand (some containing user IDs). Send the route
template (/recipe/:id) and skip repeats of the same screen. Bottom sheets don’t change the route, so
mark them yourself if they matter. For time on screen, see
How to Measure Screen Engagement in React Native.
Paywalls and subscriptions
Paywall opened and closed
Fire paywall_opened when the paywall is actually on screen and paywall_closed when it goes
away. Two properties do most of the work. source says where the person was when it appeared
(onboarding, settings, a locked feature), and trigger says why (they tapped “Unlock”, they hit a
limit). Add a paywall_id so you can compare designs. On close, record the reason
(dismissed, purchased, restored, failed to show) and how long it was open.
Common mistake: firing the “opened” event when the component mounts. Paywalls are often mounted
hidden, preloaded, or fail to load their products, and every one of those inflates views and deflates conversion.
The second mistake is leaving out source: an overall conversion rate can hide that one entry point
converts far better than another. There’s a deeper guide in
How to Track Paywall Views and Engagement in Mobile Apps.
Subscription started
You’ll want subscription_started with product_id, period_type (trial,
intro or normal), price, currency and store. But the app isn’t the source of truth for it. Renewals and trial
conversions happen while the app is closed, purchases can be made on another device, and refunds arrive days
later. The store, or a service such as RevenueCat in front of it, is what actually knows. Record subscriptions from
their server notifications, with your own user ID in both systems, and check status in the app through that layer
(RevenueCat’s customer info, for example)
rather than a flag you saved yourself.
Common mistake: counting a free trial start as revenue. A trial is a promise to maybe pay later. Keep
trial starts and paid starts apart with period_type, and count revenue when money is actually charged.
Value actions and errors
The 2–4 actions that represent value
Every app has a few actions that mean “this person got what they came for”. In a recipe app that might be
recipe_saved and recipe_cooked; in a habit tracker, habit_created and
habit_checked_in; in a fitness app, workout_completed. Pick two to four. Fire them when
the action succeeds (after the save returns, not when the button is pressed), and attach what you’ll slice by:
where it happened (source: 'search'), a category, a count.
Common mistake: tracking every button by hand. Fifty hand-written tap events drift out of sync with the UI and bury the four that matter. Use automatic tap capture for taps, and spend hand-written events on outcomes. More in How to Track User Behavior in React Native Apps.
Errors, crashes and failed requests
Errors explain drop-offs no funnel will. Fire error_occurred from your global error handler with the
error type, a scrubbed message and whether it was fatal, and request_failed when a request gets an
error status or no response, with method, host, path template and status. Slow requests matter too: people
abandon spinners.
Common mistake: sending raw error messages and URLs, which routinely contain emails, IDs, tokens and query strings. Mask them on the device, and keep stack traces in a dedicated crash reporter, which you need anyway for native crashes JavaScript never sees. And a 401 on a wrong password is a normal day, not an incident.
Naming events so they stay useful
Event names are an interface: dashboards and queries depend on them, and renaming one breaks every chart that used it.
- Object, then action, in snake_case.
recipe_saved,paywall_opened,onboarding_completed. Sorting alphabetically then groups everything about one object. - One tense, always. Past tense reads naturally for things that happened. Whatever you choose, never
mix
recipe_savedwithsave_recipe. - Name outcomes, not UI.
recipe_savedsurvives a redesign;save_button_clickeddoesn’t, and misses saves made by swiping. - Variations go in properties. If two events differ only in where or how, they’re one event with a property.
| Instead of | Use | Why |
|---|---|---|
Clicked Save Button | recipe_saved | An outcome, in one consistent case |
saveRecipe | recipe_saved | Same case and tense as every other event |
recipe_saved_from_search | recipe_saved + source: 'search' | One event to count, sliced by a property |
onboarding_step_3 | onboarding_step_viewed + step_id, step_index | Survives adding or reordering steps |
paywall_v2_shown | paywall_opened + paywall_id: 'annual_v2' | Compare designs without new events |
purchase | subscription_started | Says what happened, to what |
signup_james@example.com | account_created | Never put personal data in a name |
Properties versus new events
A useful test: would you ever count the two together? Saves from search and saves from the feed both answer “how
many recipes were saved”, so they’re one event with a source property. Saving and deleting a recipe
are never added up, so they’re separate events. Keep property values to a small known set so they group cleanly.
Versioning
Don’t rename events in place. When an event’s meaning changes, mark the new version with a property (a new
flow_id or paywall_id) or introduce a new name and retire the old one in a known
release. The app version on every event tells you which definition applied.
No personal data, anywhere
No emails, phone numbers, names, addresses or user-typed text in names or properties. Identify people by your internal user ID. Free text is the sneakiest leak: a search query looks harmless until someone types a phone number into it.
Keep a tracking plan
A table in your repository is enough: event name, when it fires, properties and allowed values, and the version that added it. Review changes like code. Better still, let the types enforce it, so a typo in an event name is a compile error rather than a new, silent event:
type EventProperties = {
recipe_saved: { recipe_id: string; source: 'search' | 'feed' | 'share' };
recipe_cooked: { recipe_id: string; servings: number };
shopping_list_created: { item_count: number };
};
type Send = (name: string, props: Record<string, string | number | boolean | null>) => void;
export function createTracker(send: Send) {
return <K extends keyof EventProperties>(name: K, props: EventProperties[K]): void => {
send(name, props);
};
}How Riveora maps to this plan
Riveora is analytics for React Native and Expo apps. Much of the plan is recorded by the SDK with no code; the rest is one line each. Every event name and property is listed in the event catalog.
Recorded automatically
| In this plan | Riveora event | Notes |
|---|---|---|
app_first_opened | app_first_opened | Once per install: sent when the SDK finds no saved Riveora ID. If the first launch happens before consent, it’s recorded once consent is granted. Needs the storage adapter, or every launch looks new. |
session_started | app_session_started | On launch, after more than 2 minutes away, or when a different person signs in. Carries start_reason, away_ms and device details. The 2 minutes can be changed; see Visits. |
app_backgrounded | app_backgrounded, app_foregrounded | Leaving the app, and coming back within the same visit (with away_ms). System pop-ups don’t count as leaving. |
screen_viewed | screen_viewed, screen_visibility_ended | With ExpoRouterTracker or ReactNavigationTracker. Screen names are route templates (/product/:id), and the end event carries how long the screen was visible. |
| Taps (instead of hand-tracking buttons) | element_tapped, rage_tap | See Automatic capture. |
error_occurred | app_error | Unhandled JavaScript errors, with error_type, is_fatal and a masked message of up to 200 characters. No stack traces. |
request_failed | request_failed, slow_request | Status 400 or above or no response; and successful requests that took 3 seconds or more. Paths are masked and query strings never recorded. |
Every event also carries the platform, the screen on display and your app version, as long as you pass the
appVersion prop to RiveoraProvider; the SDK doesn’t detect it on its own. There’s no
separate session-end event: a visit ends when the person is away longer than the timeout.
One line each
Onboarding, paywalls, value actions and identity need your code, because only your app knows what they mean:
import { Riveora } from '@riveora/react-native';
// Onboarding: onboarding_started, onboarding_step_viewed, onboarding_completed
Riveora.onboarding.start('new_user_v1');
Riveora.onboarding.stepViewed('new_user_v1', 'pick_goal', 0); // step index starts at 0
Riveora.onboarding.complete('new_user_v1');
// Paywalls: paywall_opened and paywall_closed, with source and duration
const visitId = Riveora.paywall.open({ paywallId: 'pro_yearly', source: 'settings', trigger: 'locked_feature' });
Riveora.paywall.close(visitId, { reason: 'purchased' });
// Your value actions
Riveora.track('recipe_saved', { recipe_id: 'r_42', source: 'search' });
// After sign-in (your internal ID, never an email), and on sign-out
await Riveora.identify(user.id);
await Riveora.reset();The Onboarding page in the dashboard then shows how many people start and
finish, and how many reached each step. For paywalls, TrackedPaywall records the open and close of a
modal paywall for you, and trackRevenueCatPaywall names the paywall after the RevenueCat offering it
showed; see Paywalls. A close with reason: 'purchased'
counts as a sale, so don’t send it for a free trial that hasn’t been charged.
For subscriptions, connect RevenueCat on the dashboard’s Revenue page: Riveora then stores
purchases, renewals, cancellations and refunds from RevenueCat’s webhook, keeping sandbox purchases out of revenue.
Call Purchases.logIn(user.id) and Riveora.identify(user.id) with the same ID so each
purchase joins the right journey. Free trials count once they’re charged. See
Revenue and drop-offs and Identifying users.
Bottom sheets and in-screen tabs that don’t change the route can be marked with
Riveora.screen('ProductSheet'). To keep event names matching your plan, pass
Riveora.track as the send function of the typed helper above, and avoid names the SDK
already uses.
What happens to your properties
Riveora cleans properties on the device before an event is queued, and the server applies the same filter again before storing it. The rules, as documented in Custom events:
- Values can be text (up to 500 characters; longer text is shortened), numbers,
true/falseornull. Dates are sent as text. Lists, objects and functions are dropped. - Event names are shortened to 80 characters. A property whose key is longer than 80 characters is dropped.
- An event keeps at most 50 properties, and an event over 40 KB is dropped.
- Keys that name a secret or contact detail are dropped:
password,token,secret,auth,email,phone,card,address,ibanand similar anywhere in the key; and whole words such aspin,otp,api_key,first_nameorusername. Keys likename,plan_name,country_codeandpromo_codeare kept. - Values that contain an email address or a token (a JWT, “Bearer …”, an API key), or that are a formatted phone number, a card number or an IBAN, are dropped.
These filters catch common mistakes, not everything. Keep personal data out of event names and properties to begin with, and see Privacy & consent for what the SDK never collects and how consent works.
To check the plan end to end, run the app, go through onboarding and open the paywall, then watch the events arrive in Live events. The SDK sends them every 10 seconds or every 20 events, so they show up within seconds.