mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -04:00
feat(native): use App Tracking Transparency to gate PostHog analytics (#10)
This commit is contained in:
@@ -123,7 +123,13 @@
|
||||
"microphonePermission": false
|
||||
}
|
||||
],
|
||||
["expo-localization"]
|
||||
["expo-localization"],
|
||||
[
|
||||
"expo-tracking-transparency",
|
||||
{
|
||||
"userTrackingPermission": "This identifier will be used to measure app performance and improve your experience."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"expo-splash-screen": "55.0.10",
|
||||
"expo-status-bar": "55.0.4",
|
||||
"expo-system-ui": "55.0.9",
|
||||
"expo-tracking-transparency": "^55.0.8",
|
||||
"expo-web-browser": "55.0.9",
|
||||
"posthog-react-native": "4.37.2",
|
||||
"react": "19.2.0",
|
||||
|
||||
@@ -3,6 +3,11 @@ import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client
|
||||
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import {
|
||||
getAdvertisingId,
|
||||
getTrackingPermissionsAsync,
|
||||
requestTrackingPermissionsAsync,
|
||||
} from "expo-tracking-transparency";
|
||||
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -13,7 +18,7 @@ import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||
import { ToastProvider } from "@/components/ui/toast-provider";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { queryPersister } from "@/lib/mmkv";
|
||||
import { posthog } from "@/lib/posthog";
|
||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { hasStoredServerUrl, onServerUrlChange } from "@/lib/server-url";
|
||||
|
||||
@@ -35,15 +40,42 @@ function AppContent() {
|
||||
const hasServerUrl =
|
||||
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
|
||||
|
||||
// --- PostHog screen tracking ---
|
||||
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
||||
const [trackingReady, setTrackingReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { status } = await getTrackingPermissionsAsync();
|
||||
const granted =
|
||||
status === "undetermined"
|
||||
? (await requestTrackingPermissionsAsync()).granted
|
||||
: status === "granted";
|
||||
|
||||
const enabled = applyTrackingTransparency(granted);
|
||||
|
||||
// Use the platform advertising ID (IDFA / AAID) as the PostHog
|
||||
// distinct ID, but only when the resolved state is actually enabled
|
||||
// (respects both ATT result and the user's settings override).
|
||||
if (enabled && posthog) {
|
||||
const advertisingId = await getAdvertisingId();
|
||||
if (advertisingId) {
|
||||
posthog.identify(advertisingId);
|
||||
}
|
||||
}
|
||||
|
||||
setTrackingReady(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// --- PostHog screen tracking (waits for ATT to resolve) ---
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (posthog && pathname) {
|
||||
if (trackingReady && posthog && pathname) {
|
||||
posthog.screen(pathname, params);
|
||||
}
|
||||
}, [pathname, params]);
|
||||
}, [trackingReady, pathname, params]);
|
||||
|
||||
useEffect(() => {
|
||||
Uniwind.setTheme("dark");
|
||||
|
||||
@@ -7,6 +7,8 @@ const posthogApiKey = process.env.EXPO_PUBLIC_POSTHOG_KEY ?? "";
|
||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
||||
|
||||
const ANALYTICS_ENABLED_KEY = "sofa_analytics_enabled";
|
||||
const ANALYTICS_EXPLICIT_KEY = "sofa_analytics_explicit";
|
||||
const ATT_MIGRATED_KEY = "sofa_att_migrated";
|
||||
|
||||
const posthogStorage: PostHogCustomStorage = {
|
||||
getItem: (key: string) => storage.getString(key) ?? null,
|
||||
@@ -14,10 +16,11 @@ const posthogStorage: PostHogCustomStorage = {
|
||||
};
|
||||
|
||||
// PostHog throws if apiKey is empty, so only construct when configured.
|
||||
// Start opted-out; the ATT check in root layout will opt in if appropriate.
|
||||
export const posthog: PostHog | null = posthogApiKey
|
||||
? new PostHog(posthogApiKey, {
|
||||
host,
|
||||
defaultOptIn: storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true,
|
||||
defaultOptIn: false,
|
||||
customStorage: posthogStorage,
|
||||
captureAppLifecycleEvents: true,
|
||||
personProfiles: "never",
|
||||
@@ -30,15 +33,71 @@ export const posthog: PostHog | null = posthogApiKey
|
||||
})
|
||||
: null;
|
||||
|
||||
/** Whether the user has explicitly set a preference via the settings toggle. */
|
||||
export function hasExplicitPreference(): boolean {
|
||||
return storage.getBoolean(ANALYTICS_EXPLICIT_KEY) === true;
|
||||
}
|
||||
|
||||
/** Current analytics enabled state (explicit preference or default). */
|
||||
export function isAnalyticsEnabled(): boolean {
|
||||
return storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
|
||||
}
|
||||
|
||||
/** Called by the settings toggle — marks the preference as explicit. */
|
||||
export function setAnalyticsEnabled(enabled: boolean): void {
|
||||
storage.set(ANALYTICS_ENABLED_KEY, enabled);
|
||||
storage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||
syncPosthog(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync PostHog opt-in/out state based on the resolved analytics flag.
|
||||
* Called after ATT check and from the settings toggle.
|
||||
*/
|
||||
export function syncPosthog(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
posthog?.optIn();
|
||||
} else {
|
||||
posthog?.optOut();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve analytics state after an ATT permission check.
|
||||
* If the user has an explicit preference (or a legacy preference from before
|
||||
* ATT was introduced), that wins. Otherwise the ATT result is stored as the
|
||||
* current default and PostHog is synced.
|
||||
*
|
||||
* Returns the resolved enabled state so callers can decide whether to proceed
|
||||
* with identifying, etc.
|
||||
*/
|
||||
export function applyTrackingTransparency(granted: boolean): boolean {
|
||||
// One-time migration: on the first launch after the ATT update, check if
|
||||
// the user had previously toggled analytics via the settings switch (which
|
||||
// was the only way ANALYTICS_ENABLED_KEY got set before ATT). If so,
|
||||
// promote it to an explicit preference so the ATT result doesn't overwrite
|
||||
// it. This runs exactly once — subsequent launches skip it because
|
||||
// ATT_MIGRATED_KEY is set, preventing applyTrackingTransparency's own
|
||||
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
|
||||
if (!storage.getBoolean(ATT_MIGRATED_KEY)) {
|
||||
storage.set(ATT_MIGRATED_KEY, true);
|
||||
if (
|
||||
storage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
|
||||
!hasExplicitPreference()
|
||||
) {
|
||||
storage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExplicitPreference()) {
|
||||
// User already made a choice in settings — honour it.
|
||||
const enabled = isAnalyticsEnabled();
|
||||
syncPosthog(enabled);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// No explicit preference yet — follow the ATT result.
|
||||
storage.set(ANALYTICS_ENABLED_KEY, granted);
|
||||
syncPosthog(granted);
|
||||
return granted;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user