onebox

Guide 27 of 30

For your agent: .md · all guides

On this page
  1. Why
  2. What it costs
  3. Decide first: the region
  4. Steps
  5. Paperwork before release
  6. After launch
  7. Where the values go
  8. Check it works
  9. Common errors

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

What it costs

FreeAfter the free amount
PostHog Cloud, product analytics1,000,000 events a month, no credit cardPay 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
Projects1 project on the free plan6 projects on pay-as-you-go
SeatsYour 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.

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.

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

  1. 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.
  2. 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.
  3. 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:

VariableValue
EXPO_PUBLIC_POSTHOG_KEYthe project API key, phc_...
EXPO_PUBLIC_POSTHOG_HOSTonly 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).

EventFires when
signup_completedThe API created a new account.
signed_inA 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:

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:

  1. 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).
  2. A toggle in Settings. “Share usage stats”, reading getAnalyticsConsent(). Turning it off calls setAnalyticsConsent(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.

  1. Install the native plugin and make a new build (not an OTA update):

    npx expo install @posthog/react-native-plugin

    Replay needs a development build. It does not run in Expo Go (https://posthog.com/docs/session-replay/installation/react-native, checked 2026-10-05).

  2. In the project’s settings, turn on Record user sessions.

  3. 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,
    },
  4. 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 typeLinked to the userTrackingPurpose
Usage Data: Product InteractionYesNoAnalytics
Identifiers: User ID (when you call identify())YesNoAnalytics

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

  1. 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.

  2. Read it once a week. Find the biggest drop between two steps. Fix that one. Look again after the next release.

  3. 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/mcp

    PostHog’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

ValueWhere
Project API key (phc_...)the EAS production environment, as EXPO_PUBLIC_POSTHOG_KEY. Not a secret.
Hostthe code defaults to the EU host. EXPO_PUBLIC_POSTHOG_HOST overrides it.
The user’s answerAsyncStorage on the phone, key analytics.consent
Project settings (IP, autocapture, timezone)the PostHog project, set once
What you collectthe 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).

Remove any test events from the funnel before you read it for real.

Common errors

Wrong or out of date? Fix it on GitHub.