ReadyNative
AnalyticsPro
Crash reportingPro
Privacy consent

Add analytics and crash reporting

Wire PostHog or Amplitude and Sentry into an Expo app behind a consent sheet, track your own events, and get readable stack traces.

Pro

Hey - by the end of this page your events show up in your analytics dashboard, a test error shows up in Sentry with a readable stack trace, and you've watched both stay silent until the user says yes.

That last part is the consent module's job. setup adds it whenever you pick an analytics or crash option: PostHog, Amplitude and Sentry create their clients only once the matching category is consented to, so nothing - no event, no flags request, no crash report - leaves the device before that.

Before you start

  • Tier: Pro. Analytics and crash modules aren't in Free or Starter.
  • Time: about 30 minutes, plus a build if you want native crashes and source maps checked.
  • Runs in: Expo Go for events and JS errors. Native crashes need a dev build - see Expo Go or a dev build.
  • Accounts: PostHog and Sentry - both have free tiers.
  • Previous tutorial: Add push notifications.

Picked these at setup? Skip step 1. Step 1 needs modules/, so it works only in a tree set up with --keep-modules; a finalized tree without them starts from a fresh clone - see Can I change a module after setup?.

1. Add the modules

bun run setup --analytics posthog --crash sentry --yes --keep-modules

Drop either flag if you want only one of them. consent comes along on its own. If you install with bun on a tree that already existed, run bun pm trust @sentry/cli once so its source-map uploader can download.

You should see: src/lib/analytics.ts, src/lib/crash.ts, src/lib/consent.ts and src/components/consent-sheet.tsx in your tree, and a Privacy card in Settings with Analytics and Crash reports switches.

2. Paste the keys

Copy .env.example to .env if you haven't yet (cp .env.example .env).

In PostHog, open Settings → Project and copy the Project API key (phc_…). EU project? Set the host too; it defaults to the US one.

.env
EXPO_PUBLIC_POSTHOG_KEY=phc_...
EXPO_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com

In Sentry, create a project (platform: React Native), open Settings → Projects → your project → Client Keys (DSN), and copy the DSN:

.env
EXPO_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0

All of these are public by design and safe in the client. Restart with a clean cache so Metro picks them up:

bun run start -c

You should see: bun run doctor green for the analytics and crash rows.

3. Decide who gets asked

The consent sheet asks users where the law requires opt-in and stays out of the way elsewhere:

DeviceBefore an answerSheet
Region in the EU/EEA, UK, Switzerland, Canada or Brazil, a Europe/* time zone, or no regionoffshown once, after onboarding
Anywhere elseonnever; Settings → Privacy has the switches
privacy.askEverywhere: trueoffshown to everyone

The region comes from the device's locale settings and time zone - no network lookup. To ask every user, whatever the region, flip one setting in readynative.config.ts:

readynative.config.ts
  privacy: { askEverywhere: true },

Turn it on now even if you'll turn it off later - otherwise a simulator set to the US never shows you the sheet.

You should see: on a fresh install (delete the dev build, or Expo Go's data, and open the app again), the Your privacy sheet after onboarding, with Accept all, Save choices and Only necessary.

4. Add a test card

A temporary card with two buttons lets you trigger both SDKs by hand. Both calls work whatever options you picked:

src/components/telemetry-test.tsx
import { Box, Button, Card, Text } from "@/components/ui";
import { analytics } from "@/lib/analytics";
import { crash } from "@/lib/crash";

export function TelemetryTest() {
  return (
    <Card>
      <Box gap={3}>
        <Text variant="heading">Telemetry test</Text>
        <Button onPress={() => analytics.track("telemetry_test", { source: "home" })}>
          Send test event
        </Button>
        <Button
          variant="outline"
          onPress={() => crash.capture(new Error("Telemetry test error"), { source: "home" })}
        >
          Send test error
        </Button>
      </Box>
    </Card>
  );
}

Render <TelemetryTest /> inside the <Box> of src/screens/home/home-screen.tsx for now.

You should see: the card on Home.

Open your live views side by side: PostHog's Activity → Live events, and Sentry's Issues.

  1. On the sheet, tap Only necessary (or, if you already answered, turn off Analytics and Crash reports in Settings → Privacy).
  2. Tap Send test event and Send test error a few times.
  3. Wait a minute.

You should see: nothing in either dashboard - no event, no app-open, no issue. The SDKs aren't running.

Now turn both switches on in Settings → Privacy (the change takes effect immediately) and tap the two buttons again.

You should see: telemetry_test in the live view within seconds, and Error: Telemetry test error in Sentry within about a minute, tagged with the dev environment. Turn a switch off again and the matching SDK stops.

Before release, set askEverywhere back to what you want and delete the test card.

6. Track what matters

Screen views are already tracked: the root layout calls analytics.screen(pathname) on every route change. Add your own events where something meaningful happens - for example, in the Notes screen from Add your first feature. In src/screens/notes/notes-screen.tsx, wrap the store's add and pass the wrapper to the form:

src/screens/notes/notes-screen.tsx
import { analytics } from "@/lib/analytics";

// ...inside NotesScreen(), after `const { drafts, add } = useDrafts();`:
  const save = (title: string, body: string) => {
    add(title, body);
    analytics.track("note_saved", { length: body.length });
  };

// ...and in the <List> props:
        header={<NoteForm onSave={save} />}

Keep properties free of personal data - counts, ids and choices, not emails or note text.

Then tie events and errors to the signed-in user, so both dashboards can count affected people and account deletion can erase the right person on your server:

src/hooks/use-identify.ts
import { useEffect } from "react";

import { analytics } from "@/lib/analytics";
import { auth } from "@/lib/auth";

/** Tags analytics with the signed-in user's id (never the email). */
export function useIdentify(): void {
  const session = auth.useSession();
  const userId = session.status === "authenticated" ? session.user.id : null;

  useEffect(() => {
    if (userId) analytics.identify(userId);
  }, [userId]);
}

