diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 64ede2513..2687bbae9 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, + useRegisterAppVersion, useScreenTracking, } from "@/lib/posthog"; import { queryClient } from "@/lib/queryClient"; @@ -36,6 +37,7 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { const pathname = usePathname(); useScreenTracking(); + useRegisterAppVersion(); useEffect(() => { initializeAuth(); diff --git a/apps/mobile/src/lib/posthog.test.ts b/apps/mobile/src/lib/posthog.test.ts new file mode 100644 index 000000000..3156d126d --- /dev/null +++ b/apps/mobile/src/lib/posthog.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const expoApplication = { + nativeApplicationVersion: null as string | null, +}; + +const expoConstants = { + expoConfig: null as { version?: string } | null, +}; + +vi.mock("posthog-react-native", () => ({ + usePostHog: () => null, +})); + +vi.mock("expo-router", () => ({ + usePathname: () => "/", + useSegments: () => [] as string[], +})); + +vi.mock("expo-application", () => ({ + get nativeApplicationVersion() { + return expoApplication.nativeApplicationVersion; + }, +})); + +vi.mock("expo-constants", () => ({ + default: { + get expoConfig() { + return expoConstants.expoConfig; + }, + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + expoApplication.nativeApplicationVersion = null; + expoConstants.expoConfig = { version: "0.0.0-test" }; +}); + +describe("getAppVersion", () => { + it("prefers the native application version when present", async () => { + expoApplication.nativeApplicationVersion = "9.8.7"; + expoConstants.expoConfig = { version: "0.0.0-test" }; + + const { getAppVersion } = await import("./posthog"); + + expect(getAppVersion()).toBe("9.8.7"); + }); + + it("falls back to the Expo config version when no native version is available", async () => { + expoApplication.nativeApplicationVersion = null; + expoConstants.expoConfig = { version: "1.2.3" }; + + const { getAppVersion } = await import("./posthog"); + + expect(getAppVersion()).toBe("1.2.3"); + }); + + it("returns null when neither source has a version", async () => { + expoApplication.nativeApplicationVersion = null; + expoConstants.expoConfig = null; + + const { getAppVersion } = await import("./posthog"); + + expect(getAppVersion()).toBeNull(); + }); +}); + +describe("registerAppVersion", () => { + it("registers app_version as a super property on the PostHog client", async () => { + const register = vi.fn(); + const { registerAppVersion } = await import("./posthog"); + + registerAppVersion({ register }, "1.2.3"); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith({ app_version: "1.2.3" }); + }); + + it("does nothing when the PostHog client is not yet available", async () => { + const { registerAppVersion } = await import("./posthog"); + + expect(() => registerAppVersion(null, "1.2.3")).not.toThrow(); + }); + + it("does nothing when no app version can be resolved", async () => { + const register = vi.fn(); + const { registerAppVersion } = await import("./posthog"); + + registerAppVersion({ register }, null); + + expect(register).not.toHaveBeenCalled(); + }); + + 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"); + + registerAppVersion({ register }); + + expect(register).toHaveBeenCalledWith({ app_version: "4.5.6" }); + }); +}); diff --git a/apps/mobile/src/lib/posthog.ts b/apps/mobile/src/lib/posthog.ts index 11fb74143..ebf11463c 100644 --- a/apps/mobile/src/lib/posthog.ts +++ b/apps/mobile/src/lib/posthog.ts @@ -1,3 +1,5 @@ +import * as Application from "expo-application"; +import Constants from "expo-constants"; import { usePathname, useSegments } from "expo-router"; import { usePostHog } from "posthog-react-native"; import { useEffect, useRef } from "react"; @@ -18,6 +20,49 @@ export const POSTHOG_OPTIONS = { }, }; +/** + * Resolve the app version that should ride along on every custom event. Prefer + * the native runtime value so OTA-updated binaries still report their actual + * shipped version; fall back to the Expo config (app.json) when running where + * expo-application has no native value (e.g. Expo Go, web preview). + */ +export function getAppVersion(): string | null { + return ( + Application.nativeApplicationVersion ?? + Constants.expoConfig?.version ?? + null + ); +} + +type PostHogRegisterClient = { + register: (properties: { app_version: string }) => 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. + */ +export function registerAppVersion( + client: PostHogRegisterClient | null | undefined, + version: string | null = getAppVersion(), +) { + if (!client || version === null) return; + client.register({ 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. + */ +export function useRegisterAppVersion() { + const posthog = usePostHog(); + + useEffect(() => { + registerAppVersion(posthog); + }, [posthog]); +} + /** * Screen tracking hook for expo-router. * Must be used inside PostHogProvider.