Analytics basics

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
  1. The plan at a glance
  2. Installs, sessions and the app lifecycle
  3. First open
  4. Session start and end
  5. Onboarding and screen views
  6. Onboarding started, step viewed, completed
  7. Screen viewed
  8. Paywalls and subscriptions
  9. Paywall opened and closed
  10. Subscription started
  11. Value actions and errors
  12. The 2–4 actions that represent value
  13. Errors, crashes and failed requests
  14. Naming events so they stay useful
  15. Properties versus new events
  16. Versioning
  17. No personal data, anywhere
  18. Keep a tracking plan
  19. How Riveora maps to this plan
  20. Recorded automatically
  21. One line each
  22. 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.

EventFire it whenKey properties
app_first_openedThe first launch on an install, onceplatform, app_version
session_startedLaunch, or coming back after a timeoutstart_reason, away_ms, app_version, os_version
app_backgroundedThe app goes to the backgroundnone needed
screen_viewedA different screen becomes visiblescreen (a route template), previous_screen
onboarding_startedThe first onboarding step is shownflow_id
onboarding_step_viewedEach step is shownflow_id, step_id, step_index
onboarding_completedThe last step is doneflow_id
paywall_openedThe paywall is actually on screenpaywall_id, source, trigger
paywall_closedThe paywall goes awaypaywall_id, reason, duration_ms
subscription_startedThe store confirms the purchase (server side)product_id, period_type, store
2–4 value actions, e.g. recipe_savedThe action succeedssource, a category or type
error_occurredAn unhandled error reaches your handlererror_type, message (scrubbed), is_fatal
request_failedA request errors or gets no responsemethod, 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:

first-open.tsts
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:

useSessionTracking.tsts
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_saved with save_recipe.
  • Name outcomes, not UI. recipe_saved survives a redesign; save_button_clicked doesn’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 ofUseWhy
Clicked Save Buttonrecipe_savedAn outcome, in one consistent case
saveReciperecipe_savedSame case and tense as every other event
recipe_saved_from_searchrecipe_saved + source: 'search'One event to count, sliced by a property
onboarding_step_3onboarding_step_viewed + step_id, step_indexSurvives adding or reordering steps
paywall_v2_shownpaywall_opened + paywall_id: 'annual_v2'Compare designs without new events
purchasesubscription_startedSays what happened, to what
signup_james@example.comaccount_createdNever 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:

analytics.tsts
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 planRiveora eventNotes
app_first_openedapp_first_openedOnce 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_startedapp_session_startedOn 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_backgroundedapp_backgrounded, app_foregroundedLeaving the app, and coming back within the same visit (with away_ms). System pop-ups don’t count as leaving.
screen_viewedscreen_viewed, screen_visibility_endedWith 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_tapSee Automatic capture.
error_occurredapp_errorUnhandled JavaScript errors, with error_type, is_fatal and a masked message of up to 200 characters. No stack traces.
request_failedrequest_failed, slow_requestStatus 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:

ts
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/false or null. 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, iban and similar anywhere in the key; and whole words such as pin, otp, api_key, first_name or username. Keys like name, plan_name, country_code and promo_code are 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.
A safety net, not a policy

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.

All articles
Essential Mobile App Analytics Events · Riveora