From c8924d4a374b5a82296e23410d25ef38b3765d6d Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 21:40:43 +0100 Subject: [PATCH 1/3] feat(mobile): enable PostHog error tracking and user identification Turn on uncaught-exception and unhandled-rejection autocapture so JS crashes surface in Error Tracking, and identify the signed-in user (uuid as distinct id, email/name/staff props + organization group) so events and session replays are attributed to a person. Resets on logout to keep sessions from bleeding across accounts. --- apps/mobile/src/app/_layout.tsx | 2 ++ apps/mobile/src/lib/posthog.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 64ede2513..2700f3dd2 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -21,6 +21,7 @@ import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { POSTHOG_API_KEY, POSTHOG_OPTIONS, + useIdentifyUser, useScreenTracking, } from "@/lib/posthog"; import { queryClient } from "@/lib/queryClient"; @@ -36,6 +37,7 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const pathname = usePathname(); useScreenTracking(); + useIdentifyUser(); useEffect(() => { initializeAuth(); diff --git a/apps/mobile/src/lib/posthog.ts b/apps/mobile/src/lib/posthog.ts index 11fb74143..a00886041 100644 --- a/apps/mobile/src/lib/posthog.ts +++ b/apps/mobile/src/lib/posthog.ts @@ -1,6 +1,7 @@ import { usePathname, useSegments } from "expo-router"; import { usePostHog } from "posthog-react-native"; import { useEffect, useRef } from "react"; +import { useAuthStore, useUserQuery } from "@/features/auth"; /** * PostHog configuration - used by PostHogProvider in _layout.tsx @@ -16,6 +17,12 @@ export const POSTHOG_OPTIONS = { captureLog: true, captureNetworkTelemetry: true, }, + errorTracking: { + autocapture: { + uncaughtExceptions: true, + unhandledRejections: true, + }, + }, }; /** @@ -43,3 +50,46 @@ export function useScreenTracking() { } }, [pathname, segments, posthog]); } + +/** + * Associates captured events (and session replays) with the signed-in user. + * Identifies once per user when their profile loads, and resets on logout so + * the next session starts anonymous and events don't bleed across accounts. + * Must be used inside PostHogProvider. + */ +export function useIdentifyUser() { + const posthog = usePostHog(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const { data: user } = useUserQuery(); + const identifiedUuid = useRef(null); + + useEffect(() => { + if (!posthog) return; + + if (!isAuthenticated) { + // Reset only if we previously identified, otherwise we'd churn the + // anonymous distinct id on every render before sign-in. + if (identifiedUuid.current) { + posthog.reset(); + identifiedUuid.current = null; + } + return; + } + + if (!user || identifiedUuid.current === user.uuid) return; + + posthog.identify(user.uuid, { + email: user.email, + name: [user.first_name, user.last_name].filter(Boolean).join(" "), + is_staff: Boolean(user.is_staff), + }); + + if (user.organization) { + posthog.group("organization", user.organization.id, { + name: user.organization.name, + }); + } + + identifiedUuid.current = user.uuid; + }, [posthog, isAuthenticated, user]); +} From b7082ab8767a6c2bcfa7bb99b90cc210a91e07ba Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 21:48:51 +0100 Subject: [PATCH 2/3] fix(mobile): re-identify user when identifying properties change Dedup on a signature of the full identify payload (email, name, staff, organization) instead of just the uuid, so mid-session property and group updates are forwarded to PostHog rather than being suppressed until the next login. --- apps/mobile/src/lib/posthog.ts | 36 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/lib/posthog.ts b/apps/mobile/src/lib/posthog.ts index a00886041..9e9622640 100644 --- a/apps/mobile/src/lib/posthog.ts +++ b/apps/mobile/src/lib/posthog.ts @@ -53,15 +53,18 @@ export function useScreenTracking() { /** * Associates captured events (and session replays) with the signed-in user. - * Identifies once per user when their profile loads, and resets on logout so - * the next session starts anonymous and events don't bleed across accounts. - * Must be used inside PostHogProvider. + * Re-identifies whenever the user's identifying properties change (email, name, + * staff status, organization) so mid-session updates are forwarded, and resets + * on logout so the next session starts anonymous and events don't bleed across + * accounts. Must be used inside PostHogProvider. */ export function useIdentifyUser() { const posthog = usePostHog(); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const { data: user } = useUserQuery(); - const identifiedUuid = useRef(null); + // Signature of the last forwarded payload, so we re-identify on real changes + // but don't spam identify()/group() on every render with identical data. + const lastIdentity = useRef(null); useEffect(() => { if (!posthog) return; @@ -69,19 +72,32 @@ export function useIdentifyUser() { if (!isAuthenticated) { // Reset only if we previously identified, otherwise we'd churn the // anonymous distinct id on every render before sign-in. - if (identifiedUuid.current) { + if (lastIdentity.current) { posthog.reset(); - identifiedUuid.current = null; + lastIdentity.current = null; } return; } - if (!user || identifiedUuid.current === user.uuid) return; + if (!user) return; + + const name = [user.first_name, user.last_name].filter(Boolean).join(" "); + const isStaff = Boolean(user.is_staff); + const signature = JSON.stringify([ + user.uuid, + user.email, + name, + isStaff, + user.organization?.id ?? null, + user.organization?.name ?? null, + ]); + + if (lastIdentity.current === signature) return; posthog.identify(user.uuid, { email: user.email, - name: [user.first_name, user.last_name].filter(Boolean).join(" "), - is_staff: Boolean(user.is_staff), + name, + is_staff: isStaff, }); if (user.organization) { @@ -90,6 +106,6 @@ export function useIdentifyUser() { }); } - identifiedUuid.current = user.uuid; + lastIdentity.current = signature; }, [posthog, isAuthenticated, user]); } From a1d62199be6efc9b1ed42c8397a374799bc569fe Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 22:03:46 +0100 Subject: [PATCH 3/3] fix(mobile): stop identify imports breaking app-version unit tests posthog.ts now imports the auth store and user query directly from source instead of the feature barrel, and posthog.test.ts mocks those two modules so the app-version tests don't transitively load native expo modules (expo-secure-store, expo-auth-session) that can't run in the node test environment. --- apps/mobile/src/lib/posthog.test.ts | 12 ++++++++++++ apps/mobile/src/lib/posthog.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/posthog.test.ts b/apps/mobile/src/lib/posthog.test.ts index 3156d126d..01c186b0c 100644 --- a/apps/mobile/src/lib/posthog.test.ts +++ b/apps/mobile/src/lib/posthog.test.ts @@ -31,6 +31,18 @@ vi.mock("expo-constants", () => ({ }, })); +// posthog.ts imports the auth store and user query for useIdentifyUser. Their +// real modules transitively pull in native expo modules (expo-secure-store, +// expo-auth-session) that can't load under the node test environment, so mock +// them — these app-version tests don't exercise identification. +vi.mock("@/features/auth/stores/authStore", () => ({ + useAuthStore: () => false, +})); + +vi.mock("@/features/auth/hooks/useUserQuery", () => ({ + useUserQuery: () => ({ data: undefined }), +})); + beforeEach(() => { vi.clearAllMocks(); expoApplication.nativeApplicationVersion = null; diff --git a/apps/mobile/src/lib/posthog.ts b/apps/mobile/src/lib/posthog.ts index 2b4fbeead..4813aabeb 100644 --- a/apps/mobile/src/lib/posthog.ts +++ b/apps/mobile/src/lib/posthog.ts @@ -3,7 +3,8 @@ import Constants from "expo-constants"; import { usePathname, useSegments } from "expo-router"; import { usePostHog } from "posthog-react-native"; import { useEffect, useRef } from "react"; -import { useAuthStore, useUserQuery } from "@/features/auth"; +import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; +import { useAuthStore } from "@/features/auth/stores/authStore"; /** * PostHog configuration - used by PostHogProvider in _layout.tsx