From 8fb536191cb7eb740dd5adc0860a5d1870c1e302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 18:55:30 +0200 Subject: [PATCH 1/2] feat(mobile): enrich Sentry error context --- apps/mobile/src/app/_layout.tsx | 50 ++++++++++-- .../agents/attachment-picker.test.ts | 8 +- .../components/agents/attachment-picker.ts | 8 +- .../kilo-chat/hooks/use-mark-read.ts | 8 +- .../onboarding/identity-step.test.tsx | 8 +- .../kiloclaw/onboarding/identity-step.tsx | 8 +- .../strip-image-metadata.test.ts | 9 ++- .../agent-attachments/strip-image-metadata.ts | 11 ++- .../use-agent-attachment-upload.test.ts | 31 ++++++- .../use-agent-attachment-upload.ts | 11 ++- apps/mobile/src/lib/appsflyer.test.ts | 26 ++++-- apps/mobile/src/lib/appsflyer.ts | 17 ++-- apps/mobile/src/lib/auth/auth-context.test.ts | 2 +- .../mobile/src/lib/auth/auth-context.test.tsx | 2 + apps/mobile/src/lib/auth/auth-context.tsx | 4 +- apps/mobile/src/lib/auth/credentials.test.ts | 6 +- .../src/lib/auth/logout-cleanup.test.ts | 4 +- apps/mobile/src/lib/auth/logout-cleanup.ts | 8 +- .../lib/auth/pending-external-auth.test.ts | 13 +++ .../src/lib/auth/pending-external-auth.ts | 4 +- .../src/lib/auth/use-native-auth.test.ts | 8 +- apps/mobile/src/lib/auth/use-sso-recovery.ts | 13 --- apps/mobile/src/lib/deep-link-launch.test.ts | 4 +- apps/mobile/src/lib/deep-link-launch.ts | 8 +- .../lib/hooks/secure-store-preference.test.ts | 4 +- .../src/lib/hooks/secure-store-preference.ts | 4 +- .../use-tracking-permission-prompt.test.ts | 11 ++- .../hooks/use-tracking-permission-prompt.ts | 14 +++- apps/mobile/src/lib/notifications.test.ts | 8 +- apps/mobile/src/lib/notifications.ts | 8 +- apps/mobile/src/lib/persist/drafts.test.ts | 32 ++++---- apps/mobile/src/lib/persist/drafts.ts | 80 +++++++------------ .../src/lib/persist/encrypted-kv.test.ts | 9 +++ apps/mobile/src/lib/persist/encrypted-kv.ts | 7 +- .../src/lib/persist/mutation-outbox.test.ts | 9 +++ .../mobile/src/lib/persist/mutation-outbox.ts | 73 +++++------------ .../pending-review-provider.mounted.test.tsx | 7 +- .../lib/pr-review/pending-review-provider.tsx | 2 + apps/mobile/src/lib/sentry-context.test.ts | 71 ++++++++++++++++ apps/mobile/src/lib/sentry-context.ts | 31 +++++++ 40 files changed, 451 insertions(+), 190 deletions(-) create mode 100644 apps/mobile/src/lib/sentry-context.test.ts create mode 100644 apps/mobile/src/lib/sentry-context.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index f83d9351e8..61645faa60 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -86,6 +86,7 @@ import { import { SENTRY_ENVIRONMENT } from '@/lib/config'; import { SENTRY_DSN } from '@/lib/sentry-dsn'; import { sentryOptionsForConsent } from '@/lib/sentry-consent'; +import { applySentryContext, setSentryContext } from '@/lib/sentry-context'; import { scrubBreadcrumb, scrubEvent } from '@/lib/telemetry/sentry-scrub'; import { resolveSentryEnvironment } from '@/lib/sentry-environment'; import { useSentryConsentSync } from '@/lib/hooks/use-sentry-consent-sync'; @@ -103,7 +104,8 @@ installE2EWebSocketLatency(); // MASKED session replay and error screenshots (DEC-02 amendment, owner // decision 2026-08-17); the replay integration is only registered once // optional consent is accepted, so no replay code runs before the -// decision. Account identity is cleared by step 7's `Sentry.setUser(null)`. +// decision. The Sentry context module reapplies identity and global tags after +// every init, and auth sign-out clears its canonical identity state. // `enableTombstone` is Android 12+ only; NDK stays on for older devices. // `enableMetricKit` is iOS 15+ only. App-hang tracking stays off so MetricKit // hangs are not reported twice. Native init in the Expo plugin captures @@ -148,6 +150,7 @@ function initSentry(optionalConsented: boolean) { spotlight: __DEV__, }); + applySentryContext(); } initSentry(false); @@ -208,10 +211,37 @@ function RootLayoutNav() { useEffect(() => { if (fontsError) { - Sentry.captureException(fontsError); + Sentry.captureException(fontsError, { + tags: { 'error.subsystem': 'startup', 'error.operation': 'load_fonts' }, + }); } }, [fontsError]); + useEffect(() => { + let authState: 'error' | 'loading' | 'signed_in' | 'signed_out' = 'signed_out'; + if (authLoading || userIdLoading) { + authState = 'loading'; + } else if (userIdError) { + authState = 'error'; + } else if (token && userId) { + authState = 'signed_in'; + } + setSentryContext({ + userId: authState === 'signed_in' ? (userId ?? null) : null, + authState, + telemetryMode: consentChecked && !needsConsent && optionalConsent ? 'optional' : 'mandatory', + }); + }, [ + authLoading, + consentChecked, + needsConsent, + optionalConsent, + token, + userId, + userIdError, + userIdLoading, + ]); + // Cold-start read-cache restore: best effort, never blocks startup. Starts // before the auth gate resolves so allowlisted queries can hydrate under // the splash; the authenticated mount abandons or rescopes it on identity. @@ -317,7 +347,9 @@ function RootLayoutNav() { } if (result.status === 'error') { - Sentry.captureException(result.error); + Sentry.captureException(result.error, { + tags: { 'error.subsystem': 'consent', 'error.operation': 'read_decision' }, + }); setNeedsConsent(false); setOptionalConsentState(false); setConsentChecked(false); @@ -379,7 +411,13 @@ function RootLayoutNav() { useEffect(() => { if (shareIntentError) { - Sentry.captureException(new Error(shareIntentError)); + Sentry.captureException(new Error('Share intent provider error'), { + tags: { + 'error.subsystem': 'share-intent', + 'error.operation': 'read_native_payload', + }, + fingerprint: ['share-intent-provider-error'], + }); toast.error("Couldn't read the shared content"); resetShareIntentRef.current(); } @@ -417,7 +455,9 @@ function RootLayoutNav() { if (cancelled) { return; } - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { 'error.subsystem': 'share-intent', 'error.operation': 'normalize_payload' }, + }); toast.error("Couldn't read the shared content"); resetShareIntentRef.current(); } diff --git a/apps/mobile/src/components/agents/attachment-picker.test.ts b/apps/mobile/src/components/agents/attachment-picker.test.ts index 63f34cd25e..e11a8ca233 100644 --- a/apps/mobile/src/components/agents/attachment-picker.test.ts +++ b/apps/mobile/src/components/agents/attachment-picker.test.ts @@ -118,7 +118,13 @@ describe('agent attachment picker', () => { const candidates = await pickWithSheetSelection(1); expect(candidates).toHaveLength(1); - expect(Sentry.captureException).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), { + tags: { + 'error.subsystem': 'agent-attachments', + 'error.operation': 'write-picker-launch-context', + }, + extra: { source: 'library', surface: 'agent-chat', hasSession: true }, + }); }); }); diff --git a/apps/mobile/src/components/agents/attachment-picker.ts b/apps/mobile/src/components/agents/attachment-picker.ts index 53c9dbd600..c45f16fc7a 100644 --- a/apps/mobile/src/components/agents/attachment-picker.ts +++ b/apps/mobile/src/components/agents/attachment-picker.ts @@ -150,7 +150,13 @@ export function pickAgentAttachments( } catch (error) { // A store write failure must not block the picker launch; the // recovery hook simply finds no context and nothing is attached. - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + 'error.subsystem': 'agent-attachments', + 'error.operation': 'write-picker-launch-context', + }, + extra: { source, surface: context.surface, hasSession: context.sessionId !== null }, + }); } } const result = await pickFromSource(source); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index aa74bbc8b7..efb7ae1e2e 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -40,7 +40,13 @@ export function useMarkRead(client: KiloChatClient) { // toast for a background failure is noise. Retry happens naturally on the // next mark-read trigger; just log so we can see failure rates. onError: error => { - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + 'error.subsystem': 'kilo-chat', + 'error.operation': 'mark-conversation-read', + }, + extra: { hasUser: userId !== null }, + }); }, onMutate: () => ({ startBadgeFreshnessEpoch: advanceBadgeFreshnessEpoch() }), onSuccess: (result, _variables, context) => { diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx index 1d50edf5cc..6248451df4 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx @@ -135,7 +135,13 @@ describe('IdentityStep GPS error reporting', () => { await vi.waitFor(() => { expect(Sentry.captureException).toHaveBeenCalledTimes(1); }); - expect(vi.mocked(Sentry.captureException).mock.calls[0]?.[0]).toBe(validateError); + expect(Sentry.captureException).toHaveBeenCalledWith(validateError, { + tags: { + 'error.subsystem': 'kiloclaw-onboarding', + 'error.operation': 'validate-gps-location', + }, + extra: { coordinatePrecision: 2 }, + }); }); it.each(['timeout', 'Location request failed due to unsatisfied device settings'])( diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx index 846e414cfd..47ca1ff4a7 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx @@ -198,7 +198,13 @@ export function IdentityStep({ setLocationFeedback({ message: result.currentWeatherText, status: result.status }); setValidatedLocation(result.location); } catch (validateError) { - Sentry.captureException(validateError); + Sentry.captureException(validateError, { + tags: { + 'error.subsystem': 'kiloclaw-onboarding', + 'error.operation': 'validate-gps-location', + }, + extra: { coordinatePrecision: GPS_COORDINATE_PRECISION }, + }); applyLocationText(coords); setValidatedLocation(null); setLocationFeedback({ diff --git a/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts b/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts index 3fff035ac7..3d2158f10d 100644 --- a/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts +++ b/apps/mobile/src/lib/agent-attachments/strip-image-metadata.test.ts @@ -52,6 +52,13 @@ describe('stripImageMetadata', () => { const result = await stripImageMetadata('file:///cache/original.png', 'png'); expect(result).toBe('file:///cache/original.png'); - expect(mocks.captureException).toHaveBeenCalledTimes(1); + expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error), { + tags: { + 'error.subsystem': 'agent-attachments', + 'error.operation': 'strip-image-metadata', + }, + extra: { outputExtension: 'png' }, + fingerprint: ['agent-attachments-strip-image-metadata'], + }); }); }); diff --git a/apps/mobile/src/lib/agent-attachments/strip-image-metadata.ts b/apps/mobile/src/lib/agent-attachments/strip-image-metadata.ts index dc295350c9..2405ad845f 100644 --- a/apps/mobile/src/lib/agent-attachments/strip-image-metadata.ts +++ b/apps/mobile/src/lib/agent-attachments/strip-image-metadata.ts @@ -43,8 +43,15 @@ export async function stripImageMetadata( format: saveFormatFor(extension), }); return result.uri; - } catch (error) { - Sentry.captureException(error); + } catch { + Sentry.captureException(new Error('Attachment image metadata strip failed'), { + tags: { + 'error.subsystem': 'agent-attachments', + 'error.operation': 'strip-image-metadata', + }, + extra: { outputExtension: strippedExtension(extension) }, + fingerprint: ['agent-attachments-strip-image-metadata'], + }); return uri; } } diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts index eee4dea506..193cb8381f 100644 --- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts +++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts @@ -34,12 +34,13 @@ const hoisted = vi.hoisted(() => { measureLocalSize: vi.fn(), cancelAsync: vi.fn(), fileDelete: vi.fn(), + captureException: vi.fn(), deletedUris: new Set(), }; }); vi.mock('expo-crypto', () => ({ randomUUID: hoisted.randomUUID })); -vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() })); +vi.mock('@sentry/react-native', () => ({ captureException: hoisted.captureException })); vi.mock('expo-file-system/legacy', () => ({ deleteAsync: vi.fn() })); vi.mock('expo-image-manipulator', () => ({ SaveFormat: { PNG: 'png', WEBP: 'webp', JPEG: 'jpeg' }, @@ -491,6 +492,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () => hoisted.measureLocalSize.mockReset(); hoisted.cancelAsync.mockReset(); hoisted.fileDelete.mockReset(); + hoisted.captureException.mockReset(); hoisted.deletedUris.clear(); hoisted.measureLocalSize.mockResolvedValue(1024); resolveUpload = undefined; @@ -643,6 +645,33 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () => renderer.unmount(); }); + it('reports a cache file delete failure with safe context', async () => { + const renderer = await mountHook(); + await addDocument(); + const id = hookApi().attachments[0]?.id; + if (!id) { + throw new Error('attachment id missing'); + } + hoisted.fileDelete.mockImplementationOnce(() => { + throw new Error('delete failed'); + }); + + await act(async () => { + hookApi().removeAttachment(id); + await settle(); + }); + + expect(hoisted.captureException).toHaveBeenCalledWith(expect.any(Error), { + tags: { + 'error.subsystem': 'agent-attachments', + 'error.operation': 'delete-cache-file', + }, + extra: { cacheOwned: true }, + fingerprint: ['agent-attachments-delete-cache-file'], + }); + renderer.unmount(); + }); + it('never announces or updates state when the composer is reset before the outcome', async () => { const renderer = await mountHook(); await addDocument(); diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts index 1e95cf3a83..3a25d8f0fc 100644 --- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts +++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts @@ -61,8 +61,15 @@ function deleteCacheOwnedFile(localUri: string): void { if (file.exists) { file.delete(); } - } catch (error) { - Sentry.captureException(error); + } catch { + Sentry.captureException(new Error('Attachment cache file delete failed'), { + tags: { + 'error.subsystem': 'agent-attachments', + 'error.operation': 'delete-cache-file', + }, + extra: { cacheOwned: true }, + fingerprint: ['agent-attachments-delete-cache-file'], + }); } } diff --git a/apps/mobile/src/lib/appsflyer.test.ts b/apps/mobile/src/lib/appsflyer.test.ts index 4bfca283ff..5a95c469b4 100644 --- a/apps/mobile/src/lib/appsflyer.test.ts +++ b/apps/mobile/src/lib/appsflyer.test.ts @@ -196,10 +196,17 @@ describe('initAppsFlyer purchase connector', () => { expect(Sentry.captureException).toHaveBeenCalledTimes(1); }); - const captured = vi.mocked(Sentry.captureException).mock.calls[0]?.[0]; - expect(captured).toBeInstanceOf(Error); - expect((captured as Error).message).toContain('AppsFlyer purchase connector failed'); - expect((captured as Error).message).toContain('native bridge down'); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'AppsFlyer create-purchase-connector failed' }), + { + tags: { + 'error.subsystem': 'appsflyer', + 'error.operation': 'create-purchase-connector', + }, + extra: { platform: 'ios' }, + fingerprint: ['appsflyer', 'create-purchase-connector'], + } + ); }); }); @@ -264,9 +271,14 @@ describe('AppsFlyer event reporting', () => { initAppsFlyer(); expect(Sentry.captureException).toHaveBeenCalledTimes(1); - const captured = vi.mocked(Sentry.captureException).mock.calls[0]?.[0]; - expect((captured as Error).message).toContain('AppsFlyer init failed'); - expect((captured as Error).message).toContain('Invalid dev key'); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'AppsFlyer init-sdk failed' }), + { + tags: { 'error.subsystem': 'appsflyer', 'error.operation': 'init-sdk' }, + extra: { platform: 'ios' }, + fingerprint: ['appsflyer', 'init-sdk'], + } + ); }); }); diff --git a/apps/mobile/src/lib/appsflyer.ts b/apps/mobile/src/lib/appsflyer.ts index 758cf59fe1..d0a418aabd 100644 --- a/apps/mobile/src/lib/appsflyer.ts +++ b/apps/mobile/src/lib/appsflyer.ts @@ -32,9 +32,16 @@ const pendingEvents: PendingEvent[] = []; const CONNECTOR_ALREADY_CONFIGURED = 'Connector already configured'; -function handleError(message: string) { - return (details: unknown) => { - Sentry.captureException(new Error(`${message}: ${String(details)}`)); +function handleError(operation: 'init-sdk' | 'create-purchase-connector') { + return (_details: unknown) => { + Sentry.captureException(new Error(`AppsFlyer ${operation} failed`), { + tags: { + 'error.subsystem': 'appsflyer', + 'error.operation': operation, + }, + extra: { platform: Platform.OS }, + fingerprint: ['appsflyer', operation], + }); }; } @@ -102,7 +109,7 @@ async function createPurchaseConnector(): Promise { if (isConnectorAlreadyConfigured(error)) { return true; } - handleError('AppsFlyer purchase connector failed')(error); + handleError('create-purchase-connector')(error); return false; } } @@ -199,7 +206,7 @@ export function initAppsFlyer(): void { }); drainPendingEvents(); }, - handleError('AppsFlyer init failed') + handleError('init-sdk') ); } diff --git a/apps/mobile/src/lib/auth/auth-context.test.ts b/apps/mobile/src/lib/auth/auth-context.test.ts index 5768763cc5..b31536ebbd 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.ts +++ b/apps/mobile/src/lib/auth/auth-context.test.ts @@ -50,7 +50,7 @@ vi.mock('@/lib/analytics/posthog', () => ({ })); vi.mock('@/lib/appsflyer', () => ({ resetAppsFlyerState: vi.fn(), trackEvent: vi.fn() })); -vi.mock('@sentry/react-native', () => ({ setUser: vi.fn() })); +vi.mock('@sentry/react-native', () => ({ setUser: vi.fn(), setTag: vi.fn() })); vi.mock('@/lib/telemetry/controller', () => ({ clearTelemetryDecision: vi.fn() })); vi.mock('@/lib/telemetry/posthog-storage', () => ({ purgePostHogPersistence: vi.fn() })); // sonner-native pulls in react-native at runtime, whose Flow-only `import diff --git a/apps/mobile/src/lib/auth/auth-context.test.tsx b/apps/mobile/src/lib/auth/auth-context.test.tsx index 0957e8a3cd..9f67ed1c21 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.test.tsx @@ -53,6 +53,7 @@ const hoisted = vi.hoisted(() => { setUser: vi.fn().mockImplementation(() => { callOrder.push('Sentry.setUser'); }), + setTag: vi.fn(), }; // Hoisted so the foreground tests can capture AppState listeners from the @@ -105,6 +106,7 @@ vi.mock('expo-secure-store', () => ({ vi.mock('@sentry/react-native', () => ({ setUser: hoisted.sentry.setUser, + setTag: hoisted.sentry.setTag, })); vi.mock('@/lib/analytics/posthog', () => ({ diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index b97c829c59..698116df0a 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -1,5 +1,4 @@ import * as SecureStore from 'expo-secure-store'; -import * as Sentry from '@sentry/react-native'; import { z } from 'zod'; import { createContext, @@ -63,6 +62,7 @@ import { TOKEN_EXPIRES_AT_KEY, } from '@/lib/storage-keys'; import { clearTelemetryDecision } from '@/lib/telemetry/controller'; +import { clearSentryUser } from '@/lib/sentry-context'; import { purgePostHogPersistence } from '@/lib/telemetry/posthog-storage'; import { AppState } from 'react-native'; @@ -266,7 +266,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { // teardown. gateKiloClawOwned(); clearTelemetryDecision(); - Sentry.setUser(null); + clearSentryUser(); // SDK teardown — drop queues, do not flush them. Must happen before // any SecureStore or cache awaits so optional analytics cannot // transmit during the teardown window. Each step is individually diff --git a/apps/mobile/src/lib/auth/credentials.test.ts b/apps/mobile/src/lib/auth/credentials.test.ts index 5a6e3769dd..b8a572b75c 100644 --- a/apps/mobile/src/lib/auth/credentials.test.ts +++ b/apps/mobile/src/lib/auth/credentials.test.ts @@ -32,7 +32,11 @@ vi.mock('@/lib/config', () => ({ API_BASE_URL: 'https://api.example.com' })); // The sign-out deletes live in auth-context.tsx; mounting it pulls in the full // teardown graph, so stub every side-effecting collaborator. -vi.mock('@sentry/react-native', () => ({ setUser: vi.fn(), captureException: vi.fn() })); +vi.mock('@sentry/react-native', () => ({ + setUser: vi.fn(), + setTag: vi.fn(), + captureException: vi.fn(), +})); vi.mock('@/lib/analytics/posthog', () => ({ discardPostHog: vi.fn().mockResolvedValue(undefined), })); diff --git a/apps/mobile/src/lib/auth/logout-cleanup.test.ts b/apps/mobile/src/lib/auth/logout-cleanup.test.ts index fd38780965..2c5b9fe7f0 100644 --- a/apps/mobile/src/lib/auth/logout-cleanup.test.ts +++ b/apps/mobile/src/lib/auth/logout-cleanup.test.ts @@ -192,7 +192,9 @@ describe('runLogoutCleanup', () => { vi.mocked(setItemAsync).mockRejectedValueOnce(new Error('secure store down')); await expect(runLogoutCleanup()).resolves.toBeUndefined(); - expect(captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(captureException).toHaveBeenCalledWith(expect.any(Error), { + tags: { 'error.subsystem': 'auth', 'error.operation': 'write_logout_tombstone' }, + }); }); it('never throws when the push outcome lookup itself throws', async () => { diff --git a/apps/mobile/src/lib/auth/logout-cleanup.ts b/apps/mobile/src/lib/auth/logout-cleanup.ts index 9987ed3525..edef6541d9 100644 --- a/apps/mobile/src/lib/auth/logout-cleanup.ts +++ b/apps/mobile/src/lib/auth/logout-cleanup.ts @@ -131,11 +131,15 @@ export async function runLogoutCleanup(): Promise { } catch (error) { // A tombstone write failure is reported but never blocks logout: the // push row stays removable on the next successful unregister. - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { 'error.subsystem': 'auth', 'error.operation': 'write_logout_tombstone' }, + }); } } catch (error) { // Never throw by contract: an unexpected failure anywhere in the gather // phase must not abort sign-out. - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { 'error.subsystem': 'auth', 'error.operation': 'run_logout_cleanup' }, + }); } } diff --git a/apps/mobile/src/lib/auth/pending-external-auth.test.ts b/apps/mobile/src/lib/auth/pending-external-auth.test.ts index 4b34fc4f09..ac18d092a8 100644 --- a/apps/mobile/src/lib/auth/pending-external-auth.test.ts +++ b/apps/mobile/src/lib/auth/pending-external-auth.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as SecureStore from 'expo-secure-store'; +import * as Sentry from '@sentry/react-native'; import { _resetPendingExternalAuthForTests, @@ -31,6 +32,7 @@ beforeEach(() => { vi.mocked(SecureStore.getItemAsync).mockReset(); vi.mocked(SecureStore.setItemAsync).mockReset(); vi.mocked(SecureStore.deleteItemAsync).mockReset(); + vi.mocked(Sentry.captureException).mockReset(); _resetPendingExternalAuthForTests(); }); @@ -77,6 +79,17 @@ describe('pending-external-auth', () => { expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('pending-external-auth'); }); + it('reports a write failure with safe fixed context and native grouping', async () => { + const error = new Error('device-secret'); + vi.mocked(SecureStore.setItemAsync).mockRejectedValueOnce(error); + + await writePendingExternalAuth(record); + + expect(Sentry.captureException).toHaveBeenCalledWith(error, { + tags: { 'error.subsystem': 'auth', 'error.operation': 'write_pending_external_auth' }, + }); + }); + it('serializes a write then a clear in FIFO order', async () => { const setGate = deferred(); vi.mocked(SecureStore.setItemAsync).mockReturnValue(setGate.promise); diff --git a/apps/mobile/src/lib/auth/pending-external-auth.ts b/apps/mobile/src/lib/auth/pending-external-auth.ts index f3aa76960a..177be2e970 100644 --- a/apps/mobile/src/lib/auth/pending-external-auth.ts +++ b/apps/mobile/src/lib/auth/pending-external-auth.ts @@ -32,7 +32,9 @@ async function runWriteAfter( try { await write(); } catch (error: unknown) { - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { 'error.subsystem': 'auth', 'error.operation': 'write_pending_external_auth' }, + }); } } diff --git a/apps/mobile/src/lib/auth/use-native-auth.test.ts b/apps/mobile/src/lib/auth/use-native-auth.test.ts index 84e46a7f42..473ff5075d 100644 --- a/apps/mobile/src/lib/auth/use-native-auth.test.ts +++ b/apps/mobile/src/lib/auth/use-native-auth.test.ts @@ -90,11 +90,8 @@ vi.mock('@/lib/auth/admission', async importOriginal => { }; }); -// useSsoRecovery reports the organization id to Sentry; stub the SDK so the -// hook can be mounted without a native runtime. -vi.mock('@sentry/react-native', () => ({ - addBreadcrumb: vi.fn(), -})); +const sentryMock = vi.hoisted(() => ({ addBreadcrumb: vi.fn() })); +vi.mock('@sentry/react-native', () => sentryMock); // postAuth is the single fetch boundary for native auth; stub it so the SSO // recovery path can be driven without a network. @@ -379,6 +376,7 @@ describe('useNativeAuth SSO recovery', () => { email: 'user@example.com', ssoOrganizationId: 'org_1', }); + expect(sentryMock.addBreadcrumb).not.toHaveBeenCalled(); // A new attempt clears the recovery state before posting. mockPostAuth.mockResolvedValue({ ok: true, data: { success: true } }); diff --git a/apps/mobile/src/lib/auth/use-sso-recovery.ts b/apps/mobile/src/lib/auth/use-sso-recovery.ts index 5ecd93ef49..6e6e24d0a0 100644 --- a/apps/mobile/src/lib/auth/use-sso-recovery.ts +++ b/apps/mobile/src/lib/auth/use-sso-recovery.ts @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react-native'; import { useCallback, useState } from 'react'; export type SsoRecovery = { email: string; ssoOrganizationId: string | undefined }; @@ -10,20 +9,8 @@ export function useSsoRecovery() { setSsoRecovery(null); }, []); - // The organization id is reported to Sentry as a breadcrumb tag only. It is - // deliberately NOT put in any URL — the web SSO page resolves the organization - // from the email itself. const handleSsoError = useCallback((email: string, ssoOrganizationId: string | undefined) => { setSsoRecovery({ email, ssoOrganizationId }); - if (!ssoOrganizationId) { - return; - } - Sentry.addBreadcrumb({ - category: 'auth', - level: 'info', - message: 'SSO recovery', - data: { ssoOrganizationId }, - }); }, []); return { ssoRecovery, clearSsoRecovery, handleSsoError }; diff --git a/apps/mobile/src/lib/deep-link-launch.test.ts b/apps/mobile/src/lib/deep-link-launch.test.ts index 206141077f..5ec04714e5 100644 --- a/apps/mobile/src/lib/deep-link-launch.test.ts +++ b/apps/mobile/src/lib/deep-link-launch.test.ts @@ -162,7 +162,9 @@ describe('deep-link-launch', () => { setPendingDeepLink('/(app)/(tabs)/(3_profile)', 'notification'); expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)'); await vi.waitFor(() => { - expect(Sentry.captureException).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), { + tags: { 'error.subsystem': 'deep_link', 'error.operation': 'write_pending_link' }, + }); }); }); }); diff --git a/apps/mobile/src/lib/deep-link-launch.ts b/apps/mobile/src/lib/deep-link-launch.ts index f6dc44fcdb..4f02ecbbdd 100644 --- a/apps/mobile/src/lib/deep-link-launch.ts +++ b/apps/mobile/src/lib/deep-link-launch.ts @@ -94,7 +94,9 @@ function enqueuePendingDeepLinkWrite(write: () => Promise): void { await previous; await write(); } catch (error) { - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { 'error.subsystem': 'deep_link', 'error.operation': 'write_pending_link' }, + }); } })(); } @@ -235,7 +237,9 @@ async function readPersistedPendingDeepLink(): Promise { try { return await getSecureStore().getItemAsync(PENDING_DEEP_LINK_KEY); } catch (error) { - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { 'error.subsystem': 'deep_link', 'error.operation': 'read_pending_link' }, + }); return null; } } diff --git a/apps/mobile/src/lib/hooks/secure-store-preference.test.ts b/apps/mobile/src/lib/hooks/secure-store-preference.test.ts index 89251dd01a..8332f9c110 100644 --- a/apps/mobile/src/lib/hooks/secure-store-preference.test.ts +++ b/apps/mobile/src/lib/hooks/secure-store-preference.test.ts @@ -48,7 +48,9 @@ describe('createSecureStorePreference', () => { expect(store.get()).toBe(false); expect(store.getHasLoaded()).toBe(true); - expect(captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(captureException).toHaveBeenCalledWith(expect.any(Error), { + tags: { 'error.subsystem': 'preferences', 'error.operation': 'load_secure_store' }, + }); expect(toastError).not.toHaveBeenCalled(); unsubscribe(); }); diff --git a/apps/mobile/src/lib/hooks/secure-store-preference.ts b/apps/mobile/src/lib/hooks/secure-store-preference.ts index d7c1267220..34079cd2ff 100644 --- a/apps/mobile/src/lib/hooks/secure-store-preference.ts +++ b/apps/mobile/src/lib/hooks/secure-store-preference.ts @@ -40,7 +40,9 @@ export function createSecureStorePreference(options: { // Keep the default on read failure — this runs on mount, before the // user has done anything, so there's nothing actionable to tell them. // Just log so we can see failure rates. - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { 'error.subsystem': 'preferences', 'error.operation': 'load_secure_store' }, + }); } finally { hasLoaded = true; emit(); diff --git a/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.test.ts b/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.test.ts index 3a29e0c648..196281bbe5 100644 --- a/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.test.ts +++ b/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.test.ts @@ -194,7 +194,12 @@ describe('useTrackingPermissionPrompt', () => { await Promise.resolve(); }); - expect(captureException).toHaveBeenCalledWith(requestError); + expect(captureException).toHaveBeenCalledWith(requestError, { + tags: { + 'error.subsystem': 'tracking_permission', + 'error.operation': 'request_permission', + }, + }); renderer.unmount(); }); @@ -210,7 +215,9 @@ describe('useTrackingPermissionPrompt', () => { expect(getTrackingPermissionsAsync).toHaveBeenCalledOnce(); expect(alertMock).not.toHaveBeenCalled(); - expect(captureException).toHaveBeenCalledWith(checkError); + expect(captureException).toHaveBeenCalledWith(checkError, { + tags: { 'error.subsystem': 'tracking_permission', 'error.operation': 'get_permission' }, + }); renderer.unmount(); }); diff --git a/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.ts b/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.ts index 9ff16a64f1..9e67d718e8 100644 --- a/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.ts +++ b/apps/mobile/src/lib/hooks/use-tracking-permission-prompt.ts @@ -26,7 +26,12 @@ export function useTrackingPermissionPrompt(enabled: boolean): void { if (cancelled) { return; } - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + 'error.subsystem': 'tracking_permission', + 'error.operation': 'get_permission', + }, + }); return; } @@ -46,7 +51,12 @@ export function useTrackingPermissionPrompt(enabled: boolean): void { try { await requestTrackingPermissionsAsync(); } catch (error) { - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + 'error.subsystem': 'tracking_permission', + 'error.operation': 'request_permission', + }, + }); } })(); }, diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index 25ce2f4e1b..e72d1efd44 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -145,7 +145,13 @@ describe('ensureAndroidNotificationChannels', () => { await expect(ensureAndroidNotificationChannels()).resolves.toBeUndefined(); expect(mocks.setNotificationChannelAsync).toHaveBeenCalledTimes(3); - expect(mocks.captureException).toHaveBeenCalledTimes(1); + expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error), { + tags: { + 'error.subsystem': 'notifications', + 'error.operation': 'create_android_channel', + 'notification.channel': 'agent', + }, + }); }); }); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 0a7f11ac46..d7e012886f 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -125,7 +125,13 @@ async function createAndroidNotificationChannels(): Promise { : Notifications.AndroidImportance.DEFAULT, }); } catch (error) { - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + 'error.subsystem': 'notifications', + 'error.operation': 'create_android_channel', + 'notification.channel': channel.id, + }, + }); } } } diff --git a/apps/mobile/src/lib/persist/drafts.test.ts b/apps/mobile/src/lib/persist/drafts.test.ts index 9eb37730cf..c2ee3e8e09 100644 --- a/apps/mobile/src/lib/persist/drafts.test.ts +++ b/apps/mobile/src/lib/persist/drafts.test.ts @@ -417,7 +417,7 @@ describe('unsupported serialization boundary', () => { expect(Sentry.captureException).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ - extra: expect.objectContaining({ scope: 'draft:u1', entityKey: 'k' }), + tags: { 'error.subsystem': 'drafts', 'error.operation': 'write' }, }) ); await vi.advanceTimersByTimeAsync(DRAFT_DEBOUNCE_MS * 2); @@ -432,7 +432,8 @@ describe('unsupported serialization boundary', () => { expect(Sentry.captureException).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ - extra: expect.objectContaining({ scope: 'draft:u1', entityKey: 'k' }), + tags: { 'error.subsystem': 'drafts', 'error.operation': 'write' }, + fingerprint: ['draft-write-unsupported-value'], }) ); await vi.advanceTimersByTimeAsync(DRAFT_DEBOUNCE_MS * 2); @@ -456,15 +457,13 @@ describe('corrupt read', () => { expect(Sentry.captureException).toHaveBeenCalledTimes(1); }); - it('reports the scope and entity key to Sentry', async () => { + it('reports safe fixed context without overriding native error grouping', async () => { seedStoredValue('draft:u1', 'k', 'not-json{{{'); await loadDraft('u1', 'k', isStringDraft); - expect(Sentry.captureException).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ - extra: expect.objectContaining({ scope: 'draft:u1', entityKey: 'k' }), - }) - ); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), { + level: 'warning', + tags: { 'error.subsystem': 'drafts', 'error.operation': 'read' }, + }); }); }); @@ -491,15 +490,14 @@ describe('shape validation', () => { expect(Sentry.captureException).toHaveBeenCalledTimes(1); }); - it('reports the scope and entity key for a shape mismatch', async () => { + it('uses a fixed fingerprint for a recognized shape mismatch', async () => { seedStoredValue('draft:u1', 'k', '{}'); await loadDraft('u1', 'k', isStringDraft); - expect(Sentry.captureException).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ - extra: expect.objectContaining({ scope: 'draft:u1', entityKey: 'k' }), - }) - ); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), { + level: 'warning', + tags: { 'error.subsystem': 'drafts', 'error.operation': 'read' }, + fingerprint: ['draft-read-shape-mismatch'], + }); }); }); @@ -512,7 +510,7 @@ describe('write rejection boundary', () => { expect(Sentry.captureException).toHaveBeenCalledWith( expect.any(Error), expect.objectContaining({ - extra: expect.objectContaining({ scope: 'draft:u1', entityKey: 'k' }), + tags: { 'error.subsystem': 'drafts', 'error.operation': 'write' }, }) ); }); diff --git a/apps/mobile/src/lib/persist/drafts.ts b/apps/mobile/src/lib/persist/drafts.ts index acfb8992e6..f2b22e085f 100644 --- a/apps/mobile/src/lib/persist/drafts.ts +++ b/apps/mobile/src/lib/persist/drafts.ts @@ -156,18 +156,15 @@ function fullKey(userId: string, entityKey: string): string { return `${draftScope(userId)}\u0000${entityKey}`; } -const DRAFT_READ_DISCARDED = 'draft read discarded'; -const DRAFT_WRITE_FAILED = 'draft write failed'; - -function reportDraftFailure(report: { - message: string; - reason: string; - userId: string; - entityKey: string; -}): void { - Sentry.captureException(new Error(report.message), { +function reportDraftFailure( + error: unknown, + operation: 'read' | 'write' | 'clear', + fingerprint?: string +): void { + Sentry.captureException(error, { level: 'warning', - extra: { scope: draftScope(report.userId), entityKey: report.entityKey, reason: report.reason }, + tags: { 'error.subsystem': 'drafts', 'error.operation': operation }, + ...(fingerprint ? { fingerprint: [fingerprint] } : {}), }); } @@ -193,32 +190,25 @@ export async function loadDraft( return null; } if (utf8ByteLength(raw) > DRAFT_MAX_BYTES) { - reportDraftFailure({ - message: 'stored draft exceeds the 64 KB cap', - reason: DRAFT_READ_DISCARDED, - userId, - entityKey, - }); + reportDraftFailure( + new Error('stored draft exceeds the 64 KB cap'), + 'read', + 'draft-read-size-limit' + ); return null; } const parsed: unknown = JSON.parse(raw); if (!isValid(parsed)) { - reportDraftFailure({ - message: 'stored draft does not match its expected shape', - reason: DRAFT_READ_DISCARDED, - userId, - entityKey, - }); + reportDraftFailure( + new Error('stored draft does not match its expected shape'), + 'read', + 'draft-read-shape-mismatch' + ); return null; } return parsed; } catch (error) { - reportDraftFailure({ - message: error instanceof Error ? error.message : 'stored draft is not valid JSON', - reason: DRAFT_READ_DISCARDED, - userId, - entityKey, - }); + reportDraftFailure(error, 'read'); return null; } } @@ -252,12 +242,7 @@ async function writeDraftSafely(payload: DraftWritePayload): Promise { try { await writeDraft(payload); } catch (error) { - reportDraftFailure({ - message: error instanceof Error ? error.message : DRAFT_WRITE_FAILED, - reason: DRAFT_WRITE_FAILED, - userId: payload.userId, - entityKey: payload.entityKey, - }); + reportDraftFailure(error, 'write'); } } @@ -293,12 +278,11 @@ export function saveDraft(userId: string, entityKey: string, value: unknown): vo // or symbol; reject those before the byte-cap check, which needs a string. const serialized = JSON.stringify(value) as string | undefined; if (serialized === undefined) { - reportDraftFailure({ - message: 'draft value cannot be serialized to JSON', - reason: DRAFT_WRITE_FAILED, - userId, - entityKey, - }); + reportDraftFailure( + new Error('draft value cannot be serialized to JSON'), + 'write', + 'draft-write-unsupported-value' + ); return; } if (utf8ByteLength(serialized) > DRAFT_MAX_BYTES) { @@ -318,12 +302,7 @@ export function saveDraft(userId: string, entityKey: string, value: unknown): vo } catch (error) { // Serialization and byte sizing run before any timer exists; contain // every failure here so saveDraft never throws synchronously. - reportDraftFailure({ - message: error instanceof Error ? error.message : DRAFT_WRITE_FAILED, - reason: DRAFT_WRITE_FAILED, - userId, - entityKey, - }); + reportDraftFailure(error, 'write'); } } @@ -372,12 +351,7 @@ export async function clearDraft(userId: string, entityKey: string): Promise { await expect(clearScope('s')).rejects.toThrow(MissingSQLCipherError); expect(SQLite.openDatabaseSync).toHaveBeenCalledTimes(1); expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(MissingSQLCipherError), { + level: 'error', + tags: { 'error.subsystem': 'encrypted-kv', 'error.operation': 'open' }, + fingerprint: ['encrypted-kv-missing-sqlcipher'], + }); }); }); @@ -370,6 +375,10 @@ describe('open failure recovery', () => { expect(Crypto.getRandomBytesAsync).toHaveBeenCalledTimes(2); expect(store.get(PERSIST_DB_KEY)).toMatch(/^02[0-9a-f]{62}$/); expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), { + level: 'warning', + tags: { 'error.subsystem': 'encrypted-kv', 'error.operation': 'reset' }, + }); // The store works on the recovered database. await expect(getItem('s', 'a')).resolves.toBe('x'); diff --git a/apps/mobile/src/lib/persist/encrypted-kv.ts b/apps/mobile/src/lib/persist/encrypted-kv.ts index 74e0182606..05083b1800 100644 --- a/apps/mobile/src/lib/persist/encrypted-kv.ts +++ b/apps/mobile/src/lib/persist/encrypted-kv.ts @@ -183,7 +183,8 @@ async function openEncryptedDatabase(): Promise { // existing file may hold the user's drafts. Fail loud, touch nothing. Sentry.captureException(openError, { level: 'error', - extra: { database: DATABASE_NAME, reason: 'encrypted-kv build has no SQLCipher' }, + tags: { 'error.subsystem': 'encrypted-kv', 'error.operation': 'open' }, + fingerprint: ['encrypted-kv-missing-sqlcipher'], }); throw openError; } @@ -201,7 +202,7 @@ async function openEncryptedDatabase(): Promise { await probeAndMigrate(reopened); Sentry.captureException(openError, { level: 'warning', - extra: { database: DATABASE_NAME, reason: 'encrypted-kv reset after failed probe' }, + tags: { 'error.subsystem': 'encrypted-kv', 'error.operation': 'reset' }, }); return reopened; } catch (resetError) { @@ -215,7 +216,7 @@ async function openEncryptedDatabase(): Promise { } Sentry.captureException(resetError, { level: 'error', - extra: { database: DATABASE_NAME, reason: 'encrypted-kv reset failed' }, + tags: { 'error.subsystem': 'encrypted-kv', 'error.operation': 'reset' }, }); throw resetError; } diff --git a/apps/mobile/src/lib/persist/mutation-outbox.test.ts b/apps/mobile/src/lib/persist/mutation-outbox.test.ts index a1a48e785c..4c17ccc1b5 100644 --- a/apps/mobile/src/lib/persist/mutation-outbox.test.ts +++ b/apps/mobile/src/lib/persist/mutation-outbox.test.ts @@ -221,6 +221,11 @@ describe('corrupt read', () => { seedStoredValue('outbox:u1', 'fp', '{"taxonomy":"safe-retry","operationKey":"k"}'); await expect(loadOutboxRow('u1', 'fp')).resolves.toBeNull(); expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), { + level: 'warning', + tags: { 'error.subsystem': 'mutation-outbox', 'error.operation': 'read' }, + fingerprint: ['outbox-read-shape-mismatch'], + }); }); }); @@ -229,6 +234,10 @@ describe('write rejection boundary', () => { kvMock.setItem.mockRejectedValueOnce(new Error('kv down')); await expect(writeOutboxRow('u1', safeRetryRow())).resolves.toBeUndefined(); expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error), { + level: 'warning', + tags: { 'error.subsystem': 'mutation-outbox', 'error.operation': 'write' }, + }); }); it('reports and swallows a failed remove', async () => { diff --git a/apps/mobile/src/lib/persist/mutation-outbox.ts b/apps/mobile/src/lib/persist/mutation-outbox.ts index 87b76c0131..ec052d12ce 100644 --- a/apps/mobile/src/lib/persist/mutation-outbox.ts +++ b/apps/mobile/src/lib/persist/mutation-outbox.ts @@ -68,22 +68,15 @@ function fullKey(userId: string, fingerprint: string): string { return `${outboxScope(userId)}\u0000${fingerprint}`; } -const OUTBOX_READ_DISCARDED = 'outbox read discarded'; -const OUTBOX_WRITE_FAILED = 'outbox write failed'; - -function reportOutboxFailure(report: { - message: string; - reason: string; - userId: string; - fingerprint: string; -}): void { - Sentry.captureException(new Error(report.message), { +function reportOutboxFailure( + error: unknown, + operation: 'read' | 'write' | 'remove' | 'list', + fingerprint?: string +): void { + Sentry.captureException(error, { level: 'warning', - extra: { - scope: outboxScope(report.userId), - fingerprint: report.fingerprint, - reason: report.reason, - }, + tags: { 'error.subsystem': 'mutation-outbox', 'error.operation': operation }, + ...(fingerprint ? { fingerprint: [fingerprint] } : {}), }); } @@ -99,12 +92,11 @@ export async function writeOutboxRow(userId: string, row: OutboxRow): Promise parseOutboxRow(raw)) .filter((row): row is OutboxRow => row !== null); } catch (error) { - reportOutboxFailure({ - message: error instanceof Error ? error.message : OUTBOX_WRITE_FAILED, - reason: OUTBOX_WRITE_FAILED, - userId, - fingerprint: '', - }); + reportOutboxFailure(error, 'list'); return null; } } diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx index e3805bb92e..6aebc5936d 100644 --- a/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx +++ b/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx @@ -196,7 +196,12 @@ describe('PendingReviewProvider persistence', () => { await flushMicrotasks(); expect(latest(renders).items).toEqual([ITEM_A, ITEM_B]); - expect(sentryMock.captureException).toHaveBeenCalledTimes(1); + expect(sentryMock.captureException).toHaveBeenCalledWith(expect.any(Error), { + level: 'warning', + tags: { 'error.subsystem': 'pending-review', 'error.operation': 'hydrate' }, + fingerprint: ['pending-review-invalid-items'], + extra: { dropped: 3, kept: 2 }, + }); }); it('keeps the queue empty when every restored item is malformed', async () => { diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx index e39c57966e..6e61d5e033 100644 --- a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx +++ b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx @@ -60,6 +60,8 @@ function keepValidPendingReviewItems(restored: unknown[]): PendingReviewItem[] { if (valid.length !== restored.length) { Sentry.captureException(new Error('stored pending review dropped invalid items'), { level: 'warning', + tags: { 'error.subsystem': 'pending-review', 'error.operation': 'hydrate' }, + fingerprint: ['pending-review-invalid-items'], extra: { dropped: restored.length - valid.length, kept: valid.length }, }); } diff --git a/apps/mobile/src/lib/sentry-context.test.ts b/apps/mobile/src/lib/sentry-context.test.ts new file mode 100644 index 0000000000..63808681dc --- /dev/null +++ b/apps/mobile/src/lib/sentry-context.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const setUser = vi.hoisted(() => vi.fn()); +const setTag = vi.hoisted(() => vi.fn()); + +vi.mock('@sentry/react-native', () => ({ setUser, setTag })); + +describe('Sentry context', () => { + beforeEach(() => { + vi.resetModules(); + setUser.mockReset(); + setTag.mockReset(); + }); + + it('sets the app user id and low-cardinality global tags', async () => { + const { setSentryContext } = await import('./sentry-context'); + + setSentryContext({ + userId: 'user-123', + authState: 'signed_in', + telemetryMode: 'optional', + }); + + expect(setUser).toHaveBeenCalledWith({ id: 'user-123' }); + expect(setTag).toHaveBeenCalledWith('app.auth_state', 'signed_in'); + expect(setTag).toHaveBeenCalledWith('app.telemetry_mode', 'optional'); + }); + + it('reapplies the current identity and tags after an SDK reinitialization', async () => { + const { applySentryContext, setSentryContext } = await import('./sentry-context'); + setSentryContext({ + userId: 'user-123', + authState: 'signed_in', + telemetryMode: 'optional', + }); + setUser.mockClear(); + setTag.mockClear(); + + applySentryContext(); + + expect(setUser).toHaveBeenCalledWith({ id: 'user-123' }); + expect(setTag).toHaveBeenCalledWith('app.auth_state', 'signed_in'); + expect(setTag).toHaveBeenCalledWith('app.telemetry_mode', 'optional'); + }); + + it('clears identity and marks the user signed out', async () => { + const { clearSentryUser, setSentryContext } = await import('./sentry-context'); + setSentryContext({ + userId: 'user-123', + authState: 'signed_in', + telemetryMode: 'optional', + }); + setUser.mockClear(); + setTag.mockClear(); + + clearSentryUser(); + + expect(setUser).toHaveBeenCalledWith(null); + expect(setTag).toHaveBeenCalledWith('app.auth_state', 'signed_out'); + expect(setTag).toHaveBeenCalledWith('app.telemetry_mode', 'optional'); + }); + + it('clears identity when account lookup fails', async () => { + const { setSentryContext } = await import('./sentry-context'); + + setSentryContext({ userId: null, authState: 'error', telemetryMode: 'mandatory' }); + + expect(setUser).toHaveBeenCalledWith(null); + expect(setTag).toHaveBeenCalledWith('app.auth_state', 'error'); + }); +}); diff --git a/apps/mobile/src/lib/sentry-context.ts b/apps/mobile/src/lib/sentry-context.ts new file mode 100644 index 0000000000..952a98e67f --- /dev/null +++ b/apps/mobile/src/lib/sentry-context.ts @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/react-native'; + +export type SentryAuthState = 'error' | 'loading' | 'signed_in' | 'signed_out'; +export type SentryTelemetryMode = 'mandatory' | 'optional'; + +let userId: string | null = null; +let authState: SentryAuthState = 'loading'; +let telemetryMode: SentryTelemetryMode = 'mandatory'; + +export function applySentryContext(): void { + Sentry.setUser(userId === null ? null : { id: userId }); + Sentry.setTag('app.auth_state', authState); + Sentry.setTag('app.telemetry_mode', telemetryMode); +} + +export function setSentryContext(context: { + readonly userId: string | null; + readonly authState: SentryAuthState; + readonly telemetryMode: SentryTelemetryMode; +}): void { + userId = context.userId; + authState = context.authState; + telemetryMode = context.telemetryMode; + applySentryContext(); +} + +export function clearSentryUser(): void { + userId = null; + authState = 'signed_out'; + applySentryContext(); +} From 6c5fe49857e6c7f8679aebb51d1c70e77e27d95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 19:17:13 +0200 Subject: [PATCH 2/2] fix(mobile): keep Sentry user cleared during sign-out --- apps/mobile/src/app/_layout.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 61645faa60..82fbcb7e6a 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -179,7 +179,7 @@ preloadThemePreference(); preloadStartupFonts(); function RootLayoutNav() { - const { token, isLoading: authLoading, signOut } = useAuth(); + const { token, isLoading: authLoading, isSigningOut, signOut } = useAuth(); const { updateRequired } = useForceUpdate(); const [fontsLoaded, fontsError] = useFonts({ JetBrainsMono_500Medium, @@ -219,7 +219,9 @@ function RootLayoutNav() { useEffect(() => { let authState: 'error' | 'loading' | 'signed_in' | 'signed_out' = 'signed_out'; - if (authLoading || userIdLoading) { + if (isSigningOut) { + authState = 'signed_out'; + } else if (authLoading || userIdLoading) { authState = 'loading'; } else if (userIdError) { authState = 'error'; @@ -234,6 +236,7 @@ function RootLayoutNav() { }, [ authLoading, consentChecked, + isSigningOut, needsConsent, optionalConsent, token,