From 0045eab6cfa61c2c66b8741e56669916ee79083c Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 12 Jun 2026 12:55:18 +0100 Subject: [PATCH] feat(mobile): enable session replay and align analytics with desktop --- apps/mobile/.env.example | 6 ++++ apps/mobile/package.json | 1 + apps/mobile/src/app/_layout.tsx | 4 +-- apps/mobile/src/app/auth.tsx | 50 +++++++++++++++++++++++++++-- apps/mobile/src/lib/analytics.ts | 27 ++++++++++++++++ apps/mobile/src/lib/posthog.test.ts | 37 +++++++++++++-------- apps/mobile/src/lib/posthog.ts | 33 ++++++++++++------- pnpm-lock.yaml | 19 +++++++++-- 8 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/.env.example diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000000..3a0e65e004 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,6 @@ +# PostHog analytics destination. Both desktop and mobile report to the shared +# posthog.com project (team 2) — events are segmented by the `team` and +# `$app_namespace` properties. Production builds get these from EAS env vars +# (EXPO_PUBLIC_* values are inlined at build time). +EXPO_PUBLIC_POSTHOG_API_KEY=sTMFPsFhdP1Ssg +EXPO_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com diff --git a/apps/mobile/package.json b/apps/mobile/package.json index daa6872662..8dc4cdeff4 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -63,6 +63,7 @@ "nativewind": "^4.2.1", "phosphor-react-native": "^3.0.2", "posthog-react-native": "^4.18.0", + "posthog-react-native-session-replay": "^1.6.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-keyboard-controller": "1.18.5", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 344a4fe189..c3e2e13097 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -22,7 +22,7 @@ import { POSTHOG_API_KEY, POSTHOG_OPTIONS, useIdentifyUser, - useRegisterAppVersion, + useRegisterSuperProperties, useScreenTracking, } from "@/lib/posthog"; import { queryClient } from "@/lib/queryClient"; @@ -39,7 +39,7 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { useScreenTracking(); useIdentifyUser(); - useRegisterAppVersion(); + useRegisterSuperProperties(); useEffect(() => { initializeAuth(); diff --git a/apps/mobile/src/app/auth.tsx b/apps/mobile/src/app/auth.tsx index 92f2a598f0..7b230cd3e1 100644 --- a/apps/mobile/src/app/auth.tsx +++ b/apps/mobile/src/app/auth.tsx @@ -11,6 +11,12 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import { QrScanModal, type QrScanResult } from "@/components/QrScanModal"; import { type CloudRegion, useAuthStore } from "@/features/auth"; +import { + ANALYTICS_EVENTS, + type SignInFailureReason, + type SignInMethod, + useAnalytics, +} from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; type RegionOption = { value: CloudRegion; label: string }; @@ -42,6 +48,35 @@ export default function AuthScreen() { const [scannerVisible, setScannerVisible] = useState(false); const { loginWithOAuth, loginWithPersonalApiKey } = useAuthStore(); + const analytics = useAnalytics(); + + const trackSignInStarted = (method: SignInMethod) => { + analytics.track(ANALYTICS_EVENTS.SIGN_IN_STARTED, { + method, + region: selectedRegion, + }); + }; + + const trackSignInCompleted = (method: SignInMethod) => { + analytics.track(ANALYTICS_EVENTS.SIGN_IN_COMPLETED, { + method, + region: selectedRegion, + }); + }; + + const trackSignInFailed = (method: SignInMethod, message: string) => { + const reason: SignInFailureReason = message.includes("cancel") + ? "cancelled" + : message.includes("timed out") + ? "timeout" + : "error"; + analytics.track(ANALYTICS_EVENTS.SIGN_IN_FAILED, { + method, + region: selectedRegion, + reason, + error_message: message, + }); + }; // After successful sign-in, resume the originally-requested deep link if // there was one, otherwise drop into the default tab. Guards against `next` @@ -62,15 +97,19 @@ export default function AuthScreen() { setDevProjectId(String(result.projectId)); setIsLoading(true); setError(null); + trackSignInStarted("qr_scan"); try { await loginWithPersonalApiKey({ token: result.apiKey, projectId: result.projectId, region: selectedRegion, }); + trackSignInCompleted("qr_scan"); navigateAfterLogin(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to sign in"); + const message = err instanceof Error ? err.message : "Failed to sign in"; + trackSignInFailed("qr_scan", message); + setError(message); } finally { setIsLoading(false); } @@ -79,6 +118,7 @@ export default function AuthScreen() { const handleDevSignIn = async () => { setIsLoading(true); setError(null); + trackSignInStarted("dev_api_key"); try { const projectIdNum = Number(devProjectId); if (!Number.isFinite(projectIdNum) || projectIdNum <= 0) { @@ -89,9 +129,12 @@ export default function AuthScreen() { projectId: projectIdNum, region: selectedRegion, }); + trackSignInCompleted("dev_api_key"); navigateAfterLogin(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to sign in"); + const message = err instanceof Error ? err.message : "Failed to sign in"; + trackSignInFailed("dev_api_key", message); + setError(message); } finally { setIsLoading(false); } @@ -100,14 +143,17 @@ export default function AuthScreen() { const handleSignIn = async () => { setIsLoading(true); setError(null); + trackSignInStarted("oauth"); try { await loginWithOAuth(selectedRegion); + trackSignInCompleted("oauth"); // Navigate to tabs on success navigateAfterLogin(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to authenticate"; + trackSignInFailed("oauth", message); if (message.includes("cancelled") || message.includes("cancel")) { setError("Authorization cancelled."); diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 79211af7ff..afaebd5a83 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -12,8 +12,32 @@ export const ANALYTICS_EVENTS = { INBOX_REPORT_CLOSED: "Inbox report closed", INBOX_REPORT_SCROLLED: "Inbox report scrolled", INBOX_REPORT_ACTION: "Inbox report action", + SIGN_IN_STARTED: "Sign in started", + SIGN_IN_COMPLETED: "Sign in completed", + SIGN_IN_FAILED: "Sign in failed", } as const; +export type SignInMethod = "oauth" | "dev_api_key" | "qr_scan"; + +export type SignInFailureReason = "cancelled" | "timeout" | "error"; + +export interface SignInStartedProperties { + method: SignInMethod; + region: string; +} + +export interface SignInCompletedProperties { + method: SignInMethod; + region: string; +} + +export interface SignInFailedProperties { + method: SignInMethod; + region: string; + reason: SignInFailureReason; + error_message: string; +} + export type InboxReportOpenMethod = | "click" | "click_cmd" @@ -144,6 +168,9 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties; [ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties; [ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties; + [ANALYTICS_EVENTS.SIGN_IN_STARTED]: SignInStartedProperties; + [ANALYTICS_EVENTS.SIGN_IN_COMPLETED]: SignInCompletedProperties; + [ANALYTICS_EVENTS.SIGN_IN_FAILED]: SignInFailedProperties; }; export interface Analytics { diff --git a/apps/mobile/src/lib/posthog.test.ts b/apps/mobile/src/lib/posthog.test.ts index 01c186b0cb..14239b9175 100644 --- a/apps/mobile/src/lib/posthog.test.ts +++ b/apps/mobile/src/lib/posthog.test.ts @@ -78,39 +78,48 @@ describe("getAppVersion", () => { }); }); -describe("registerAppVersion", () => { - it("registers app_version as a super property on the PostHog client", async () => { +describe("registerPersistentSuperProperties", () => { + it("registers team and app_version as super properties on the PostHog client", async () => { const register = vi.fn(); - const { registerAppVersion } = await import("./posthog"); + const { registerPersistentSuperProperties } = await import("./posthog"); - registerAppVersion({ register }, "1.2.3"); + registerPersistentSuperProperties({ register }, "1.2.3"); expect(register).toHaveBeenCalledTimes(1); - expect(register).toHaveBeenCalledWith({ app_version: "1.2.3" }); + expect(register).toHaveBeenCalledWith({ + team: "posthog-code", + app_version: "1.2.3", + }); }); it("does nothing when the PostHog client is not yet available", async () => { - const { registerAppVersion } = await import("./posthog"); + const { registerPersistentSuperProperties } = await import("./posthog"); - expect(() => registerAppVersion(null, "1.2.3")).not.toThrow(); + expect(() => + registerPersistentSuperProperties(null, "1.2.3"), + ).not.toThrow(); }); - it("does nothing when no app version can be resolved", async () => { + it("still registers team when no app version can be resolved", async () => { const register = vi.fn(); - const { registerAppVersion } = await import("./posthog"); + const { registerPersistentSuperProperties } = await import("./posthog"); - registerAppVersion({ register }, null); + registerPersistentSuperProperties({ register }, null); - expect(register).not.toHaveBeenCalled(); + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith({ team: "posthog-code" }); }); it("resolves the version from getAppVersion when none is provided", async () => { expoApplication.nativeApplicationVersion = "4.5.6"; const register = vi.fn(); - const { registerAppVersion } = await import("./posthog"); + const { registerPersistentSuperProperties } = await import("./posthog"); - registerAppVersion({ register }); + registerPersistentSuperProperties({ register }); - expect(register).toHaveBeenCalledWith({ app_version: "4.5.6" }); + expect(register).toHaveBeenCalledWith({ + team: "posthog-code", + app_version: "4.5.6", + }); }); }); diff --git a/apps/mobile/src/lib/posthog.ts b/apps/mobile/src/lib/posthog.ts index 4813aabebd..81c6643d28 100644 --- a/apps/mobile/src/lib/posthog.ts +++ b/apps/mobile/src/lib/posthog.ts @@ -15,7 +15,9 @@ export const POSTHOG_OPTIONS = { captureAppLifecycleEvents: true, enableSessionReplay: true, sessionReplayConfig: { - maskAllTextInputs: false, + // Recordings land in the shared posthog.com project; the auth flow takes + // credentials, so text inputs must stay masked. + maskAllTextInputs: true, maskAllImages: false, captureLog: true, captureNetworkTelemetry: true, @@ -43,31 +45,36 @@ export function getAppVersion(): string | null { } type PostHogRegisterClient = { - register: (properties: { app_version: string }) => unknown; + register: (properties: Record) => unknown; }; /** - * Register the app version as a PostHog super property so it is attached to - * every event the client emits. No-op if the client is not yet ready or no - * version is available. + * Register the super properties attached to every event the client emits: + * `team` (mirrors the desktop app so mobile and desktop events are segmentable + * in the shared posthog.com project) and `app_version` when one is available. + * No-op if the client is not yet ready. */ -export function registerAppVersion( +export function registerPersistentSuperProperties( client: PostHogRegisterClient | null | undefined, version: string | null = getAppVersion(), ) { - if (!client || version === null) return; - client.register({ app_version: version }); + if (!client) return; + client.register({ + team: "posthog-code", + ...(version !== null ? { app_version: version } : {}), + }); } /** - * Hook variant of `registerAppVersion`. Runs once per client instance so the - * super property is re-applied if the PostHog client is recreated. + * Hook variant of `registerPersistentSuperProperties`. Runs once per client + * instance so the super properties are re-applied if the PostHog client is + * recreated. */ -export function useRegisterAppVersion() { +export function useRegisterSuperProperties() { const posthog = usePostHog(); useEffect(() => { - registerAppVersion(posthog); + registerPersistentSuperProperties(posthog); }, [posthog]); } @@ -120,6 +127,8 @@ export function useIdentifyUser() { // anonymous distinct id on every render before sign-in. if (lastIdentity.current) { posthog.reset(); + // reset() wipes super properties. + registerPersistentSuperProperties(posthog); lastIdentity.current = null; } return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d81c0b41a4..7cc77acba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,7 +571,10 @@ importers: version: 3.0.3(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) posthog-react-native: specifier: ^4.18.0 - version: 4.30.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(@react-navigation/native@7.1.28(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(expo-application@7.0.8(expo@54.0.33))(expo-device@8.0.10(expo@54.0.33))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(expo-localization@17.0.8(expo@54.0.33)(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)) + version: 4.30.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(@react-navigation/native@7.1.28(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(expo-application@7.0.8(expo@54.0.33))(expo-device@8.0.10(expo@54.0.33))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(expo-localization@17.0.8(expo@54.0.33)(react@19.1.0))(posthog-react-native-session-replay@1.6.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)) + posthog-react-native-session-replay: + specifier: ^1.6.0 + version: 1.6.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 @@ -11081,6 +11084,12 @@ packages: resolution: {integrity: sha512-C4ueZUrifTJMDFngybSWQ+GthcqCqPiCcGg5qnjoh+f6ie3+tdhFROqqshjttpQ6Q4DPM40USPTmU/UBYqgsbA==} engines: {node: ^20.20.0 || >=22.22.0} + posthog-react-native-session-replay@1.6.0: + resolution: {integrity: sha512-OCaei77mtgg7JT+TgHSCgpWeKq2XXENUOPNxGbjhXZa/aJpptOW5VsBqjtH4BPzM2c1veS1DK4/Fb/uV4Rb3cg==} + peerDependencies: + react: '*' + react-native: '*' + posthog-react-native@4.30.0: resolution: {integrity: sha512-cZ5f9myFmcjhJCRm/duwj0li2Mta6XqvkfzqIdMEKJcL17lKpwM/wFnMx4Zf8i1q37JiiU0xrdyeLwfBRQcaUw==} peerDependencies: @@ -24610,7 +24619,12 @@ snapshots: dependencies: '@posthog/core': 1.20.0 - posthog-react-native@4.30.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(@react-navigation/native@7.1.28(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(expo-application@7.0.8(expo@54.0.33))(expo-device@8.0.10(expo@54.0.33))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(expo-localization@17.0.8(expo@54.0.33)(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)): + posthog-react-native-session-replay@1.6.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0) + + posthog-react-native@4.30.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(@react-navigation/native@7.1.28(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(expo-application@7.0.8(expo@54.0.33))(expo-device@8.0.10(expo@54.0.33))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)))(expo-localization@17.0.8(expo@54.0.33)(react@19.1.0))(posthog-react-native-session-replay@1.6.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0)): dependencies: '@posthog/core': 1.20.0 react-native-svg: 15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) @@ -24621,6 +24635,7 @@ snapshots: expo-device: 8.0.10(expo@54.0.33) expo-file-system: 19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0)) expo-localization: 17.0.8(expo@54.0.33)(react@19.1.0) + posthog-react-native-session-replay: 1.6.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) postject@1.0.0-alpha.6: