Guide 27 of 30
For your agent: .md · all guides
On this page
Product analytics with PostHog
Runs on: your browser (PostHog), then your Mac (the app). Your box does not change.
Crash reports tell you what broke. Product analytics tell you what people do: where new users give up, and who reaches the first useful moment. This guide adds PostHog to an Expo app, with a small set of named events, an opt-in, and the privacy paperwork.
Do this before your first public release, not before. First get the app working end to end in TestFlight. A new app has few users and no question that numbers can answer yet. Every analytics tool also adds one more processor to your privacy policy and one more answer in App Privacy, so add it once you have a question for it. It is optional. The app ships without it.
Why
- You see where new users stop: after sign-up, in onboarding, before the first useful result, at the paywall.
- You decide what to fix from numbers, not from the loudest message in your inbox.
- A funnel is a list of steps in order. PostHog shows how many people reach each step. You will build one funnel (see “After launch”).
- An event is one named thing the app reports, such as
signup_completed.
What it costs
| Free | After the free amount | |
|---|---|---|
| PostHog Cloud, product analytics | 1,000,000 events a month, no credit card | Pay as you go: 0.00005 USD per event for the 1,000,001st to the 2,000,000th, then less |
| PostHog, session replay (optional) | 2,500 mobile recordings a month (the 5,000 on the pricing page are web recordings) | Pay as you go |
| Projects | 1 project on the free plan | 6 projects on pay-as-you-go |
| Seats | Your whole team, no per-seat price |
Checked 2026-10-05 at https://posthog.com/pricing and https://posthog.com/docs/product-analytics/pricing. Replay and projects checked 2026-10-09 at https://posthog.com/docs/session-replay/pricing and https://posthog.com/pricing. The free amounts reset every month. A small app with about ten events stays far below them.
- Do not add a payment method until you need one. The free plan needs no card, so it cannot charge you.
- If you add a card later, set a billing limit for each product first. It is in the billing settings of your PostHog organization, at the bottom of each product’s page. Set it to 0 USD, so only the free amount is used. PostHog’s page does not say that 0 is allowed, so if it refuses, use the lowest amount it accepts. When a limit is reached, PostHog drops the new data for good. That is the point of the limit.
Decide first: the region
PostHog has two separate clouds: US and EU. The EU cloud runs in Frankfurt. You choose at sign-up, and you cannot move later. PostHog moves a project between regions only for its highest paid plans, with an engineer’s help (https://posthog.com/docs/migrate, checked 2026-10-05). So decide now.
- Users in the EU or EEA: use the EU cloud. Sign up at
https://eu.posthog.com. The app sends events to
https://eu.i.posthog.com. - All other users: either cloud works. The US host is
https://us.i.posthog.com. - The key from one cloud does not work on the other cloud’s host.
How to check which one you have: after you sign in, the address in the browser
starts with eu.posthog.com for the EU cloud. Also, the project’s settings
page shows the host the SDK must use. It must say eu.i.posthog.com.
Steps
1. Make the account and the project
- Sign up on the cloud you chose above. Make one organization and one project for the app. The free plan has only 1 project. A second project, for staging, needs the pay-as-you-go plan.
- Open the project’s settings and set these. PostHog moves its labels
around, so look for the page for each name. The project is for a phone
app, so most web features are noise.
- Web autocapture, heatmaps and web vitals: off. They only apply to web pages. The app sends none of them.
- Console log capture: off. Logs can hold personal data.
- IP data capture: discard. The page is Settings, Project, General, “IP data capture configuration”. New projects on the EU cloud start with it off. Check it anyway.
- Timezone: your own, so a day in a chart is your day.
- Copy the project API key. It starts with
phc_. PostHog’s newer pages also call it the project token. It is not a secret: it only lets someone send events to your project, and it ships inside the app.
2. Install the SDK
From the app folder:
npx expo install posthog-react-native expo-file-system expo-application \
expo-device expo-localization @react-native-async-storage/async-storage
The four expo-* packages are the Expo peer dependencies in PostHog’s guide
for React Native (https://posthog.com/docs/libraries/react-native, checked
2026-10-05). AsyncStorage keeps the user’s answer to the opt-in (step 6). If
the app has some of these already, expo install skips them.
These packages have native code, so you need a new native build. Ship it with a release, not as an EAS Update (ship-an-update.md). An OTA update cannot add native code, and it only reaches builds with a matching runtime version.
3. Put the key in the EAS environment
The app reads two values. Both are optional:
| Variable | Value |
|---|---|
EXPO_PUBLIC_POSTHOG_KEY | the project API key, phc_... |
EXPO_PUBLIC_POSTHOG_HOST | only to override the default, https://eu.i.posthog.com |
Run the command in the app folder, the one with eas.json, not in the
repository root:
cd apps/mobile
eas env:set production --name EXPO_PUBLIC_POSTHOG_KEY --value phc_xxxxxxxx \
--visibility plaintext --non-interactive
eas env:list --environment production # check that it is there
Older versions of eas-cli call the same command eas env:create.
Why here and not in the env block of eas.json (expo-app.md,
step 6)? Both work for a build. But eas update does not read that block, so a
key kept only there is missing from an OTA bundle. ship-ios:eas-update fills
that gap. One place is simpler. Use plaintext, because a local build cannot
read a secret variable (expo-eas.md).
Set it for production only. Then development and preview builds send
nothing, and your own testing stays out of the numbers. Remember that
EXPO_PUBLIC_* values are put into the bundle when the build is made
(expo-app.md, step 4).
4. A small wrapper
Do not call PostHog from every screen. Put it behind one file. The file has four jobs: do nothing without a key, send only the events you named, send nothing before the user said yes, and never throw.
// src/analytics.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import PostHog from "posthog-react-native";
// The project API key is public by design. Without it, every call below does nothing.
const KEY = process.env.EXPO_PUBLIC_POSTHOG_KEY?.trim();
const HOST = process.env.EXPO_PUBLIC_POSTHOG_HOST?.trim() || "https://eu.i.posthog.com";
const CONSENT_KEY = "analytics.consent"; // "granted", "denied", or missing: not asked yet
// Every event the app sends. A typo does not compile. Properties are ids and
// fixed words only: never an email, a name, a file path or text the user typed.
type Events = {
signup_completed: undefined;
signed_in: undefined;
onboarding_step_viewed: { step: string };
onboarding_skipped: { step: string };
core_action_started: { kind: string };
core_action_completed: { kind: string };
paywall_shown: { trigger: string };
purchase_completed: { product: string };
share_tapped: { kind: string };
};
let client: PostHog | null = null; // exists only while the user has said yes
let userId: string | null = null;
function start() {
if (!KEY || client) return;
client = new PostHog(KEY, {
host: HOST,
personProfiles: "identified_only", // no profile for people you never identify
captureAppLifecycleEvents: false, // fewer events; turn on later for retention
});
if (userId) client.identify(userId);
}
async function stop() {
const old = client;
client = null;
if (!old) return;
old.reset(); // wipe the id kept on the phone
await old.shutdown();
}
// Call once at launch. Nothing starts unless the user said yes before.
export async function initAnalytics() {
try {
if ((await AsyncStorage.getItem(CONSENT_KEY)) === "granted") start();
} catch {}
}
export async function getAnalyticsConsent(): Promise<"granted" | "denied" | "unknown"> {
try {
const v = await AsyncStorage.getItem(CONSENT_KEY);
return v === "granted" || v === "denied" ? v : "unknown";
} catch {
return "unknown";
}
}
export async function setAnalyticsConsent(granted: boolean) {
try {
await AsyncStorage.setItem(CONSENT_KEY, granted ? "granted" : "denied");
} catch {}
if (granted) start();
else await stop();
}
export function track<E extends keyof Events>(
event: E,
...props: Events[E] extends undefined ? [] : [Events[E]]
) {
try {
client?.capture(event, props[0] as Record<string, string> | undefined);
} catch {}
}
export function screen(name: string) {
try {
client?.screen(name);
} catch {}
}
// After sign-in: your own user id. Never an email or a name.
export function identify(id: string) {
userId = id;
try {
client?.identify(id);
} catch {}
}
// On sign-out and on account deletion.
export function resetAnalytics() {
userId = null;
try {
client?.reset();
} catch {}
}
This is the whole SDK surface the app uses. There is no PostHogProvider, so
PostHog’s touch autocapture never runs. It is off unless you add the
provider with captureTouches on. Leave it off: taps give noise and can
record what a user touched.
Wire it in the root layout (src/app/_layout.tsx). The tabs and screens of
Expo Router do not report themselves, so send the route yourself. Use the
route pattern (recipe/[id]), not the URL: a URL can hold a name or an id of
something private.
import { useEffect } from "react";
import { useSegments } from "expo-router";
import { initAnalytics, screen } from "../analytics"; // src/analytics.ts
// inside the root layout component
useEffect(() => { void initAnalytics(); }, []);
const route = useSegments().join("/");
useEffect(() => { if (route) screen(route); }, [route]);
After sign-in call identify(user.id). On sign-out and on account deletion
call resetAnalytics(). Use your own internal user id, never the email,
the name or the Apple relay address.
Local development and tests keep working. Without the key, start() does
nothing and no PostHog client exists.
5. The events
Name the events for your activation funnel: the steps a new user takes
from the first launch to the first useful result, and on to paying. Start
with these nine. Rename core_action after the one thing your app is for
(recipe_imported, workout_logged, photo_scanned).
| Event | Fires when |
|---|---|
signup_completed | The API created a new account. |
signed_in | A returning user signed in. |
onboarding_step_viewed {step} | Each onboarding screen shows. step is a fixed word such as welcome. |
onboarding_skipped {step} | The user taps Skip, with the step they were on. |
core_action_started {kind} | The user starts the main thing. |
core_action_completed {kind} | The main thing worked, and the user saw the result. This is the first value moment. |
paywall_shown {trigger} | The paywall opens. trigger says why: limit, feature, settings. |
purchase_completed {product} | RevenueCat’s purchase call succeeded (revenuecat.md). product is the store product id. |
share_tapped {kind} | The user shares something out of the app. |
Rules:
- Fire on success, where the code knows it. Not on the tap that starts it.
core_action_completedgoes after the result is on screen. signup_completedneeds a fact from the API. The sign-in response must say whether this call created the account (for exampleisNewUser). The app cannot guess it.- No personal data in a property. No email, no name, no file path or photo URI, no text the user typed, no search query. Ids of your own things and fixed words are fine.
- Few events. Each event is one more thing to keep true and to put in the privacy policy. Add one only when a question needs it.
- Touches are not events. No screen-wide tap capture.
6. Ask the user first
This is not legal advice. It is the cautious setup.
Apple’s Guideline 5.1.1(ii) says an app that collects user or usage data must get the user’s consent, even if the data is anonymous. It also says the user must have an easy way to withdraw, and that paid features must not depend on the answer. The EU’s GDPR and the ePrivacy rules point the same way. So one rule is easiest, for every user:
Nothing is sent until the user says yes. That is what the wrapper does: no PostHog client exists before a yes, so no event leaves the phone and no id is written to it.
Build two things:
- A one-time ask. A card on the first home screen after sign-up, not a
blocking dialog. Do not show it on the very first screen. Do not show it
on a paywall or after an error. Say what you collect, in plain words.
Two buttons of equal weight: “Yes, share” and “No thanks”. Never ask
twice. Both answers go through
setAnalyticsConsent(true | false). - A toggle in Settings. “Share usage stats”, reading
getAnalyticsConsent(). Turning it off callssetAnalyticsConsent(false): the wrapper resets PostHog’s stored id and shuts the client down.
Suggested wording for the card:
Help improve the app? We count which screens and features people use, to find what to fix. If you say yes, this is linked to your account. You can turn it off in Settings at any time.
Do not call it “anonymous” when you call identify(). After a yes the events
are linked to the account. Say that.
What this costs you, honestly: only people who said yes are in the numbers, and they are fewer than all users. Use the funnel to see where people stop, not to count users exactly.
A softer option exists: before the answer, send events with no id, kept in
memory only (persistence: "memory" in the PostHog options), and no identify.
You see more users. But Apple’s rule above covers anonymous data too, so it
carries risk in review and in the EU. Take it only after you have checked
it with someone who can give legal advice. This guide does not do it.
Deleting your own account in the app does not delete what PostHog already holds for that user id. On a deletion request, also delete that person in PostHog, and say in the privacy policy how you do it.
7. Session replay (optional)
Session replay records the screen of a session, so you can watch where someone got stuck. It sees what the user sees, so use it only after the opt-in, and only if you need it. Most apps do not need it at first.
-
Install the native plugin and make a new build (not an OTA update):
npx expo install @posthog/react-native-pluginReplay needs a development build. It does not run in Expo Go (https://posthog.com/docs/session-replay/installation/react-native, checked 2026-10-05).
-
In the project’s settings, turn on Record user sessions.
-
Add this to the options in
start()in the wrapper. The client exists only after a yes, so replay can only run after a yes too:enableSessionReplay: true, sessionReplayConfig: { maskAllImages: true, // the user's photos and your artwork maskAllTextInputs: true, maskAllSandboxedViews: true, captureLog: false, // logs can hold personal data captureNetworkTelemetry: false, // URLs can hold personal data captureTouches: false, }, -
Say it in the consent card (“and screen recordings with photos and text hidden”), and in the privacy policy.
Watch two of your own recordings before release. Look for anything readable that should be hidden.
Paperwork before release
The privacy policy, the App Privacy answers and the app must say the same thing. A mismatch is a common rejection (submit-for-review.md).
The privacy policy. Add PostHog as a processor (privacy-and-support-pages.md). For example:
If you agree, the app sends usage events (for example “import started”) and the screens you visit to PostHog, our product analytics service. They are linked to your account id, never to your name or email. [If you also agree to screen recordings: The recordings hide photos and all text.] They are stored in the EU (Frankfurt) for [how long]. You can turn this off in Settings. Then we stop sending, and we delete the data we hold about you on request.
Fill in “how long” from the data retention shown in the PostHog project settings. Write the number you see there, not a number you like.
App Privacy in App Store Connect (submit-for-review.md, step 1):
| Data type | Linked to the user | Tracking | Purpose |
|---|---|---|---|
| Usage Data: Product Interaction | Yes | No | Analytics |
Identifiers: User ID (when you call identify()) | Yes | No | Analytics |
When in doubt, answer “Linked”. It is the safe answer. If you use session replay, also declare Other Usage Data. Apple’s definitions: https://developer.apple.com/app-store/app-privacy-details/ (checked 2026-10-05).
The privacy manifest. The App Privacy answers and the manifest must
agree. Add this under expo.ios.privacyManifests.NSPrivacyCollectedDataTypes
in app.json, next to any entries you already have
(crash-reports.md, the Sentry step):
{ "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeProductInteraction",
"NSPrivacyCollectedDataTypeLinked": true, "NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAnalytics"] },
{ "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeUserID",
"NSPrivacyCollectedDataTypeLinked": true, "NSPrivacyCollectedDataTypeTracking": false,
"NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAnalytics"] }
ship-ios:app-store-ready checks the privacy manifest.
No tracking prompt. Apple says “tracking” is linking your app’s data with third-party data for advertising, or sharing it with a data broker. Product analytics for your own use is neither. So you do not show the App Tracking Transparency prompt, and you answer “No” to tracking. Do not connect PostHog to an ad network, and do not send its data to a data broker. That would change the answer.
After launch
-
Build one funnel. In PostHog, make a funnel insight with the events in order:
signup_completed,onboarding_step_viewed,core_action_started,core_action_completed,paywall_shown,purchase_completed. Add it to a dashboard. One funnel is enough for months. -
Read it once a week. Find the biggest drop between two steps. Fix that one. Look again after the next release.
-
Optional: let the agent read it. PostHog has an MCP server that can run queries and read insights. The address is
https://mcp.posthog.com/mcp. It signs in with the account you log in with, and PostHog says it picks the US or the EU data for you (https://posthog.com/docs/model-context-protocol, checked 2026-10-05). In Claude Code:claude mcp add --transport http posthog https://mcp.posthog.com/mcpPostHog’s own installer does the same for several agents:
npx @posthog/wizard mcp add. The agent then sees your usage data. Add it only on your own machine.
Where the values go
| Value | Where |
|---|---|
Project API key (phc_...) | the EAS production environment, as EXPO_PUBLIC_POSTHOG_KEY. Not a secret. |
| Host | the code defaults to the EU host. EXPO_PUBLIC_POSTHOG_HOST overrides it. |
| The user’s answer | AsyncStorage on the phone, key analytics.consent |
| Project settings (IP, autocapture, timezone) | the PostHog project, set once |
| What you collect | the privacy policy, App Privacy, privacyManifests in app.json |
Check it works
Use a build that has the key (a production build, or a preview build that has the variable).
- Without the key: run a development build. The app works and sends nothing.
- Before the answer: sign in and use the app. PostHog’s page for live events shows nothing from your phone.
- After “Yes”: the events appear, with your internal user id. Check that
the project lives on the cloud you chose: the address starts with
eu.posthog.comfor the EU. - No personal data: open a few events. No email, no name, no file path. The IP address is not stored.
- After “No”, or the Settings toggle off: no new events. The stored id is gone, so a new “Yes” starts with a new id.
- Replay, if you use it: photos and text are hidden in the recording.
- Sign-out and account deletion call
resetAnalytics().
Remove any test events from the funnel before you read it for real.
Common errors
- No events at all. The key is missing from that build. Run
eas env:list --environment production.EXPO_PUBLIC_*values are fixed when the build is made, so set the key first and build again. Or the user has not said yes. - Events never arrive, and the key looks right. The key and the host are
from different clouds. An EU key needs
https://eu.i.posthog.com. Cannot find native module 'ExpoApplication'(orExpoDevice,ExpoLocalization). A peer package was added without a new build, or the change shipped as an EAS Update. Make a new build.posthog-react-nativecannot resolve a package. A peer dependency is missing. Run theexpo installline in step 2 again.- Replay shows nothing. Record user sessions is off in the project, or you are in Expo Go. Use a development build.
- Replay shows a photo or typed text. A masking option is off, or the
view is custom. Check
sessionReplayConfig, and mask that view. - The event count grows fast. Lifecycle events or autocapture is on.
Keep
captureAppLifecycleEvents: false, and do not addPostHogProvider. - Two people for one user.
identify()ran with a different id, such as the email on one screen and the user id on another. Use the user id everywhere. - App Review asks why the app collects usage data. The App Privacy answers or the policy do not name PostHog, or the app sends data before the user said yes. Fix both to match.