Call it once, in RootStack in src/app/_layout.tsx, next to the redirect hooks:

src/app/_layout.tsx
import { useIdentify } from "@/hooks/use-identify";

// ...inside RootStack(), after useSignOutCleanup():
useIdentify();

Signing out already resets the identity (wipeLocalData() calls analytics.reset()).

You should see: after signing in, your next events carry your user id in the dashboard.

7. Get readable stack traces

Sentry needs the source maps of each build and each update, or stack traces stay minified. This step uses EAS builds and eas update; if you haven't made either yet, come back to it after Ship to TestFlight and Your first update.

Three build-time keys make the upload work - never with an EXPO_PUBLIC_ prefix:

  • SENTRY_ORG and SENTRY_PROJECT: the slugs from your Sentry project URL.
  • SENTRY_AUTH_TOKEN: Settings → Auth Tokens, with the scopes project:releases and org:read.

Put them in every EAS environment you build from, as sensitive:

bunx eas-cli env:set --name SENTRY_ORG --value your-org --environment production --visibility sensitive
bunx eas-cli env:set --name SENTRY_PROJECT --value your-project --environment production --visibility sensitive
bunx eas-cli env:set --name SENTRY_AUTH_TOKEN --value sntrys_... --environment production --visibility sensitive

EAS builds then upload their source maps on their own, through the Sentry config plugin. EAS updates don't - after each eas update, upload the bundle it exported to dist/, with the same three keys set in your shell:

bunx eas-cli update --channel production --environment production --message "Fix totals"
bunx sentry-expo-upload-sourcemaps dist

You should see: in Sentry, an error from a release build or an update pointing at your TypeScript file and line, not at index.bundle.

Check it

bun run doctor
bun run typecheck

Every analytics, crash and consent row should be green, and typecheck should pass.

If it doesn't work

  • Nothing arrives even after consent - the key isn't set, or Metro still has the old env. Check .env and restart with bun run start -- -c.
  • The sheet never shows - the device's region isn't in the ask group and askEverywhere is off, or you already answered on this install. Set askEverywhere: true and reinstall.
  • The first screen view of each launch is missing - with storage=async-storage the consent record loads asynchronously, and events fired before it loads are dropped rather than sent without a known answer. kv-store and MMKV load it synchronously.
  • Stack traces are minified - the three SENTRY_* keys are missing from the EAS environment, or you didn't run sentry-expo-upload-sourcemaps after the update.
  • Works locally, silent in a build - the EXPO_PUBLIC_* keys aren't on EAS. See A key works locally but not in an EAS build.

More in Troubleshooting.

Congrats 🎉

You can see how people use the app and where it breaks - with their consent, tied to their account, and with stack traces you can read. To take a module out again, see PostHog → Remove it and Sentry → Remove it.

Next, give the app its own backend in the cloud: Deploy your API routes.

On this page

Get ReadyNative