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
74 changes: 71 additions & 3 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -92,7 +95,6 @@ function initSentry(consented: boolean) {
sendDefaultPii: false,

enableLogs: true,
tracesSampleRate: 0,
environment: resolveSentryEnvironment(SENTRY_ENVIRONMENT, __DEV__),
...sentryOptionsForConsent(consented),

Expand All @@ -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,
Expand All @@ -133,6 +135,9 @@ function RootLayoutNav() {
const [needsConsent, setNeedsConsent] = useState(false);
const [consentCheckError, setConsentCheckError] = useState<unknown>(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) {
Expand All @@ -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;
Comment thread
iscekic marked this conversation as resolved.

// 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) {
Expand Down Expand Up @@ -306,6 +337,8 @@ function RootLayoutNav() {
if (!inForceUpdate) {
router.replace('/force-update');
} else {
markStartupComplete('force-update');
setStartupFinished(true);
void SplashScreen.hideAsync();
}
return;
Expand All @@ -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;
}
Expand All @@ -339,6 +378,8 @@ function RootLayoutNav() {

if (needsConsent) {
if (onConsentRoute) {
markStartupComplete('consent');
setStartupFinished(true);
void SplashScreen.hideAsync();
} else {
router.replace('/(app)/consent' as Href);
Expand All @@ -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());
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/analytics/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/lib/sentry-consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
18 changes: 12 additions & 6 deletions apps/mobile/src/lib/sentry-consent.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand All @@ -18,6 +22,7 @@ export function sentryOptionsForConsent(consented: boolean): SentryConsentOption
return {
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
tracesSampleRate: 0,
attachScreenshot: false,
attachViewHierarchy: false,
};
Expand All @@ -26,6 +31,7 @@ export function sentryOptionsForConsent(consented: boolean): SentryConsentOption
return {
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
tracesSampleRate: 0.1,
Comment thread
iscekic marked this conversation as resolved.
attachScreenshot: true,
attachViewHierarchy: true,
};
Expand Down
76 changes: 76 additions & 0 deletions apps/mobile/src/lib/startup-timing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import type * as StartupTimingModule from './startup-timing';

async function freshTiming(): Promise<typeof StartupTimingModule> {
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<string, unknown>).theme_ready).toBe(0);
expect((payload as Record<string, unknown>).fonts_ready).toBe(120);
expect((payload as Record<string, unknown>).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<string, unknown>).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<string, unknown>).outcome).toBe('consent');
expect((payload as Record<string, unknown>).splash_hidden).toBe(0);
});
});
53 changes: 53 additions & 0 deletions apps/mobile/src/lib/startup-timing.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
iscekic marked this conversation as resolved.

let origin: number | undefined = undefined;
const marks = new Map<StartupMark, number>();
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<string, string | number> | null {
if (taken || outcome === undefined) {
return null;
}
taken = true;
return { outcome, ...Object.fromEntries(marks) };
}