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.
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-modulesDrop 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.
EXPO_PUBLIC_POSTHOG_KEY=phc_...
EXPO_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.comIn Sentry, create a project (platform: React Native), open Settings → Projects → your project → Client Keys (DSN), and copy the DSN:
EXPO_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0All of these are public by design and safe in the client. Restart with a clean cache so Metro picks them up:
bun run start -cYou 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:
| Device | Before an answer | Sheet |
|---|---|---|
Region in the EU/EEA, UK, Switzerland, Canada or Brazil, a Europe/* time zone, or no region | off | shown once, after onboarding |
| Anywhere else | on | never; Settings → Privacy has the switches |
privacy.askEverywhere: true | off | shown 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:
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:
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.
5. Prove nothing is sent before consent
Open your live views side by side: PostHog's Activity → Live events, and Sentry's Issues.
- On the sheet, tap Only necessary (or, if you already answered, turn off Analytics and Crash reports in Settings → Privacy).
- Tap Send test event and Send test error a few times.
- 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:
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:
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:
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_ORGandSENTRY_PROJECT: the slugs from your Sentry project URL.SENTRY_AUTH_TOKEN: Settings → Auth Tokens, with the scopesproject:releasesandorg: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 sensitiveEAS 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 distYou 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 doctorbun run typecheckEvery 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
.envand restart withbun run start -- -c. - The sheet never shows - the device's region isn't in the ask group and
askEverywhereis off, or you already answered on this install. SetaskEverywhere: trueand reinstall. - The first screen view of each launch is missing - with
storage=async-storagethe consent record loads asynchronously, and events fired before it loads are dropped rather than sent without a known answer.kv-storeand MMKV load it synchronously. - Stack traces are minified - the three
SENTRY_*keys are missing from the EAS environment, or you didn't runsentry-expo-upload-sourcemapsafter 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.
Add push notifications
Set up APNs and FCM through EAS, get an Expo push token on a real phone, send yourself a test push, and open a screen from the tap.
Deploy your API routes
Put Expo Router API routes on EAS Hosting, give them server keys, smoke-test /api/health, and point every build profile at the deployed URL.