Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/mobile/.env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
POSTHOG_API_KEY,
POSTHOG_OPTIONS,
useIdentifyUser,
useRegisterAppVersion,
useRegisterSuperProperties,
useScreenTracking,
} from "@/lib/posthog";
import { queryClient } from "@/lib/queryClient";
Expand All @@ -39,7 +39,7 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) {

useScreenTracking();
useIdentifyUser();
useRegisterAppVersion();
useRegisterSuperProperties();

useEffect(() => {
initializeAuth();
Expand Down
50 changes: 48 additions & 2 deletions apps/mobile/src/app/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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`
Expand All @@ -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);
}
Expand All @@ -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) {
Expand All @@ -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);
}
Expand All @@ -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.");
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 23 additions & 14 deletions apps/mobile/src/lib/posthog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
Comment on lines +81 to 125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prefer parameterised tests for the registerPersistentSuperProperties suite

Three of the four cases here test the same code path (client present, call register) and differ only in the version input and the expected properties object. Per the team's simplicity rules, consolidating them into a single it.each table (e.g., [version, expected]) removes the repeated vi.fn() / import / expect boilerplate and makes it easier to add a new combination in the future.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/lib/posthog.test.ts
Line: 81-125

Comment:
**Prefer parameterised tests for the `registerPersistentSuperProperties` suite**

Three of the four cases here test the same code path (client present, call `register`) and differ only in the `version` input and the expected properties object. Per the team's simplicity rules, consolidating them into a single `it.each` table (e.g., `[version, expected]`) removes the repeated `vi.fn()` / `import` / `expect` boilerplate and makes it easier to add a new combination in the future.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

33 changes: 21 additions & 12 deletions apps/mobile/src/lib/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,31 +45,36 @@ export function getAppVersion(): string | null {
}

type PostHogRegisterClient = {
register: (properties: { app_version: string }) => unknown;
register: (properties: Record<string, 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.
* 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]);
}

Expand Down Expand Up @@ -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;
Expand Down
19 changes: 17 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading