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
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"expo-constants": "~57.0.7",
"expo-crypto": "~57.0.1",
"expo-dev-client": "~57.0.9",
"expo-device": "~57.0.1",
"expo-document-picker": "~57.0.1",
"expo-file-system": "~57.0.1",
"expo-font": "57.0.1",
Expand Down
106 changes: 106 additions & 0 deletions apps/mobile/src/lib/analytics/posthog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const hoisted = vi.hoisted(() => {
const client = {
register: vi.fn(),
setPersonPropertiesForFlags: vi.fn(),
onFeatureFlags: vi.fn(),
};
const holder: { options?: Record<string, unknown> } = {};
const device: { deviceType: number | null } = { deviceType: null };
return { client, holder, device };
});

vi.mock('posthog-react-native', () => ({
// Must be a `function`, not an arrow: arrows are not constructible and
// `new PostHog(...)` throws "not a constructor". A `function` returning an
// object makes `new` yield that object.
default: vi.fn(function PostHogMock(_key: string, options: Record<string, unknown>) {
hoisted.holder.options = options;
return hoisted.client;
}),
}));

vi.mock('expo-device', () => ({
DeviceType: { UNKNOWN: 0, PHONE: 1, TABLET: 2, DESKTOP: 3, TV: 4 },
// Getter: reads the holder at access time, so per-test values take effect.
get deviceType() {
return hoisted.device.deviceType;
},
}));

// Required: the real expo-application import pulls in react-native, which
// pure vitest cannot load.
vi.mock('expo-application', () => ({
nativeApplicationVersion: '1.2.3',
nativeBuildVersion: '45',
}));

vi.mock('@/lib/config', () => ({ POSTHOG_API_KEY: 'test-key' }));

vi.stubGlobal('__DEV__', false);

type CustomAppProperties = (properties: Record<string, unknown>) => Record<string, unknown>;

function readCustomAppProperties(): CustomAppProperties {
const customAppProperties = hoisted.holder.options?.customAppProperties;
expect(customAppProperties).toEqual(expect.any(Function));
return customAppProperties as CustomAppProperties;
}

async function loadInitPostHog() {
vi.resetModules();
const module = await import('./posthog');
return module.initPostHog;
}

describe('initPostHog device_form_factor', () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.holder.options = undefined;
hoisted.device.deviceType = null;
});

it.each([
{ deviceType: 1, expected: 'phone' },
{ deviceType: 2, expected: 'tablet' },
{ deviceType: 3, expected: 'desktop' },
{ deviceType: 4, expected: 'tv' },
{ deviceType: 0, expected: 'unknown' },
{ deviceType: null, expected: 'unknown' },
] as const)(
'maps deviceType $deviceType to device_form_factor $expected',
async ({ deviceType, expected }) => {
hoisted.device.deviceType = deviceType;
const initPostHog = await loadInitPostHog();
initPostHog();

const customAppProperties = readCustomAppProperties();
expect(customAppProperties({ $device_type: 'Mobile' })).toEqual({
$device_type: 'Mobile',
device_form_factor: expected,
});
}
);

it('registers platform: mobile once', async () => {
hoisted.device.deviceType = 1;
const initPostHog = await loadInitPostHog();
initPostHog();

expect(hoisted.client.register).toHaveBeenCalledTimes(1);
expect(hoisted.client.register).toHaveBeenCalledWith({ platform: 'mobile' });
});

it('constructs PostHog only once when init is called twice', async () => {
hoisted.device.deviceType = 1;
const initPostHog = await loadInitPostHog();
const posthogModule = await import('posthog-react-native');
const PostHog = posthogModule.default;

initPostHog();
initPostHog();

expect(PostHog).toHaveBeenCalledTimes(1);
});
});
43 changes: 42 additions & 1 deletion apps/mobile/src/lib/analytics/posthog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Application from 'expo-application';
import * as Device from 'expo-device';
import PostHog from 'posthog-react-native';
import { useCallback, useSyncExternalStore } from 'react';

Expand All @@ -10,7 +11,23 @@ import { POSTHOG_API_KEY } from '@/lib/config';
* the web convention (see apps/web PostHogProvider) — otherwise the same
* person double-counts across platforms.
*
* Payload rules (hard): stable enum strings only — no free text, no PII.
* Payload rules (hard): stable enum strings only — no free text, no PII. This
* binds OUR `captureEvent` / `customAppProperties` payloads. The SDK's stock
* auto-captured device fields (`$device_name`, `$device_manufacturer`, etc.)
* are an intentional exception — still no PII beyond stock device metadata.
*
* Auto-captured on every event (posthog-react-native 4.59.0 `native-deps.js`):
* - Once `expo-device` is installed: `$device_manufacturer`, `$device_name`,
* `$os_name`, `$os_version`, `$is_emulator`
* - Always: `$device_type` (always the string `'Mobile'` — no form-factor
* granularity), `$app_name`/`$app_version`/`$app_build`/`$app_namespace`,
* `$locale`/`$timezone`, `$screen_width`/`$screen_height`
*
* Brand (`Device.brand`) is skipped — near-duplicate of manufacturer, no extra
* segmentation value. Form factor is the real gap: we add `device_form_factor`
* via the `customAppProperties` constructor option (merges into `_appProperties`,
* lands on every event including the first, survives `client.reset()` — unlike
* `register()`). We do not override the reserved `$device_type`.
*/

export const SESSION_VIEWED_EVENT = 'session_viewed';
Expand Down Expand Up @@ -57,6 +74,26 @@ function appVersionProperties(): Record<string, string> {
return props;
}

// Total mapping of expo-device DeviceType (+ null) → stable enum strings.
// Always present on every event via customAppProperties; never conditional.
function deviceFormFactor(): 'phone' | 'tablet' | 'desktop' | 'tv' | 'unknown' {
// Total mapping: null / UNKNOWN / unexpected → 'unknown'. if-chain (not
// switch) so exhaustiveness over DeviceType | null stays lint-clean.
if (Device.deviceType === Device.DeviceType.PHONE) {
return 'phone';
}
if (Device.deviceType === Device.DeviceType.TABLET) {
return 'tablet';
}
if (Device.deviceType === Device.DeviceType.DESKTOP) {
return 'desktop';
}
if (Device.deviceType === Device.DeviceType.TV) {
return 'tv';
}
return 'unknown';
}

export function initPostHog(): void {
if (client) {
return;
Expand All @@ -65,6 +102,10 @@ export function initPostHog(): void {
host: 'https://us.i.posthog.com',
// No events are sent from dev builds.
disabled: __DEV__,
customAppProperties: properties => ({
...properties,
device_form_factor: deviceFormFactor(),
}),
});
// Super property on every event so dashboards can filter mobile vs web
// without relying on $lib.
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/vitest.pure.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineProject({
'src/lib/*.test.ts',
'src/lib/a11y/**/*.test.ts',
'src/lib/agent-attachments/**/*.test.ts',
'src/lib/analytics/**/*.test.ts',
'src/lib/auth/**/*.test.ts',
'src/lib/apple-iap/**/*.test.ts',
'src/lib/apple-iap/**/*.test.tsx',
Expand Down
Loading