diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index e771cd11e1..4bffa77190 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -36,6 +36,9 @@ import { useAuth } from '@/lib/auth/auth-context'; import { consentModeForSearchParam } from '@/components/consent/consent-mode'; import { checkConsentGate } from '@/lib/consent-gate'; import { subscribeToConsentChanges } from '@/lib/consent'; +import { shouldStartAnalytics } from '@/lib/analytics-consent'; +import { APP_STARTUP_EVENT, captureEvent } from '@/lib/analytics/posthog'; +import { markStartup, markStartupComplete, takeStartupTimings } from '@/lib/startup-timing'; import { useAnalyticsConsentGate } from '@/lib/hooks/use-analytics-consent-gate'; import { useForceUpdate } from '@/lib/hooks/use-force-update'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; @@ -92,7 +95,6 @@ function initSentry(consented: boolean) { sendDefaultPii: false, enableLogs: true, - tracesSampleRate: 0, environment: resolveSentryEnvironment(SENTRY_ENVIRONMENT, __DEV__), ...sentryOptionsForConsent(consented), @@ -112,7 +114,7 @@ captureLaunchDeepLink(); function RootLayoutNav() { const { token, isLoading: authLoading, signOut } = useAuth(); - const { updateRequired, isChecking: updateChecking } = useForceUpdate(); + const { updateRequired } = useForceUpdate(); const [fontsLoaded, fontsError] = useFonts({ JetBrainsMono_500Medium, JetBrainsMono_600SemiBold, @@ -133,6 +135,9 @@ function RootLayoutNav() { const [needsConsent, setNeedsConsent] = useState(false); const [consentCheckError, setConsentCheckError] = useState(null); const [consentCheckRetryKey, setConsentCheckRetryKey] = useState(0); + // Flipped by every splash-hide site below, so the app_startup drain can + // depend on "startup finished" as an ordinary dependency. + const [startupFinished, setStartupFinished] = useState(false); useEffect(() => { if (fontsError) { @@ -143,7 +148,33 @@ function RootLayoutNav() { useSentryConsentSync(consentChecked && !needsConsent, initSentry); const fontsReady = fontsLoaded || fontsError !== null; - const isLoading = authLoading || updateChecking || !fontsReady || !themeHasLoaded; + // The force-update check is deliberately absent: it is a live network round + // trip that fails open in every branch (lib/hooks/use-force-update), so + // holding first paint for it only ever costs time. `updateRequired` starts + // false, first paint happens, and the effect below routes to /force-update + // if the check later says an update is required. + const isLoading = authLoading || !fontsReady || !themeHasLoaded; + + // Startup phase timings (lib/startup-timing). Idempotent per mark, so this + // effect re-runs freely as gates settle. `userIdLoading` is false while the + // query is disabled, so it only counts once there is a token. + useEffect(() => { + if (!authLoading) { + markStartup('auth_ready'); + } + if (fontsReady) { + markStartup('fonts_ready'); + } + if (themeHasLoaded) { + markStartup('theme_ready'); + } + if (token != null && !userIdLoading) { + markStartup('user_ready'); + } + if (consentChecked) { + markStartup('consent_ready'); + } + }, [authLoading, fontsReady, themeHasLoaded, token, userIdLoading, consentChecked]); useEffect(() => { if (themeHasLoaded) { @@ -306,6 +337,8 @@ function RootLayoutNav() { if (!inForceUpdate) { router.replace('/force-update'); } else { + markStartupComplete('force-update'); + setStartupFinished(true); void SplashScreen.hideAsync(); } return; @@ -318,17 +351,23 @@ function RootLayoutNav() { if (!token) { if (inAuthGroup) { + markStartupComplete('login'); + setStartupFinished(true); void SplashScreen.hideAsync(); } else { router.replace('/(auth)/login'); } } else { if (userIdError) { + markStartupComplete('user-error'); + setStartupFinished(true); void SplashScreen.hideAsync(); return; } if (consentCheckError) { + markStartupComplete('consent-error'); + setStartupFinished(true); void SplashScreen.hideAsync(); return; } @@ -339,6 +378,8 @@ function RootLayoutNav() { if (needsConsent) { if (onConsentRoute) { + markStartupComplete('consent'); + setStartupFinished(true); void SplashScreen.hideAsync(); } else { router.replace('/(app)/consent' as Href); @@ -351,6 +392,8 @@ function RootLayoutNav() { return; } + markStartupComplete('app'); + setStartupFinished(true); void SplashScreen.hideAsync(); // Navigate to pending deep link (cold start universal link / notification tap) const pendingNavigation = resolvePendingNavigation(getPendingDeepLink()); @@ -398,6 +441,31 @@ function RootLayoutNav() { setPendingShareId(null); }, [pendingShareId, isShellReady, onGateRoute, router]); + // One `app_startup` event per launch. It needs BOTH "startup finished" and + // "analytics is allowed to run", and neither implies the other — a + // consent-settled launch can still be waiting on fonts, and a splash hidden + // at the consent screen has no analytics client yet. So both are + // dependencies, and whichever settles last triggers the send. + // + // Must stay the LAST effect here: `takeStartupTimings()` consumes the payload + // and `captureEvent` no-ops while the PostHog client is null, so this has to + // run after useAnalyticsConsentGate's initPostHog(). + // + // Signed-out launches are never reported — PostHog does not start without + // consent, and that is the intended trade. + useEffect(() => { + if ( + !startupFinished || + !shouldStartAnalytics({ hasToken: token != null, consentChecked, needsConsent }) + ) { + return; + } + const timings = takeStartupTimings(); + if (timings) { + captureEvent(APP_STARTUP_EVENT, timings); + } + }, [startupFinished, token, consentChecked, needsConsent]); + const needsForceUpdate = updateRequired && !inForceUpdate; const showingForceUpdate = updateRequired && inForceUpdate; const needsAuth = !token && !inAuthGroup; diff --git a/apps/mobile/src/lib/analytics/posthog.ts b/apps/mobile/src/lib/analytics/posthog.ts index f59c4c75d8..48194fcbb4 100644 --- a/apps/mobile/src/lib/analytics/posthog.ts +++ b/apps/mobile/src/lib/analytics/posthog.ts @@ -43,6 +43,7 @@ export const ORGANIZATION_MEMBER_INVITED_EVENT = 'organization_member_invited'; export const KILO_PASS_PURCHASE_STARTED_EVENT = 'kilo_pass_purchase_started'; export const KILO_PASS_PURCHASE_COMPLETED_EVENT = 'kilo_pass_purchase_completed'; export const KILO_PASS_PURCHASE_FAILED_EVENT = 'kilo_pass_purchase_failed'; +export const APP_STARTUP_EVENT = 'app_startup'; export type AnalyticsSurface = 'claw' | 'cloud-agent' | 'remote-session'; diff --git a/apps/mobile/src/lib/sentry-consent.test.ts b/apps/mobile/src/lib/sentry-consent.test.ts index 9aeeef3f2f..a8b0ca2422 100644 --- a/apps/mobile/src/lib/sentry-consent.test.ts +++ b/apps/mobile/src/lib/sentry-consent.test.ts @@ -7,22 +7,24 @@ const closeMock = vi.hoisted(() => vi.fn()); vi.mock('@sentry/react-native', () => ({ close: closeMock })); describe('sentryOptionsForConsent', () => { - it('disables replay, screenshots, and view-hierarchy when consent is declined', () => { + it('disables replay, screenshots, view-hierarchy, and tracing when consent is declined', () => { expect(sentryOptionsForConsent(false)).toEqual({ replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 0, + tracesSampleRate: 0, attachScreenshot: false, attachViewHierarchy: false, }); }); - it('enables replay, screenshots, and view-hierarchy when consent is accepted', () => { + it('enables replay, screenshots, view-hierarchy, and tracing when consent is accepted', () => { const options = sentryOptionsForConsent(true); expect(options.attachScreenshot).toBe(true); expect(options.attachViewHierarchy).toBe(true); expect(options.replaysSessionSampleRate).toBeGreaterThan(0); expect(options.replaysOnErrorSampleRate).toBeGreaterThan(0); + expect(options.tracesSampleRate).toBeGreaterThan(0); }); }); diff --git a/apps/mobile/src/lib/sentry-consent.ts b/apps/mobile/src/lib/sentry-consent.ts index 3de48fcbbd..c35570e0f2 100644 --- a/apps/mobile/src/lib/sentry-consent.ts +++ b/apps/mobile/src/lib/sentry-consent.ts @@ -1,14 +1,18 @@ import * as Sentry from '@sentry/react-native'; -// Session replay, screenshots, and view-hierarchy capture must not run -// before the user accepts consent (the consent copy only promises -// "anonymous performance and crash data" — see consent-card.tsx). This is -// the pure decision function; src/app/_layout.tsx re-inits Sentry with -// these options (via reinitSentryForConsent below) whenever the stored -// consent state changes. +// Session replay, screenshots, view-hierarchy capture, and performance +// tracing (TTID/TTFD, app start) must not run before the user accepts +// consent (the consent copy only promises "anonymous performance and crash +// data" — see consent-card.tsx). This is the pure decision function; +// src/app/_layout.tsx re-inits Sentry with these options (via +// reinitSentryForConsent below) whenever the stored consent state changes. +// +// Per-launch startup timing therefore comes from the PostHog `app_startup` +// event in src/lib/startup-timing.ts, not from Sentry traces. type SentryConsentOptions = { readonly replaysSessionSampleRate: number; readonly replaysOnErrorSampleRate: number; + readonly tracesSampleRate: number; readonly attachScreenshot: boolean; readonly attachViewHierarchy: boolean; }; @@ -18,6 +22,7 @@ export function sentryOptionsForConsent(consented: boolean): SentryConsentOption return { replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 0, + tracesSampleRate: 0, attachScreenshot: false, attachViewHierarchy: false, }; @@ -26,6 +31,7 @@ export function sentryOptionsForConsent(consented: boolean): SentryConsentOption return { replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1, + tracesSampleRate: 0.1, attachScreenshot: true, attachViewHierarchy: true, }; diff --git a/apps/mobile/src/lib/startup-timing.test.ts b/apps/mobile/src/lib/startup-timing.test.ts new file mode 100644 index 0000000000..2090c34da2 --- /dev/null +++ b/apps/mobile/src/lib/startup-timing.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type * as StartupTimingModule from './startup-timing'; + +async function freshTiming(): Promise { + vi.resetModules(); + const mod = import('./startup-timing'); + // satisfy require-await without return-await + await Promise.resolve(); + return mod; +} + +describe('startup-timing', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('measures deltas from the first mark and marks are first-wins', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + + const { markStartup, markStartupComplete, takeStartupTimings } = await freshTiming(); + + // First mark establishes the origin — its own delta is always 0. + markStartup('theme_ready'); + vi.advanceTimersByTime(120); + markStartup('fonts_ready'); + vi.advanceTimersByTime(30); + // Second mark for the same gate is ignored. + markStartup('fonts_ready'); + + markStartupComplete('app'); + const payload = takeStartupTimings(); + + expect(payload).not.toBeNull(); + expect((payload as Record).theme_ready).toBe(0); + expect((payload as Record).fonts_ready).toBe(120); + expect((payload as Record).splash_hidden).toBe(150); + }); + + it('returns null for an unfinished launch and is taken exactly once', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + + const { markStartup, markStartupComplete, takeStartupTimings } = await freshTiming(); + + // Before any markStartupComplete, nothing is ready to send. + markStartup('auth_ready'); + expect(takeStartupTimings()).toBeNull(); + + // After completion, the payload is returned exactly once. + markStartupComplete('app'); + const first = takeStartupTimings(); + expect(first).not.toBeNull(); + expect((first as Record).outcome).toBe('app'); + + const second = takeStartupTimings(); + expect(second).toBeNull(); + }); + + it('first outcome wins when markStartupComplete is called multiple times', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + + const { markStartupComplete, takeStartupTimings } = await freshTiming(); + + markStartupComplete('consent'); + vi.advanceTimersByTime(50); + markStartupComplete('app'); + + const payload = takeStartupTimings(); + expect(payload).not.toBeNull(); + expect((payload as Record).outcome).toBe('consent'); + expect((payload as Record).splash_hidden).toBe(0); + }); +}); diff --git a/apps/mobile/src/lib/startup-timing.ts b/apps/mobile/src/lib/startup-timing.ts new file mode 100644 index 0000000000..e892d8f920 --- /dev/null +++ b/apps/mobile/src/lib/startup-timing.ts @@ -0,0 +1,53 @@ +// One-shot cold-start timing. Deltas are milliseconds from the FIRST +// `markStartup` call of any name (its own value is therefore always 0) — read +// them as "since the first bootstrap gate was observed", not "since auth" and +// not "since process start". Pre-JS native startup is not observable from JS; +// Sentry's app-start measurement covers that half. +// +// Deliberately imports nothing: anything imported here would evaluate before +// the origin is captured and its own cost would disappear from the numbers. + +type StartupMark = + | 'auth_ready' + | 'fonts_ready' + | 'theme_ready' + | 'user_ready' + | 'consent_ready' + | 'splash_hidden'; + +// Why the splash hid — the startup path this launch actually took. Stable enum +// strings only, per the payload rules in src/lib/analytics/posthog.ts. +type StartupOutcome = 'app' | 'login' | 'consent' | 'force-update' | 'user-error' | 'consent-error'; + +let origin: number | undefined = undefined; +const marks = new Map(); +let outcome: StartupOutcome | undefined = undefined; +let taken = false; + +// First mark wins for a given name: these are gate transitions, and the +// effect that records them re-runs on every later gate change. +export function markStartup(mark: StartupMark): void { + origin ??= Date.now(); + if (!marks.has(mark)) { + marks.set(mark, Date.now() - origin); + } +} + +// The first splash hide ends startup; later navigations are not startup. +export function markStartupComplete(value: StartupOutcome): void { + if (outcome === undefined) { + outcome = value; + markStartup('splash_hidden'); + } +} + +// Returns the event payload exactly once per launch, and only after startup +// actually finished. Null means "nothing to send" — never send a partial +// launch, and never send twice. Callers may poll this freely. +export function takeStartupTimings(): Record | null { + if (taken || outcome === undefined) { + return null; + } + taken = true; + return { outcome, ...Object.fromEntries(marks) }; +}