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
55 changes: 49 additions & 6 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -148,6 +150,7 @@ function initSentry(optionalConsented: boolean) {

spotlight: __DEV__,
});
applySentryContext();
}

initSentry(false);
Expand Down Expand Up @@ -176,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,
Expand Down Expand Up @@ -208,10 +211,40 @@ 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 (isSigningOut) {
authState = 'signed_out';
} else if (authLoading || userIdLoading) {
authState = 'loading';
} else if (userIdError) {
authState = 'error';
} else if (token && userId) {
Comment thread
iscekic marked this conversation as resolved.
authState = 'signed_in';
}
setSentryContext({
userId: authState === 'signed_in' ? (userId ?? null) : null,
authState,
telemetryMode: consentChecked && !needsConsent && optionalConsent ? 'optional' : 'mandatory',
});
}, [
authLoading,
consentChecked,
isSigningOut,
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.
Expand Down Expand Up @@ -317,7 +350,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);
Expand Down Expand Up @@ -379,7 +414,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();
}
Expand Down Expand Up @@ -417,7 +458,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();
}
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/components/agents/attachment-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
});
});

Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/components/agents/attachment-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'])(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
});
});
11 changes: 9 additions & 2 deletions apps/mobile/src/lib/agent-attachments/strip-image-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {

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.

bot: Preserve the original image-processing failure when reporting it

Suggested fix: Bind the caught value and report that exception with the new static tags/fingerprint (for example, catch (error) { Sentry.captureException(error, { ... }) }). If raw native exception messages are intentionally excluded for privacy, route it through an approved sanitizer that still preserves safe diagnostic information such as error type and source stack; the current new Error only points to this catch handler and makes re-encode failures difficult to diagnose.

tags: {
'error.subsystem': 'agent-attachments',
'error.operation': 'strip-image-metadata',
},
extra: { outputExtension: strippedExtension(extension) },
fingerprint: ['agent-attachments-strip-image-metadata'],
});
return uri;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@ const hoisted = vi.hoisted(() => {
measureLocalSize: vi.fn(),
cancelAsync: vi.fn(),
fileDelete: vi.fn(),
captureException: vi.fn(),
deletedUris: new Set<string>(),
};
});

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' },
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
}
}

Expand Down
26 changes: 19 additions & 7 deletions apps/mobile/src/lib/appsflyer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}
);
});
});

Expand Down Expand Up @@ -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'],
}
);
});
});

Expand Down
17 changes: 12 additions & 5 deletions apps/mobile/src/lib/appsflyer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
});
};
}

Expand Down Expand Up @@ -102,7 +109,7 @@ async function createPurchaseConnector(): Promise<boolean> {
if (isConnectorAlreadyConfigured(error)) {
return true;
}
handleError('AppsFlyer purchase connector failed')(error);
handleError('create-purchase-connector')(error);
return false;
}
}
Expand Down Expand Up @@ -199,7 +206,7 @@ export function initAppsFlyer(): void {
});
drainPendingEvents();
},
handleError('AppsFlyer init failed')
handleError('init-sdk')
);
}

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/auth/auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/auth/auth-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', () => ({
Expand Down
Loading