fix(native): keep analytics off until the user opts in

Default analytics to off, and name screen events from route patterns instead of pathnames and params so they carry no content IDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-06 12:56:58 -04:00
1 parent f48da5e6c4
commit 8084561a18
3 files changed
+74 -13

No files matched your search

+7 -7
View File
@@ -7,7 +7,7 @@ import {
persistQueryClientSubscribe,
} from "@tanstack/react-query-persist-client";
import * as Application from "expo-application";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import { Stack, useSegments } from "expo-router";
import { ThemeProvider } from "expo-router/react-navigation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
@@ -28,7 +28,7 @@ import { useServerConnection } from "@/hooks/use-server-connection";
import { useWidgetRefresh } from "@/hooks/use-widget-refresh";
import { initLocale } from "@/lib/i18n";
import { createScopedQueryPersister, hasScopedStorage, scopedStorage } from "@/lib/mmkv";
import { initAnalytics, posthog } from "@/lib/posthog";
import { initAnalytics, posthog, screenNameFromSegments } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { QUERY_PERSIST_MAX_AGE } from "@/lib/query-config";
import { initSentry, Sentry } from "@/lib/sentry";
@@ -78,14 +78,14 @@ function AppContent() {
}, []);
// --- PostHog screen tracking ---
const pathname = usePathname();
const params = useGlobalSearchParams();
const segments = useSegments();
const screenName = screenNameFromSegments(segments);
useEffect(() => {
if (posthog && pathname) {
posthog.screen(pathname, params);
if (posthog) {
posthog.screen(screenName);
}
}, [pathname, params]);
}, [screenName]);
useEffect(() => {
Uniwind.setTheme("dark");
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
const store = new Map<string, boolean | string>();
vi.mock("@/lib/mmkv", () => ({
globalStorage: {
getBoolean: (key: string) => {
const v = store.get(key);
return typeof v === "boolean" ? v : undefined;
},
getString: (key: string) => {
const v = store.get(key);
return typeof v === "string" ? v : undefined;
},
set: (key: string, value: boolean | string) => {
store.set(key, value);
},
},
}));
vi.mock("posthog-react-native", () => ({ PostHog: vi.fn<() => void>() }));
const mod = await import("./posthog");
describe("analytics consent", () => {
it("is off by default", () => {
store.clear();
expect(mod.isAnalyticsEnabled()).toBe(false);
});
it("follows the explicit preference", () => {
mod.setAnalyticsEnabled(true);
expect(mod.isAnalyticsEnabled()).toBe(true);
mod.setAnalyticsEnabled(false);
expect(mod.isAnalyticsEnabled()).toBe(false);
});
});
describe("screenNameFromSegments", () => {
it("names screens from route patterns without dynamic values", () => {
const f = mod.screenNameFromSegments;
expect(f(["(tabs)", "(home)", "index"])).toBe("home");
expect(f(["(tabs)", "(search)", "index"])).toBe("search");
expect(f(["(tabs)", "(library)", "history"])).toBe("library/history");
expect(f(["(tabs)", "(home)", "upcoming"])).toBe("home/upcoming");
expect(f(["(auth)", "login"])).toBe("login");
expect(f(["title", "[id]"])).toBe("title/[id]");
expect(f(["person", "[id]"])).toBe("person/[id]");
expect(f([])).toBe("home");
});
});
+16 -6
View File
@@ -32,14 +32,24 @@ export const posthog: PostHog | null = posthogApiKey
})
: null;
/** Whether the user has explicitly set a preference via the settings toggle. */
export function hasExplicitPreference(): boolean {
return globalStorage.getBoolean(ANALYTICS_EXPLICIT_KEY) === true;
/** Current analytics enabled state: explicit preference, else off — see docs/content/docs/telemetry.mdx. */
export function isAnalyticsEnabled(): boolean {
return globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) ?? false;
}
/** Current analytics enabled state (explicit preference or default). */
export function isAnalyticsEnabled(): boolean {
return globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
/** Route groups that only wrap layouts; the tab groups inside `(tabs)` do name the screen. */
const LAYOUT_GROUPS = new Set(["(tabs)", "(auth)"]);
/**
* PostHog screen name from Expo Router segments, without dynamic values:
* ["(tabs)", "(search)", "index"] → "search", ["(tabs)", "(library)", "history"] →
* "library/history", ["title", "[id]"] → "title/[id]".
*/
export function screenNameFromSegments(segments: readonly string[]): string {
const parts = segments
.filter((s) => !LAYOUT_GROUPS.has(s) && s !== "index")
.map((s) => (s.startsWith("(") && s.endsWith(")") ? s.slice(1, -1) : s));
return parts.length > 0 ? parts.join("/") : "home";
}
/** Called by the settings toggle — marks the preference as explicit. */