diff --git a/.env.example b/.env.example index 000b1e563..fb60eca29 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,10 @@ EXPO_PUBLIC_ENVIRONMENT=preview # Staging and production share one instance and one project and are told apart # by EXPO_PUBLIC_ENVIRONMENT, so set that to `staging` or `production`. EXPO_PUBLIC_SENTRY_DSN= +# Admin Errors page: error toasts, failed API calls, crashes and failed flows are +# batched to POST /accounts/v1/errors/ingest. +# On unless set to `false`. Dev builds (__DEV__) never send, whatever this says. +EXPO_PUBLIC_ERRORS_INGEST_ENABLED=true # Release is derived automatically at build time (solid-ui@ on # web, @+ on native). Only set this to override. # EXPO_PUBLIC_SENTRY_RELEASE= diff --git a/app/_layout.tsx b/app/_layout.tsx index 3c32be445..cdf83c93f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -61,6 +61,10 @@ import { isProduction, } from '@/lib/config'; import { configureObserve, markAppInteractive, withObserve } from '@/lib/observe'; +import { installFetchReporter } from '@/lib/telemetry/installFetchReporter'; +import { installToastReporter } from '@/lib/telemetry/installToastReporter'; +import { setCurrentScreen } from '@/lib/telemetry/reportError'; +import { getSentryUser, installSentryUserSync } from '@/lib/telemetry/sentryUser'; import { config } from '@/lib/wagmi'; import { useUserStore } from '@/store/useUserStore'; import { useWhatsNewStore } from '@/store/useWhatsNewStore'; @@ -79,8 +83,9 @@ Sentry.init({ // `@+`, which is what the Expo plugin uploads under. ...(EXPO_PUBLIC_SENTRY_RELEASE ? { release: EXPO_PUBLIC_SENTRY_RELEASE } : {}), - // No PII: this is a financial app, and GlitchTip is our own box but still - // not a place for user identifiers, headers or cookies. + // No default PII (IP address, headers, cookies): this is a financial app. + // The signed-in user is attached explicitly instead — backend user id and + // username, never the email — by lib/telemetry/sentryUser. sendDefaultPii: false, // GlitchTip is error tracking only. Tracing, profiling, replay, user @@ -119,6 +124,13 @@ Sentry.init({ if (event.request?.cookies) { delete event.request.cookies; } + // Some captures still pass `user: { id: suborgId }` inline, which replaces + // the scope's user for that event. The signed-in backend user id wins, so + // every event from one person is filed under the same id. + const signedInUser = getSentryUser(); + if (signedInUser) { + event.user = { ...event.user, ...signedInUser }; + } return event; }, @@ -131,6 +143,13 @@ Sentry.init({ // launch, so configure dispatching before the app renders. configureObserve(); +// Admin Errors page: failed calls to our backend and every error toast are +// reported from here on (both inert in dev builds), and GlitchTip follows the +// signed-in user. Installed before the app renders. +installFetchReporter(); +installToastReporter(); +installSentryUserSync(); + export function ErrorBoundary(props: ErrorBoundaryProps) { return ; } @@ -344,6 +363,11 @@ function RootLayout() { trackScreen(pathname, params); }, [pathname, params, analyticsReady]); + // Error reports carry the screen the user was on. + useEffect(() => { + setCurrentScreen(pathname); + }, [pathname]); + useEffect(() => { if (fontError) { console.error('Error loading fonts:', fontError); diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx index 3d01ffc22..3ce35035a 100644 --- a/components/ErrorBoundary.tsx +++ b/components/ErrorBoundary.tsx @@ -10,7 +10,9 @@ import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { track } from '@/lib/analytics'; +import { EXPO_PUBLIC_SENTRY_DSN } from '@/lib/config'; import { isStaleBundleError, reloadForNewBundle } from '@/lib/staleBundle'; +import { reportError } from '@/lib/telemetry/reportError'; import type { ErrorBoundaryProps } from 'expo-router'; @@ -38,12 +40,24 @@ const ErrorBoundary = ({ error, retry }: ErrorBoundaryProps) => { // error tracker looked quieter than the app actually was. A stale bundle // is excluded: it is a deploy artifact, not a defect, and it recovers on // reload. - if (!isStaleBundle) { - Sentry.captureException(error, { - tags: { source: 'error_boundary', platform: Platform.OS }, - extra: { pathname }, - }); - } + const glitchtipEventId = isStaleBundle + ? undefined + : Sentry.captureException(error, { + tags: { source: 'error_boundary', platform: Platform.OS }, + extra: { pathname }, + }); + // The one Errors page event for this crash (the `error_boundary` track + // above is deliberately not mirrored there), linked to its GlitchTip + // event when GlitchTip is on. A stale bundle is reported as info. + reportError({ + kind: 'crash', + flow: 'app', + severity: isStaleBundle ? 'info' : 'error', + code: isStaleBundle ? 'STALE_BUNDLE' : error?.name, + message: `${error?.name ?? 'Error'}: ${String(error?.message ?? '')}`, + screen: pathname, + glitchtipEventId: EXPO_PUBLIC_SENTRY_DSN ? glitchtipEventId : undefined, + }); } } catch {} }, [error, isStaleBundle, pathname]); diff --git a/lib/analytics.ts b/lib/analytics.ts index 960ba91bf..1481fb623 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -35,6 +35,7 @@ import { EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET, } from '@/lib/config'; import { trackGTMEvent } from '@/lib/gtm'; +import { isErrorEvent, reportFlowError } from '@/lib/telemetry/errorEvents'; import { sanitize, toTitleCase } from '@/lib/utils/utils'; import { useAttributionStore } from '@/store/useAttributionStore'; @@ -269,6 +270,12 @@ export const track = ( trackFirebaseEvent(event, sanitizedParams), Promise.resolve(trackGTMEvent(event, sanitizedParams)), ]); + + // Failures also go to the admin Errors page, whatever `amplitude` says: + // that option is about double-counting in Amplitude, not about errors. + if (isErrorEvent(event)) { + reportFlowError(event, formatAmplitudeEvent(event), params); + } } catch (error) { console.error('Error tracking event:', error); } diff --git a/lib/config.ts b/lib/config.ts index d34806750..6dc5f6492 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -37,6 +37,13 @@ export const EXPO_PUBLIC_SENTRY_DSN = process.env.EXPO_PUBLIC_SENTRY_DSN ?? ''; * and overriding it here would point events at a release that has no artifacts. */ export const EXPO_PUBLIC_SENTRY_RELEASE = process.env.EXPO_PUBLIC_SENTRY_RELEASE ?? ''; +/** + * Report app errors (error toasts, failed API calls, crashes, failed flows) to + * the accounts service for the admin Errors page. On unless set to `false`; + * never sends anything from a dev build either way (see lib/telemetry). + */ +export const EXPO_PUBLIC_ERRORS_INGEST_ENABLED = + process.env.EXPO_PUBLIC_ERRORS_INGEST_ENABLED !== 'false'; // Sandbox: skip the TransFi buy-crypto KYC gate on the client and go straight to // the amount/quote screen. Pair with backend TRANSFI_SKIP_KYC. Never set in prod. export const EXPO_PUBLIC_TRANSFI_SKIP_KYC = process.env.EXPO_PUBLIC_TRANSFI_SKIP_KYC === 'true'; diff --git a/lib/telemetry/__tests__/errorEvents.test.ts b/lib/telemetry/__tests__/errorEvents.test.ts new file mode 100644 index 000000000..4c33841aa --- /dev/null +++ b/lib/telemetry/__tests__/errorEvents.test.ts @@ -0,0 +1,168 @@ +/// + +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { + inferErrorFlow, + isErrorEvent, + reportFlowError, + toFlowErrorReport, +} from '@/lib/telemetry/errorEvents'; +import { reportError } from '@/lib/telemetry/reportError'; + +jest.mock('@/lib/telemetry/reportError', () => ({ reportError: jest.fn() })); + +const mockReportError = reportError as jest.MockedFunction; + +describe('isErrorEvent', () => { + it.each([ + TRACKING_EVENTS.DEPOSIT_ERROR, + TRACKING_EVENTS.CARD_ACTIVATION_FAILED, + TRACKING_EVENTS.BUY_CRYPTO_KYC_REJECTED, + TRACKING_EVENTS.ONRAMPER_KYC_SHARE_DECLINED, + TRACKING_EVENTS.VIRTUAL_ACCOUNT_REGION_BLOCKED, + TRACKING_EVENTS.BUY_CRYPTO_KYC_HOSTED_RETRY_UNAVAILABLE, + TRACKING_EVENTS.ERROR_BOUNDARY, + TRACKING_EVENTS.REGION_UNAVAILABLE_SHOWN, + TRACKING_EVENTS.QR_SCANNER_PERMISSION_DENIED, + TRACKING_EVENTS.LOGIN_FAILED, + ])('treats %s as an error', name => { + expect(isErrorEvent(name)).toBe(true); + }); + + it.each([ + TRACKING_EVENTS.DEPOSIT_CANCELLED, + TRACKING_EVENTS.DEPOSIT_COMPLETED, + TRACKING_EVENTS.BUY_CRYPTO_ERROR_VIEWED, + TRACKING_EVENTS.ORCHESTRA_ERROR_ACTION_PRESSED, + TRACKING_EVENTS.CARD_SPEND_REGISTER_CANCELLED, + TRACKING_EVENTS.STORE_REVIEW_UNAVAILABLE, + TRACKING_EVENTS.TRUSTPILOT_WIDGET_UNAVAILABLE, + TRACKING_EVENTS.RETRY_ATTEMPTED, + 'ATT_Response', + '', + ])('does not treat %p as an error', name => { + expect(isErrorEvent(name)).toBe(false); + }); + + it('every *_failed and *_error constant is an error event', () => { + const failures = Object.values(TRACKING_EVENTS).filter( + name => name.endsWith('_failed') || name.endsWith('_error'), + ); + expect(failures.length).toBeGreaterThan(30); + failures.forEach(name => expect(isErrorEvent(name)).toBe(true)); + }); +}); + +describe('inferErrorFlow', () => { + it.each([ + [TRACKING_EVENTS.DEPOSIT_ERROR, 'deposit'], + [TRACKING_EVENTS.CARD_DEPOSIT_FAILED, 'card_deposit'], + [TRACKING_EVENTS.CARD_ACTIVATION_FAILED, 'card'], + [TRACKING_EVENTS.CARD_KYC_COUNTRY_DETECTION_FAILED, 'kyc'], + [TRACKING_EVENTS.KYC_LINK_ERROR, 'kyc'], + [TRACKING_EVENTS.WITHDRAW_TRANSACTION_ERROR, 'withdraw'], + [TRACKING_EVENTS.FAST_WITHDRAW_FAILED, 'withdraw'], + [TRACKING_EVENTS.CANCEL_WITHDRAW_ERROR, 'withdraw'], + [TRACKING_EVENTS.SEND_PAGE_TRANSACTION_FAILED, 'send'], + [TRACKING_EVENTS.SWAP_FAILED, 'swap'], + [TRACKING_EVENTS.PEG_SWAP_FAILED, 'swap'], + [TRACKING_EVENTS.WRAP_FAILED, 'swap'], + [TRACKING_EVENTS.BRIDGE_TO_MAINNET_ERROR, 'bridge'], + [TRACKING_EVENTS.BUY_CRYPTO_ORDER_FAILED, 'buy_crypto'], + [TRACKING_EVENTS.ORCHESTRA_ORDER_FAILED, 'buy_crypto'], + [TRACKING_EVENTS.CASH_OUT_ORDER_FAILED, 'cash_out'], + [TRACKING_EVENTS.SIGNUP_FAILED, 'signup'], + [TRACKING_EVENTS.EMAIL_VERIFICATION_FAILED, 'signup'], + [TRACKING_EVENTS.PASSKEY_CREATION_FAILED, 'signup'], + [TRACKING_EVENTS.USERNAME_UNAVAILABLE, 'signup'], + [TRACKING_EVENTS.LOGIN_FAILED, 'login'], + [TRACKING_EVENTS.TIER_LOCK_FAILED, 'tier'], + [TRACKING_EVENTS.QUEST_WALLET_UPDATE_FAILED, 'rewards'], + [TRACKING_EVENTS.VIRTUAL_ACCOUNT_CREATION_FAILED, 'deposit'], + [TRACKING_EVENTS.ERROR_BOUNDARY, 'app'], + [TRACKING_EVENTS.QR_CODE_SCAN_FAILED, 'other'], + ])('%s → %s', (name, flow) => { + expect(inferErrorFlow(name)).toBe(flow); + }); +}); + +describe('toFlowErrorReport', () => { + it('reads the reason, code, step and transaction from whichever keys the call site used', () => { + expect( + toFlowErrorReport(TRACKING_EVENTS.DEPOSIT_ERROR, 'Deposit Error', { + error_message: 'Insufficient balance', + error_code: 'INSUFFICIENT_BALANCE', + failed_stage: 'permit', + clientTxId: 'tx-1', + amount: '10', + }), + ).toEqual({ + kind: 'flow', + flow: 'deposit', + amplitudeEvent: 'Deposit Error', + message: 'Insufficient balance', + code: 'INSUFFICIENT_BALANCE', + step: 'permit', + refs: { clientTxId: 'tx-1' }, + claimedUsername: undefined, + }); + }); + + it('prefers `error` over the other message keys and skips empty ones', () => { + const report = toFlowErrorReport('swap_failed', 'Swap Failed', { + error: ' ', + error_message: 'Slippage too high', + message: 'ignored', + }); + expect(report.message).toBe('Slippage too high'); + }); + + it('accepts Error objects and numeric codes', () => { + const report = toFlowErrorReport('send_transaction_error', 'Send Transaction Error', { + error: new Error('execution reverted'), + code: 500, + }); + expect(report.message).toBe('execution reverted'); + expect(report.code).toBe('500'); + }); + + it('falls back to the event name when nothing says why', () => { + expect(toFlowErrorReport('wrap_failed', 'Wrap Failed', {}).message).toBe('wrap_failed'); + }); + + it('only keeps the typed username for login failures', () => { + expect( + toFlowErrorReport(TRACKING_EVENTS.LOGIN_FAILED, 'Login Failed', { username: 'alice' }) + .claimedUsername, + ).toBe('alice'); + expect( + toFlowErrorReport(TRACKING_EVENTS.SIGNUP_FAILED, 'Signup Failed', { username: 'alice' }) + .claimedUsername, + ).toBeUndefined(); + }); +}); + +describe('reportFlowError', () => { + beforeEach(() => mockReportError.mockClear()); + + it('reports a failed flow', () => { + reportFlowError(TRACKING_EVENTS.CARD_DEPOSIT_FAILED, 'Card Deposit Failed', { + error: 'boom', + }); + expect(mockReportError).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'flow', flow: 'card_deposit', message: 'boom' }), + ); + }); + + it('leaves error_boundary to the boundary, which reports the crash itself', () => { + reportFlowError(TRACKING_EVENTS.ERROR_BOUNDARY, 'Error Boundary', { message: 'boom' }); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('never throws', () => { + mockReportError.mockImplementationOnce(() => { + throw new Error('broken'); + }); + expect(() => reportFlowError('swap_failed', 'Swap Failed', {})).not.toThrow(); + }); +}); diff --git a/lib/telemetry/__tests__/errorIngestQueue.test.ts b/lib/telemetry/__tests__/errorIngestQueue.test.ts new file mode 100644 index 000000000..301f352c1 --- /dev/null +++ b/lib/telemetry/__tests__/errorIngestQueue.test.ts @@ -0,0 +1,212 @@ +/// + +import { AppState } from 'react-native'; + +import { + __getQueuedErrorEvents, + __resetErrorIngestQueue, + enqueueErrorEvent, + ERROR_INGEST_URL, + FLUSH_INTERVAL_MS, + isErrorIngestEnabled, + MAX_QUEUED, + RETRY_DELAY_MS, +} from '@/lib/telemetry/errorIngestQueue'; + +import type { ClientErrorEvent } from '@/lib/telemetry/types'; + +jest.mock('@/lib/api', () => ({ + getPlatformHeaders: () => ({ 'X-Platform': 'mobile' }), + getJWTToken: () => 'access-token', +})); +jest.mock('@/lib/config', () => ({ + EXPO_PUBLIC_FLASH_API_BASE_URL: 'https://api.test', + EXPO_PUBLIC_ERRORS_INGEST_ENABLED: true, +})); + +let counter = 0; +const event = (overrides: Partial = {}): ClientErrorEvent => { + counter += 1; + return { + id: `id-${counter}`, + ts: '2026-10-08T10:00:00.000Z', + kind: 'api', + message: `failure ${counter}`, + platform: 'ios', + ...overrides, + }; +}; + +const respond = (status: number) => ({ ok: status >= 200 && status < 300, status }) as Response; + +const sentBatches = (fetchMock: jest.Mock) => + fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).events as ClientErrorEvent[]); + +describe('errorIngestQueue', () => { + const originalFetch = global.fetch; + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + __resetErrorIngestQueue(); + fetchMock = jest.fn(() => Promise.resolve(respond(202))); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + __resetErrorIngestQueue(); + global.fetch = originalFetch; + jest.useRealTimers(); + }); + + it('sends what was queued as one batch after the flush interval, with the app auth', async () => { + enqueueErrorEvent(event()); + enqueueErrorEvent(event()); + expect(fetchMock).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.test/accounts/v1/errors/ingest'); + expect(url).toBe(ERROR_INGEST_URL); + expect(init).toMatchObject({ + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-Platform': 'mobile', + Authorization: 'Bearer access-token', + }, + }); + expect(sentBatches(fetchMock)[0]).toHaveLength(2); + expect(__getQueuedErrorEvents()).toHaveLength(0); + }); + + it('sends straight away once 20 events are waiting', async () => { + for (let i = 0; i < 20; i += 1) enqueueErrorEvent(event()); + await jest.advanceTimersByTimeAsync(0); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sentBatches(fetchMock)[0]).toHaveLength(20); + }); + + it('never sends more than 50 events in one request', async () => { + let release!: () => void; + fetchMock.mockImplementationOnce( + () => new Promise(resolve => (release = () => resolve(respond(202)))), + ); + + // 20 start a flush; the next 100 queue up behind the request in flight. + for (let i = 0; i < 120; i += 1) enqueueErrorEvent(event()); + release(); + await jest.advanceTimersByTimeAsync(0); + + expect(sentBatches(fetchMock).map(batch => batch.length)).toEqual([20, 50, 50]); + }); + + it('keeps at most 200 events, dropping the oldest', async () => { + fetchMock.mockImplementationOnce(() => new Promise(() => {})); + for (let i = 0; i < 20; i += 1) enqueueErrorEvent(event()); // in flight + const queued = Array.from({ length: 250 }, () => event()); + queued.forEach(enqueueErrorEvent); + + const waiting = __getQueuedErrorEvents(); + expect(waiting).toHaveLength(MAX_QUEUED); + expect(waiting[0].id).toBe(queued[50].id); + expect(waiting[MAX_QUEUED - 1].id).toBe(queued[249].id); + }); + + it('sends an identical event once within 5 seconds', async () => { + const same = { kind: 'api' as const, code: 'E1', message: 'Boom', endpoint: '/a' }; + + expect(enqueueErrorEvent(event(same))).toBe(true); + expect(enqueueErrorEvent(event(same))).toBe(false); + // A different endpoint is a different failure. + expect(enqueueErrorEvent(event({ ...same, endpoint: '/b' }))).toBe(true); + + await jest.advanceTimersByTimeAsync(5_000); + expect(enqueueErrorEvent(event(same))).toBe(true); + }); + + it('retries a failed batch once, then drops it', async () => { + fetchMock.mockImplementation(() => Promise.reject(new TypeError('Network request failed'))); + enqueueErrorEvent(event()); + + await jest.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(RETRY_DELAY_MS); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(__getQueuedErrorEvents()).toHaveLength(0); + }); + + it('retries a server error, but not a rejected batch', async () => { + fetchMock + .mockImplementationOnce(() => Promise.resolve(respond(503))) + .mockImplementationOnce(() => Promise.resolve(respond(202))); + enqueueErrorEvent(event()); + await jest.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS + RETRY_DELAY_MS); + expect(fetchMock).toHaveBeenCalledTimes(2); + + fetchMock.mockClear(); + fetchMock.mockImplementation(() => Promise.resolve(respond(400))); + enqueueErrorEvent(event()); + await jest.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS + RETRY_DELAY_MS); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('resends a 401 straight away without credentials instead of refreshing', async () => { + fetchMock.mockImplementationOnce(() => Promise.resolve(respond(401))); + enqueueErrorEvent(event()); + await jest.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, anonymous] = fetchMock.mock.calls[1]; + expect(anonymous.credentials).toBe('omit'); + expect(anonymous.headers.Authorization).toBeUndefined(); + expect(fetchMock.mock.calls[0][1].body).toBe(anonymous.body); + }); + + it('flushes when the app goes to the background', async () => { + enqueueErrorEvent(event()); + const onChange = (AppState.addEventListener as jest.Mock).mock.calls.find( + ([type]) => type === 'change', + )?.[1]; + expect(onChange).toBeDefined(); + + onChange('background'); + await jest.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('never throws, whatever fetch does', async () => { + fetchMock.mockImplementation(() => { + throw new Error('sync failure'); + }); + expect(() => enqueueErrorEvent(event())).not.toThrow(); + await expect(jest.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS + RETRY_DELAY_MS)).resolves.toBe( + undefined, + ); + }); +}); + +describe('isErrorIngestEnabled', () => { + const dev = (global as any).__DEV__; + afterEach(() => { + (global as any).__DEV__ = dev; + }); + + it('is off in dev builds', () => { + (global as any).__DEV__ = true; + expect(isErrorIngestEnabled()).toBe(false); + }); + + it('is on in release builds with a backend configured', () => { + (global as any).__DEV__ = false; + expect(isErrorIngestEnabled()).toBe(true); + }); +}); diff --git a/lib/telemetry/__tests__/installFetchReporter.test.ts b/lib/telemetry/__tests__/installFetchReporter.test.ts new file mode 100644 index 000000000..e8a8fa955 --- /dev/null +++ b/lib/telemetry/__tests__/installFetchReporter.test.ts @@ -0,0 +1,257 @@ +/// + +import { isErrorIngestEnabled } from '@/lib/telemetry/errorIngestQueue'; +import { + endpointOf, + installFetchReporter, + isExpectedFailure, + isReportableUrl, + requestMethod, + requestUrl, +} from '@/lib/telemetry/installFetchReporter'; +import { reportError } from '@/lib/telemetry/reportError'; + +jest.mock('@/lib/config', () => ({ + EXPO_PUBLIC_FLASH_API_BASE_URL: 'https://api.test', + EXPO_PUBLIC_FLASH_REWARDS_API_BASE_URL: 'https://rewards.test/', + EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL: '', + EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL: 'https://vaults.test', +})); +jest.mock('@/lib/telemetry/errorIngestQueue', () => ({ + ERROR_INGEST_URL: 'https://api.test/accounts/v1/errors/ingest', + isErrorIngestEnabled: jest.fn(() => true), +})); +jest.mock('@/lib/telemetry/reportError', () => ({ reportError: jest.fn() })); + +const mockReportError = reportError as jest.MockedFunction; +const mockEnabled = isErrorIngestEnabled as jest.MockedFunction; + +/** Let the reporter's async read of the cloned body finish. */ +const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + +const jsonResponse = (status: number, body: unknown, statusText = '') => + new Response(JSON.stringify(body), { + status, + statusText, + headers: { 'Content-Type': 'application/json' }, + }); + +describe('installFetchReporter', () => { + const originalFetch = global.fetch; + let underlying: jest.Mock; + + beforeEach(() => { + mockReportError.mockClear(); + mockEnabled.mockReturnValue(true); + underlying = jest.fn(); + global.fetch = underlying as unknown as typeof fetch; + installFetchReporter(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('wraps fetch once, however often it is installed', () => { + const wrapped = global.fetch; + expect(wrapped).not.toBe(underlying); + installFetchReporter(); + expect(global.fetch).toBe(wrapped); + }); + + it('leaves fetch alone when ingest is off', () => { + global.fetch = underlying as unknown as typeof fetch; + mockEnabled.mockReturnValue(false); + installFetchReporter(); + expect(global.fetch).toBe(underlying); + }); + + it('passes arguments and `this` through, and returns the very same response', async () => { + const response = jsonResponse(200, { ok: true }); + underlying.mockResolvedValue(response); + const init = { method: 'POST', body: '{}' }; + const self = {}; + + const result = await global.fetch.call(self, 'https://api.test/accounts/v1/users/me', init); + + expect(result).toBe(response); + expect(underlying).toHaveBeenCalledWith('https://api.test/accounts/v1/users/me', init); + expect(underlying.mock.contexts[0]).toBe(self); + await expect(result.json()).resolves.toEqual({ ok: true }); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('returns the original promise untouched for other hosts', () => { + const pending = Promise.resolve(jsonResponse(500, {})); + underlying.mockReturnValue(pending); + + expect(global.fetch('https://api.coingecko.com/api/v3/ping')).toBe(pending); + }); + + it('reports a failed response from our backend and still hands over an unread body', async () => { + const response = jsonResponse( + 400, + { code: 'INSUFFICIENT_BALANCE', message: ['amount too low', 'try more'] }, + 'Bad Request', + ); + underlying.mockResolvedValue(response); + + const result = await global.fetch('https://api.test/accounts/v1/deposits/123?token=x#frag'); + expect(result).toBe(response); + // The caller can still read the body after the reporter has read its copy. + await expect(result.json()).resolves.toMatchObject({ code: 'INSUFFICIENT_BALANCE' }); + await settle(); + + expect(mockReportError).toHaveBeenCalledTimes(1); + expect(mockReportError).toHaveBeenCalledWith({ + kind: 'api', + httpStatus: 400, + endpoint: '/accounts/v1/deposits/123', + code: 'INSUFFICIENT_BALANCE', + message: 'amount too low; try more', + }); + }); + + it('falls back to the status text when the body is not JSON', async () => { + underlying.mockResolvedValue( + new Response('Bad gateway', { status: 502, statusText: 'Bad Gateway' }), + ); + + await global.fetch('https://vaults.test/v1/vaults'); + await settle(); + + expect(mockReportError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'api', + httpStatus: 502, + endpoint: '/v1/vaults', + message: 'Bad Gateway', + }), + ); + }); + + it('works with Request and URL inputs', async () => { + underlying.mockImplementation(() => Promise.resolve(jsonResponse(500, { message: 'down' }))); + + await global.fetch(new Request('https://rewards.test/rewards/v1/points')); + await global.fetch(new URL('https://api.test/accounts/v1/cards')); + await settle(); + + expect(mockReportError.mock.calls.map(([input]) => input.endpoint)).toEqual([ + '/rewards/v1/points', + '/accounts/v1/cards', + ]); + }); + + it('ignores 401s (the token refresh handles them) and successes', async () => { + underlying.mockResolvedValueOnce(jsonResponse(401, { message: 'Unauthorized' })); + underlying.mockResolvedValueOnce(jsonResponse(200, {})); + + await global.fetch('https://api.test/accounts/v1/users/me'); + await global.fetch('https://api.test/accounts/v1/users/me'); + await settle(); + + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('ignores a 404 on a read ("there is none") but reports one on a write', async () => { + underlying.mockImplementation(() => + Promise.resolve(jsonResponse(404, { message: 'Card not found' })), + ); + + await global.fetch('https://api.test/accounts/v1/cards/status'); + await global.fetch(new Request('https://api.test/accounts/v1/cards/status')); + await global.fetch('https://api.test/accounts/v1/cards/activate', { method: 'post' }); + await settle(); + + expect(mockReportError).toHaveBeenCalledTimes(1); + expect(mockReportError).toHaveBeenCalledWith( + expect.objectContaining({ httpStatus: 404, endpoint: '/accounts/v1/cards/activate' }), + ); + }); + + it('never reports the ingest endpoint itself', async () => { + underlying.mockResolvedValue(jsonResponse(500, { message: 'down' })); + underlying.mockRejectedValueOnce(new TypeError('Network request failed')); + + await expect(global.fetch('https://api.test/accounts/v1/errors/ingest')).rejects.toThrow(); + await global.fetch('https://api.test/accounts/v1/errors/ingest'); + await settle(); + + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('reports a network failure as a warning and rethrows the same error', async () => { + const failure = new TypeError('Network request failed'); + underlying.mockRejectedValue(failure); + + await expect(global.fetch('https://api.test/accounts/v1/activities')).rejects.toBe(failure); + + expect(mockReportError).toHaveBeenCalledWith({ + kind: 'network', + severity: 'warning', + endpoint: '/accounts/v1/activities', + message: 'Network request failed', + }); + }); + + it('rethrows other failures without reporting them', async () => { + const aborted = new Error('Aborted'); + aborted.name = 'AbortError'; + underlying.mockRejectedValue(aborted); + + await expect(global.fetch('https://api.test/accounts/v1/activities')).rejects.toBe(aborted); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('does not report when the reporter itself breaks', async () => { + mockReportError.mockImplementation(() => { + throw new Error('broken'); + }); + const failure = new TypeError('Failed to fetch'); + underlying.mockRejectedValue(failure); + + await expect(global.fetch('https://api.test/accounts/v1/activities')).rejects.toBe(failure); + mockReportError.mockReset(); + }); +}); + +describe('url helpers', () => { + it('reads the URL of every kind of fetch input', () => { + expect(requestUrl('https://api.test/a')).toBe('https://api.test/a'); + expect(requestUrl(new URL('https://api.test/b'))).toBe('https://api.test/b'); + expect(requestUrl(new Request('https://api.test/c'))).toBe('https://api.test/c'); + expect(requestUrl(undefined)).toBeUndefined(); + }); + + it('matches our services on a path boundary only', () => { + expect(isReportableUrl('https://api.test/accounts/v1/x')).toBe(true); + expect(isReportableUrl('https://api.test?x=1')).toBe(true); + expect(isReportableUrl('https://rewards.test/rewards/v1')).toBe(true); + expect(isReportableUrl('https://api.test.evil.com/accounts')).toBe(false); + expect(isReportableUrl('https://api.test/accounts/v1/errors/ingest')).toBe(false); + expect(isReportableUrl('https://example.com/accounts')).toBe(false); + }); + + it('reads the method from init or the Request, defaulting to GET', () => { + expect(requestMethod('https://api.test', { method: 'delete' })).toBe('DELETE'); + expect(requestMethod(new Request('https://api.test', { method: 'PUT' }), undefined)).toBe( + 'PUT', + ); + expect(requestMethod('https://api.test', undefined)).toBe('GET'); + }); + + it('treats 401s and 404s on reads as answers, not failures', () => { + expect(isExpectedFailure(401, 'POST')).toBe(true); + expect(isExpectedFailure(404, 'GET')).toBe(true); + expect(isExpectedFailure(404, 'POST')).toBe(false); + expect(isExpectedFailure(403, 'GET')).toBe(false); + expect(isExpectedFailure(500, 'GET')).toBe(false); + }); + + it('keeps only the path', () => { + expect(endpointOf('https://api.test/accounts/v1/x?y=1#z')).toBe('/accounts/v1/x'); + expect(endpointOf('https://api.test')).toBe('/'); + expect(endpointOf('/relative/path?q')).toBe('/relative/path'); + }); +}); diff --git a/lib/telemetry/__tests__/installToastReporter.test.ts b/lib/telemetry/__tests__/installToastReporter.test.ts new file mode 100644 index 000000000..ecb43604b --- /dev/null +++ b/lib/telemetry/__tests__/installToastReporter.test.ts @@ -0,0 +1,99 @@ +/// + +import Toast from 'react-native-toast-message'; + +import { isErrorIngestEnabled } from '@/lib/telemetry/errorIngestQueue'; +import { installToastReporter, isCancellationText } from '@/lib/telemetry/installToastReporter'; +import { reportError } from '@/lib/telemetry/reportError'; + +jest.mock('@/lib/telemetry/errorIngestQueue', () => ({ + isErrorIngestEnabled: jest.fn(() => true), +})); +jest.mock('@/lib/telemetry/reportError', () => ({ reportError: jest.fn() })); + +const mockReportError = reportError as jest.MockedFunction; +const mockEnabled = isErrorIngestEnabled as jest.MockedFunction; + +describe('installToastReporter', () => { + const librarysShow = Toast.show; + let shown: jest.Mock; + + beforeEach(() => { + mockReportError.mockReset(); + mockEnabled.mockReturnValue(true); + shown = jest.fn(); + Toast.show = shown; + installToastReporter(); + }); + + afterAll(() => { + Toast.show = librarysShow; + }); + + it('still shows every toast, and wraps only once', () => { + const wrapped = Toast.show; + installToastReporter(); + expect(Toast.show).toBe(wrapped); + + const params = { type: 'success', text1: 'Saved' }; + Toast.show(params); + expect(shown).toHaveBeenCalledWith(params); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('reports an error toast with exactly the text it showed', () => { + Toast.show({ type: 'error', text1: 'Deposit failed', text2: 'Please try again.' }); + + expect(shown).toHaveBeenCalledTimes(1); + expect(mockReportError).toHaveBeenCalledWith({ + kind: 'toast', + message: 'Deposit failed — Please try again.', + userMessage: 'Deposit failed — Please try again.', + severity: 'error', + }); + }); + + it('reports a cancelled prompt as info', () => { + Toast.show({ type: 'error', text1: 'Login failed', text2: 'User rejected the request.' }); + expect(mockReportError).toHaveBeenCalledWith(expect.objectContaining({ severity: 'info' })); + }); + + it('skips error toasts with no text', () => { + Toast.show({ type: 'error' }); + expect(shown).toHaveBeenCalledTimes(1); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('shows the toast even when reporting breaks', () => { + mockReportError.mockImplementation(() => { + throw new Error('broken'); + }); + expect(() => Toast.show({ type: 'error', text1: 'Oops' })).not.toThrow(); + expect(shown).toHaveBeenCalledTimes(1); + }); + + it('leaves Toast alone when ingest is off', () => { + Toast.show = shown; + mockEnabled.mockReturnValue(false); + installToastReporter(); + expect(Toast.show).toBe(shown); + }); +}); + +describe('isCancellationText', () => { + it.each([ + 'Request cancelled.', + 'Passkey prompt was cancelled', + 'NotAllowedError: The operation either timed out or was not allowed.', + 'User rejected the request.', + ])('%s is a cancellation', text => { + expect(isCancellationText(text)).toBe(true); + }); + + it.each(['Failed to cancel physical card — Please try again.', 'Insufficient balance'])( + '%s is not', + text => { + expect(isCancellationText(text)).toBe(false); + }, + ); +}); diff --git a/lib/telemetry/__tests__/reportError.test.ts b/lib/telemetry/__tests__/reportError.test.ts new file mode 100644 index 000000000..6f50cbcb8 --- /dev/null +++ b/lib/telemetry/__tests__/reportError.test.ts @@ -0,0 +1,157 @@ +/// + +import { enqueueErrorEvent, isErrorIngestEnabled } from '@/lib/telemetry/errorIngestQueue'; +import { + buildClientErrorEvent, + ERROR_FIELD_LIMITS, + reportError, + setCurrentScreen, + truncateField, +} from '@/lib/telemetry/reportError'; + +jest.mock('@/lib/telemetry/errorIngestQueue', () => ({ + enqueueErrorEvent: jest.fn(), + isErrorIngestEnabled: jest.fn(() => true), +})); +jest.mock('@amplitude/analytics-react-native', () => ({ getDeviceId: jest.fn(() => 'device-1') })); +jest.mock('expo-application', () => ({ nativeApplicationVersion: '2.0.2' })); +jest.mock('expo-constants', () => ({ + __esModule: true, + default: { expoConfig: { version: '9.9.9' } }, +})); +jest.mock('expo-crypto', () => ({ + randomUUID: jest.fn(() => '6f1c0e5e-1b2a-4c3d-8e4f-5a6b7c8d9e0f'), +})); + +const mockEnqueue = enqueueErrorEvent as jest.MockedFunction; +const mockEnabled = isErrorIngestEnabled as jest.MockedFunction; + +const FAKE_KEY = 'pim_TestKey1234567890abc'; + +describe('buildClientErrorEvent', () => { + afterEach(() => setCurrentScreen(undefined)); + + it('fills in the device context', () => { + setCurrentScreen('/deposit'); + const event = buildClientErrorEvent( + { kind: 'api', message: 'Bad request', httpStatus: 400, endpoint: '/accounts/v1/deposits' }, + new Date('2026-10-08T10:00:00.000Z'), + ); + + expect(event).toEqual({ + id: '6f1c0e5e-1b2a-4c3d-8e4f-5a6b7c8d9e0f', + ts: '2026-10-08T10:00:00.000Z', + kind: 'api', + message: 'Bad request', + httpStatus: 400, + endpoint: '/accounts/v1/deposits', + screen: '/deposit', + // jest-expo runs as iOS + platform: 'ios', + appVersion: '2.0.2', + deviceId: 'device-1', + }); + // Nothing undefined goes over the wire. + expect(JSON.parse(JSON.stringify(event))).toEqual(event); + }); + + it('strips credentials from the message and from what the user saw', () => { + const event = buildClientErrorEvent({ + kind: 'toast', + message: `URL: https://api.pimlico.io/v2/122/rpc?apikey=${FAKE_KEY}`, + userMessage: `Failed: Bearer abc.def.ghi`, + }); + + expect(event.message).not.toContain(FAKE_KEY); + expect(event.message).toContain('apikey=[redacted]'); + expect(event.userMessage).toBe('Failed: Bearer [redacted]'); + }); + + it('cuts every field to the contract limits', () => { + const long = 'x'.repeat(5000); + const event = buildClientErrorEvent({ + kind: 'flow', + message: long, + userMessage: long, + step: long, + code: long, + endpoint: `/${long}`, + screen: `/${long}`, + claimedUsername: long, + }); + + expect(event.message).toHaveLength(ERROR_FIELD_LIMITS.message); + expect(event.message.endsWith('…')).toBe(true); + expect(event.userMessage).toHaveLength(ERROR_FIELD_LIMITS.userMessage); + expect(event.step).toHaveLength(ERROR_FIELD_LIMITS.step); + expect(event.code).toHaveLength(ERROR_FIELD_LIMITS.code); + expect(event.endpoint).toHaveLength(ERROR_FIELD_LIMITS.endpoint); + expect(event.screen).toHaveLength(ERROR_FIELD_LIMITS.screen); + expect(event.claimedUsername).toHaveLength(ERROR_FIELD_LIMITS.claimedUsername); + }); + + it('redacts before cutting, so a cut can never expose part of a key', () => { + const message = `${'x'.repeat(1990)} apikey=${FAKE_KEY}`; + expect(buildClientErrorEvent({ kind: 'api', message }).message).not.toContain('pim_'); + }); + + it('falls back to what the user saw, then to the kind, for the message', () => { + expect(buildClientErrorEvent({ kind: 'toast', userMessage: 'Oops' }).message).toBe('Oops'); + expect(buildClientErrorEvent({ kind: 'crash', message: ' ' }).message).toBe('crash'); + }); + + it('turns numeric codes into strings and drops empty refs', () => { + const event = buildClientErrorEvent({ + kind: 'flow', + message: 'm', + code: 404, + refs: { clientTxId: '', activityId: 'a-1' }, + }); + expect(event.code).toBe('404'); + expect(event.refs).toEqual({ activityId: 'a-1' }); + expect(buildClientErrorEvent({ kind: 'flow', message: 'm', refs: {} }).refs).toBeUndefined(); + }); + + it('lets a caller name the screen', () => { + setCurrentScreen('/home'); + expect(buildClientErrorEvent({ kind: 'crash', message: 'm', screen: '/card' }).screen).toBe( + '/card', + ); + }); +}); + +describe('truncateField', () => { + it('keeps short text, trims it and treats blank as absent', () => { + expect(truncateField(' ok ', 10)).toBe('ok'); + expect(truncateField(' ', 10)).toBeUndefined(); + expect(truncateField(undefined, 10)).toBeUndefined(); + expect(truncateField('abcdef', 4)).toBe('abc…'); + }); +}); + +describe('reportError', () => { + beforeEach(() => { + mockEnqueue.mockReset(); + mockEnabled.mockReturnValue(true); + }); + + it('queues the built event', () => { + reportError({ kind: 'network', message: 'Network request failed', severity: 'warning' }); + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'network', severity: 'warning' }), + ); + }); + + it('does nothing when ingest is off (dev builds, env switch)', () => { + mockEnabled.mockReturnValue(false); + reportError({ kind: 'network', message: 'Network request failed' }); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + + it('never throws', () => { + mockEnqueue.mockImplementation(() => { + throw new Error('broken'); + }); + expect(() => reportError({ kind: 'api', message: 'x' })).not.toThrow(); + }); +}); diff --git a/lib/telemetry/__tests__/sentryUser.test.ts b/lib/telemetry/__tests__/sentryUser.test.ts new file mode 100644 index 000000000..f14ca5db7 --- /dev/null +++ b/lib/telemetry/__tests__/sentryUser.test.ts @@ -0,0 +1,61 @@ +/// + +import * as Sentry from '@sentry/react-native'; + +import { getSentryUser, installSentryUserSync } from '@/lib/telemetry/sentryUser'; +import { useUserStore } from '@/store/useUserStore'; + +import type { User } from '@/lib/types'; + +jest.mock('@sentry/react-native', () => ({ setUser: jest.fn() })); +jest.mock('@/lib/mmvkStorage', () => ({ + __esModule: true, + default: () => { + const store = new Map(); + return { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }; + }, +})); + +const mockSetUser = Sentry.setUser as jest.Mock; + +const user = (overrides: Partial): User => + ({ + username: 'alice', + userId: 'mongo-id-1', + suborgId: 'turnkey-suborg-1', + safeAddress: '0x0000000000000000000000000000000000000001', + signWith: 'passkey', + selected: false, + email: 'alice@example.com', + ...overrides, + }) as User; + +describe('installSentryUserSync', () => { + it('follows the selected user by backend id and username, never suborg or email', () => { + useUserStore.setState({ users: [user({ selected: true })] }); + installSentryUserSync(); + + expect(mockSetUser).toHaveBeenLastCalledWith({ id: 'mongo-id-1', username: 'alice' }); + expect(getSentryUser()).toEqual({ id: 'mongo-id-1', username: 'alice' }); + + // Switching accounts + useUserStore.setState({ + users: [user({}), user({ userId: 'mongo-id-2', username: 'bob', selected: true })], + }); + expect(mockSetUser).toHaveBeenLastCalledWith({ id: 'mongo-id-2', username: 'bob' }); + + // An unrelated store update does not re-send the same user + const calls = mockSetUser.mock.calls.length; + useUserStore.setState({ redirectFrom: '/card' }); + expect(mockSetUser.mock.calls.length).toBe(calls); + + // Logging out + useUserStore.getState().unselectUser(); + expect(mockSetUser).toHaveBeenLastCalledWith(null); + expect(getSentryUser()).toBeNull(); + }); +}); diff --git a/lib/telemetry/__tests__/trackErrorEvents.test.ts b/lib/telemetry/__tests__/trackErrorEvents.test.ts new file mode 100644 index 000000000..4fb29c70b --- /dev/null +++ b/lib/telemetry/__tests__/trackErrorEvents.test.ts @@ -0,0 +1,89 @@ +/// + +import { track } from '@/lib/analytics'; +import { reportError } from '@/lib/telemetry/reportError'; + +jest.mock('@amplitude/analytics-react-native', () => ({ + add: jest.fn(), + getDeviceId: jest.fn(() => 'device-1'), + getSessionId: jest.fn(() => 1), + Identify: jest.fn(), + identify: jest.fn(), + init: jest.fn(), + setUserId: jest.fn(), + track: jest.fn(), +})); +jest.mock('@react-native-async-storage/async-storage', () => ({ __esModule: true, default: {} })); +jest.mock('@react-native-firebase/analytics', () => ({ + getAnalytics: jest.fn(), + logEvent: jest.fn(), + setUserId: jest.fn(), + setUserProperties: jest.fn(), +})); +jest.mock('@react-native-firebase/app', () => ({ + getApps: () => [], + initializeApp: jest.fn(), + setReactNativeAsyncStorage: jest.fn(), +})); +jest.mock('@/lib/gtm', () => ({ trackGTMEvent: jest.fn() })); +jest.mock('@/lib/attribution', () => ({ getAttributionChannel: () => 'direct' })); +jest.mock('@/store/useAttributionStore', () => ({ + useAttributionStore: { getState: () => ({ getAttributionForEvent: () => ({}) }) }, +})); +jest.mock('@/lib/utils/utils', () => ({ + sanitize: (value: unknown) => value, + toTitleCase: (word: string) => word.charAt(0).toUpperCase() + word.slice(1), +})); +jest.mock('@/lib/telemetry/reportError', () => ({ reportError: jest.fn() })); + +const mockReportError = reportError as jest.MockedFunction; + +describe('track() → Errors page', () => { + const dev = (global as any).__DEV__; + + beforeEach(() => { + (global as any).__DEV__ = false; + mockReportError.mockClear(); + }); + + afterEach(() => { + (global as any).__DEV__ = dev; + }); + + it('mirrors an error event with its Amplitude name and the call-site params', () => { + track('deposit_error', { error: 'boom', clientTxId: 'tx-1' }); + expect(mockReportError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'flow', + flow: 'deposit', + amplitudeEvent: 'Deposit Error', + message: 'boom', + refs: { clientTxId: 'tx-1' }, + }), + ); + }); + + it('still mirrors it when Amplitude is opted out for that event', () => { + track('card_activation_failed', { error: 'boom' }, { amplitude: false }); + expect(mockReportError).toHaveBeenCalledWith( + expect.objectContaining({ amplitudeEvent: 'Card Activation Failed', flow: 'card' }), + ); + }); + + it('leaves error_boundary to the boundary, so a crash is reported once', () => { + track('error_boundary', { message: 'boom' }); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('ignores events that are not failures', () => { + track('deposit_completed', {}); + track('deposit_cancelled', {}); + expect(mockReportError).not.toHaveBeenCalled(); + }); + + it('sends nothing from a dev build', () => { + (global as any).__DEV__ = true; + track('deposit_error', { error: 'boom' }); + expect(mockReportError).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/telemetry/errorEvents.ts b/lib/telemetry/errorEvents.ts new file mode 100644 index 000000000..22a4be212 --- /dev/null +++ b/lib/telemetry/errorEvents.ts @@ -0,0 +1,150 @@ +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { reportError, type ReportErrorInput } from '@/lib/telemetry/reportError'; + +import type { ErrorFlow } from '@/lib/telemetry/types'; + +/** + * Amplitude error events, mirrored to the admin Errors page. + * + * Failed flows are already tracked to Amplitude under names that say so + * (`deposit_error`, `card_activation_failed`, …), with the reason in whichever + * property that screen happened to use. This picks those events out by name and + * reads the reason back out, so each one also lands on the Errors page without + * touching the call sites. + */ + +/** Names ending in one of these are failures. */ +const ERROR_SUFFIXES = ['_failed', '_error', '_rejected', '_declined', '_blocked', '_unavailable']; + +/** Failures whose names do not say so. */ +const ERROR_EVENTS = new Set([ + TRACKING_EVENTS.ERROR_BOUNDARY, + TRACKING_EVENTS.REGION_UNAVAILABLE_SHOWN, + TRACKING_EVENTS.QR_SCANNER_PERMISSION_DENIED, +]); + +/** + * Names that match a suffix but are not something going wrong in the app: the + * OS has no review sheet, or an ad blocker kept a third-party widget out. + */ +const NOT_ERROR_EVENTS = new Set([ + TRACKING_EVENTS.STORE_REVIEW_UNAVAILABLE, + TRACKING_EVENTS.TRUSTPILOT_WIDGET_UNAVAILABLE, +]); + +/** + * Whether a tracked event records something failing. User decisions and + * impressions (`*_cancelled`, `*_viewed`, `*_pressed`) never match a suffix, so + * they stay out unless listed in {@link ERROR_EVENTS}. + */ +export const isErrorEvent = (name: string): boolean => { + if (!name || typeof name !== 'string') return false; + const event = name.toLowerCase(); + if (NOT_ERROR_EVENTS.has(event)) return false; + return ERROR_EVENTS.has(event) || ERROR_SUFFIXES.some(suffix => event.endsWith(suffix)); +}; + +/** First match wins, so the more specific prefixes come first. */ +const FLOW_PREFIXES: [prefix: string, flow: ErrorFlow][] = [ + [TRACKING_EVENTS.ERROR_BOUNDARY, 'app'], + ['card_deposit_', 'card_deposit'], + ['card_kyc_', 'kyc'], + ['kyc_link_', 'kyc'], + ['card_', 'card'], + ['deposit_', 'deposit'], + ['virtual_account_', 'deposit'], + ['withdraw_', 'withdraw'], + ['fast_withdraw_', 'withdraw'], + ['cancel_withdraw_', 'withdraw'], + ['send_', 'send'], + ['cross_chain_send_', 'send'], + ['swap_', 'swap'], + ['peg_swap_', 'swap'], + ['wrap_', 'swap'], + ['bridge_to_', 'bridge'], + ['buy_crypto_', 'buy_crypto'], + ['orchestra_', 'buy_crypto'], + ['onramper_', 'buy_crypto'], + ['cash_out_', 'cash_out'], + ['signup_', 'signup'], + ['email_verification_', 'signup'], + ['passkey_', 'signup'], + ['username_', 'signup'], + ['login_', 'login'], + ['tier_', 'tier'], + ['quest_', 'rewards'], +]; + +/** The product flow an event belongs to, from its name. */ +export const inferErrorFlow = (name: string): ErrorFlow => { + const event = name.toLowerCase(); + return FLOW_PREFIXES.find(([prefix]) => event.startsWith(prefix))?.[1] ?? 'other'; +}; + +/** A property as text: strings, numbers and Error-likes; anything else is ignored. */ +const textOf = (value: unknown): string | undefined => { + if (typeof value === 'string') return value.trim() || undefined; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value && typeof value === 'object' && typeof (value as Error).message === 'string') { + return (value as Error).message.trim() || undefined; + } + return undefined; +}; + +const firstText = (...values: unknown[]): string | undefined => { + for (const value of values) { + const text = textOf(value); + if (text) return text; + } + return undefined; +}; + +/** + * The Errors page report for a tracked error event. Call sites name the reason + * inconsistently, so every key in use is tried in order of how specific it is. + */ +export const toFlowErrorReport = ( + name: string, + amplitudeEvent: string, + params: Record = {}, +): ReportErrorInput => { + const clientTxId = firstText(params.clientTxId, params.client_tx_id); + + return { + kind: 'flow', + flow: inferErrorFlow(name), + amplitudeEvent, + message: + firstText( + params.error, + params.error_message, + params.message, + params.reason, + params.error_type, + params.error_cause_message, + ) ?? name, + code: firstText(params.error_code, params.code, params.error_kind, params.error_type), + step: firstText(params.step, params.failed_stage), + refs: clientTxId ? { clientTxId } : undefined, + // What was typed on the login screen: unverified, shown only to tell + // anonymous login failures apart. + claimedUsername: name === TRACKING_EVENTS.LOGIN_FAILED ? firstText(params.username) : undefined, + }; +}; + +/** + * Mirror a tracked error event to the Errors page. `error_boundary` is skipped: + * the boundary reports the crash itself, with the GlitchTip event id attached. + */ +export const reportFlowError = ( + name: string, + amplitudeEvent: string, + params: Record = {}, +) => { + try { + if (name === TRACKING_EVENTS.ERROR_BOUNDARY) return; + reportError(toFlowErrorReport(name, amplitudeEvent, params)); + } catch { + // Never let this reach the caller of track(). + } +}; diff --git a/lib/telemetry/errorIngestQueue.ts b/lib/telemetry/errorIngestQueue.ts new file mode 100644 index 000000000..1fd9b347b --- /dev/null +++ b/lib/telemetry/errorIngestQueue.ts @@ -0,0 +1,203 @@ +import { AppState, Platform } from 'react-native'; + +import { getJWTToken, getPlatformHeaders } from '@/lib/api'; +import { EXPO_PUBLIC_ERRORS_INGEST_ENABLED, EXPO_PUBLIC_FLASH_API_BASE_URL } from '@/lib/config'; +import { fetchWithTimeout } from '@/lib/fetchWithTimeout'; + +import type { ClientErrorEvent } from '@/lib/telemetry/types'; + +/** + * Batches app errors to the accounts service for the admin Errors page. + * + * Errors come in bursts — one failed request usually means a failed flow event + * and an error toast right behind it, and a broken screen can repeat the same + * failure on every render — so events are queued and sent together instead of + * one request each. Delivery is best effort: the queue is in memory only, a + * batch that fails twice is dropped, and nothing here ever throws or reports + * its own failures (that would feed straight back into the queue). + */ + +export const ERROR_INGEST_URL = `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/errors/ingest`; + +/** Send whatever is queued this long after the first event of a batch. */ +export const FLUSH_INTERVAL_MS = 2_000; +/** ...or straight away once this many are waiting. */ +export const FLUSH_AT = 20; +/** The endpoint takes 1..50 events per request. */ +export const MAX_BATCH = 50; +/** Past this the oldest events are dropped: a crash loop must not grow memory. */ +export const MAX_QUEUED = 200; +/** Identical events (kind + code + message + endpoint) inside this window are sent once. */ +export const DEDUPE_WINDOW_MS = 5_000; +/** One retry per batch, this long after the first attempt failed. */ +export const RETRY_DELAY_MS = 3_000; +/** A request that hangs would otherwise hold up every batch behind it. */ +const SEND_TIMEOUT_MS = 15_000; +/** Browsers refuse `keepalive` requests above 64 KiB, so a bigger page-hide flush goes without it. */ +const KEEPALIVE_MAX_BYTES = 60_000; + +/** + * Off in dev builds, when switched off by env, when there is no backend to send + * to, and while the web build is pre-rendered in Node (no `window`). + */ +export const isErrorIngestEnabled = (): boolean => + !__DEV__ && + EXPO_PUBLIC_ERRORS_INGEST_ENABLED && + !!EXPO_PUBLIC_FLASH_API_BASE_URL && + (Platform.OS !== 'web' || typeof window !== 'undefined'); + +let queue: ClientErrorEvent[] = []; +let flushTimer: ReturnType | null = null; +let flushing = false; +let lifecycleListening = false; +/** Dedupe key → when an event with that key was last accepted. */ +const recentlySeen = new Map(); + +const dedupeKey = (event: ClientErrorEvent) => + [event.kind, event.code ?? '', event.message, event.endpoint ?? ''].join('\u0000'); + +const pruneRecentlySeen = (now: number) => { + for (const [key, seenAt] of recentlySeen) { + if (now - seenAt >= DEDUPE_WINDOW_MS) recentlySeen.delete(key); + } +}; + +const scheduleFlush = () => { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flushErrorEvents(); + }, FLUSH_INTERVAL_MS); +}; + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +type SendResult = 'sent' | 'retry' | 'drop'; + +const post = async ( + body: string, + { anonymous, keepalive }: { anonymous: boolean; keepalive: boolean }, +): Promise => { + const headers: Record = { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + }; + const jwt = anonymous ? null : getJWTToken(); + if (jwt) headers.Authorization = `Bearer ${jwt}`; + + return fetchWithTimeout( + ERROR_INGEST_URL, + { + method: 'POST', + headers, + // Web authenticates with cookies; the anonymous retry must not send them. + credentials: anonymous ? 'omit' : 'include', + body, + ...(keepalive ? { keepalive: true } : {}), + }, + SEND_TIMEOUT_MS, + ); +}; + +/** + * One attempt at delivering a batch. A 401 is retried once straight away + * without credentials rather than refreshing the session: the endpoint takes + * anonymous events, and a refresh from here could loop with the very failure + * being reported. + */ +const send = async (events: ClientErrorEvent[], keepalive: boolean): Promise => { + try { + const body = JSON.stringify({ events }); + const useKeepalive = keepalive && body.length <= KEEPALIVE_MAX_BYTES; + let response = await post(body, { anonymous: false, keepalive: useKeepalive }); + if (response.status === 401) { + response = await post(body, { anonymous: true, keepalive: useKeepalive }); + } + if (response.ok) return 'sent'; + // Throttled or the server is struggling: worth one more try. Anything else + // (a 400 for a malformed batch, say) will fail the same way again. + return response.status === 429 || response.status >= 500 ? 'retry' : 'drop'; + } catch { + // Offline, timed out or cut off. + return 'retry'; + } +}; + +/** + * Send everything queued, in batches of {@link MAX_BATCH}. A batch that fails + * is retried once after {@link RETRY_DELAY_MS} and then dropped. + */ +export const flushErrorEvents = async ({ keepalive = false } = {}): Promise => { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + // The flush in flight picks up anything queued meanwhile. + if (flushing || queue.length === 0) return; + + flushing = true; + try { + while (queue.length > 0) { + const batch = queue.splice(0, MAX_BATCH); + if ((await send(batch, keepalive)) === 'retry') { + await wait(RETRY_DELAY_MS); + await send(batch, keepalive); + } + } + } catch { + // Never let delivery problems escape into the app. + } finally { + flushing = false; + if (queue.length > 0) scheduleFlush(); + } +}; + +/** Flush as the app leaves the foreground, while the JS thread still runs. */ +const listenForLifecycle = () => { + if (lifecycleListening) return; + lifecycleListening = true; + try { + AppState.addEventListener('change', state => { + if (state === 'background' || state === 'inactive') void flushErrorEvents(); + }); + if (Platform.OS === 'web' && typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('pagehide', () => void flushErrorEvents({ keepalive: true })); + } + } catch { + // Lifecycle flushing is a nicety; the interval flush still runs. + } +}; + +/** + * Queue an event for delivery. Returns false when it was dropped as a + * duplicate of one accepted within the last {@link DEDUPE_WINDOW_MS}. + */ +export const enqueueErrorEvent = (event: ClientErrorEvent): boolean => { + const now = Date.now(); + const key = dedupeKey(event); + const seenAt = recentlySeen.get(key); + if (seenAt !== undefined && now - seenAt < DEDUPE_WINDOW_MS) return false; + + if (recentlySeen.size >= MAX_QUEUED) pruneRecentlySeen(now); + recentlySeen.set(key, now); + + queue.push(event); + if (queue.length > MAX_QUEUED) queue.splice(0, queue.length - MAX_QUEUED); + + listenForLifecycle(); + if (queue.length >= FLUSH_AT) void flushErrorEvents(); + else scheduleFlush(); + return true; +}; + +/** Test seam: the module state would otherwise leak between cases. */ +export const __resetErrorIngestQueue = () => { + if (flushTimer) clearTimeout(flushTimer); + flushTimer = null; + flushing = false; + queue = []; + recentlySeen.clear(); +}; + +/** Test seam: what is waiting to be sent. */ +export const __getQueuedErrorEvents = (): readonly ClientErrorEvent[] => queue; diff --git a/lib/telemetry/installFetchReporter.ts b/lib/telemetry/installFetchReporter.ts new file mode 100644 index 000000000..8c0b79715 --- /dev/null +++ b/lib/telemetry/installFetchReporter.ts @@ -0,0 +1,190 @@ +import { + EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL, + EXPO_PUBLIC_FLASH_API_BASE_URL, + EXPO_PUBLIC_FLASH_REWARDS_API_BASE_URL, + EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL, +} from '@/lib/config'; +import { ERROR_INGEST_URL, isErrorIngestEnabled } from '@/lib/telemetry/errorIngestQueue'; +import { reportError } from '@/lib/telemetry/reportError'; +import { isNetworkError } from '@/lib/utils/userFacingError'; + +/** + * Failed calls to our own backend, reported to the admin Errors page. + * + * Most of lib/api.ts is plain `fetch` ending in `throw response`, so a failing + * endpoint left no trace anywhere unless the screen happened to track it. The + * global `fetch` is wrapped once at startup instead: for our own services only, + * a 4xx/5xx response and a connection failure are reported. The caller gets back + * exactly what `fetch` gave — the same Response object, or the same error — and + * every other host gets the untouched original promise. + */ + +const REPORTER_MARK = '__solidErrorReporter'; + +type MarkedFetch = typeof fetch & { [REPORTER_MARK]?: true }; + +/** `fetch` has one overload per lib (DOM, React Native); call it without picking one. */ +type AnyFetch = (this: unknown, ...args: unknown[]) => Promise; + +const OWN_BASE_URLS = [ + EXPO_PUBLIC_FLASH_API_BASE_URL, + EXPO_PUBLIC_FLASH_REWARDS_API_BASE_URL, + EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL, + EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL, +] + .filter(Boolean) + .map(base => base.replace(/\/+$/, '')); + +/** The URL of a `fetch` input: a string, a URL or a Request. */ +export const requestUrl = (input: unknown): string | undefined => { + try { + if (typeof input === 'string') return input; + if (input && typeof input === 'object') { + const { url, href } = input as { url?: unknown; href?: unknown }; + if (typeof url === 'string') return url; // Request + if (typeof href === 'string') return href; // URL + } + } catch { + // An exotic input with throwing getters: not ours to report. + } + return undefined; +}; + +const startsWithBase = (url: string, base: string) => + url.startsWith(base) && /^($|[/?#])/.test(url.slice(base.length)); + +/** + * One of our services, and not the ingest endpoint itself — reporting the + * reporter's own failures would feed them straight back into its queue. + */ +export const isReportableUrl = (url: string): boolean => + !url.startsWith(ERROR_INGEST_URL) && OWN_BASE_URLS.some(base => startsWithBase(url, base)); + +/** + * The path, without scheme, host, query or fragment. Parsed by hand: React + * Native's `URL` does not implement `pathname`. + */ +export const endpointOf = (url: string): string => { + const [withoutQuery] = url.split(/[?#]/, 1); + const schemeEnd = withoutQuery.indexOf('://'); + if (schemeEnd === -1) return withoutQuery || '/'; + const pathStart = withoutQuery.indexOf('/', schemeEnd + 3); + return pathStart === -1 ? '/' : withoutQuery.slice(pathStart); +}; + +/** The request method, from `init` or a Request input. */ +export const requestMethod = (input: unknown, init: unknown): string => { + try { + const method = + (init as RequestInit | undefined)?.method ?? (input as Request | undefined)?.method; + return typeof method === 'string' ? method.toUpperCase() : 'GET'; + } catch { + return 'GET'; + } +}; + +/** + * Failures that are answers, not errors: + * - 401: the token refresh handles it (and retries the call). + * - 404 on a read: "there is none" — the card status, Bridge customer, latest + * What's New and others answer that way on every app open for most users, and + * their callers turn it into `null`. + */ +export const isExpectedFailure = (status: number, method: string): boolean => + status === 401 || (status === 404 && (method === 'GET' || method === 'HEAD')); + +/** Our backend answers `{ code?, message }`, where `message` may be a list. */ +const errorBodyMessage = (body: any): string | undefined => { + const message = body?.message; + if (Array.isArray(message)) { + const joined = message.filter(part => typeof part === 'string' && part).join('; '); + if (joined) return joined; + } + if (typeof message === 'string' && message.trim()) return message; + if (typeof body?.error === 'string' && body.error.trim()) return body.error; + return undefined; +}; + +const reportFailedResponse = (response: Response, url: string, method: string) => { + const { status, statusText } = response; + if (status < 400 || isExpectedFailure(status, method)) return; + + const endpoint = endpointOf(url); + const report = (body?: any) => { + const code = body?.code; + reportError({ + kind: 'api', + httpStatus: status, + endpoint, + code: typeof code === 'string' || typeof code === 'number' ? code : undefined, + message: errorBodyMessage(body) || statusText || `HTTP ${status}`, + }); + }; + + // Cloned before the caller sees the response, so reading the copy can never + // race the caller reading (and locking) the original body. + let copy: Response | undefined; + try { + copy = response.clone(); + } catch { + copy = undefined; + } + if (!copy) { + report(); + return; + } + copy.json().then(report, () => report()); +}; + +const reportFailedRequest = (error: unknown, url: string) => { + if (!isNetworkError(error)) return; + reportError({ + kind: 'network', + severity: 'warning', + endpoint: endpointOf(url), + message: error instanceof Error ? error.message : String(error), + }); +}; + +/** Wrap the global `fetch` once; safe to call again (including after a fast refresh). */ +export const installFetchReporter = () => { + try { + if (!isErrorIngestEnabled()) return; + const original = globalThis.fetch as MarkedFetch | undefined; + if (typeof original !== 'function' || original[REPORTER_MARK]) return; + + const call = original as unknown as AnyFetch; + const reportingFetch = function (this: unknown, ...args: unknown[]) { + const pending = call.apply(this, args); + + const url = requestUrl(args[0]); + if (!url || !isReportableUrl(url)) return pending; + + return pending.then( + response => { + try { + reportFailedResponse(response, url, requestMethod(args[0], args[1])); + } catch { + // The response is the caller's whatever happens here. + } + return response; + }, + error => { + try { + reportFailedRequest(error, url); + } catch { + // Rethrown below regardless. + } + throw error; + }, + ); + } as MarkedFetch; + + // Keep anything hung on the original (whatwg-fetch sets `fetch.polyfill`). + Object.assign(reportingFetch, original); + reportingFetch[REPORTER_MARK] = true; + globalThis.fetch = reportingFetch; + } catch { + // Leave fetch untouched if anything about it is unexpected. + } +}; diff --git a/lib/telemetry/installToastReporter.ts b/lib/telemetry/installToastReporter.ts new file mode 100644 index 000000000..9c9da45c6 --- /dev/null +++ b/lib/telemetry/installToastReporter.ts @@ -0,0 +1,64 @@ +import Toast, { type ToastShowParams } from 'react-native-toast-message'; + +import { isErrorIngestEnabled } from '@/lib/telemetry/errorIngestQueue'; +import { reportError } from '@/lib/telemetry/reportError'; +import { USER_CANCELLED_MESSAGE, userFacingErrorMessage } from '@/lib/utils/userFacingError'; + +/** + * Every error toast, reported to the admin Errors page. + * + * An error toast is the one failure the user is certain to have seen, and there + * are well over a hundred call sites showing them. Rather than touch each one, + * `Toast.show` (a plain static on the library's component) is wrapped once at + * startup, so a toast is reported with exactly the text it was shown with. + */ + +const REPORTER_MARK = '__solidErrorReporter'; + +type MarkedShow = typeof Toast.show & { [REPORTER_MARK]?: true }; + +/** + * A dismissed passkey or wallet prompt is a decision, not a failure, but some + * screens still show it as an error toast. Those are reported as info so they + * stay countable without reading as faults. + */ +export const isCancellationText = (text: string): boolean => + userFacingErrorMessage(text) === USER_CANCELLED_MESSAGE || /\bcancell?ed\b/i.test(text); + +export const reportErrorToast = (params: ToastShowParams | undefined) => { + if (params?.type !== 'error') return; + const text1 = typeof params.text1 === 'string' ? params.text1.trim() : ''; + const text2 = typeof params.text2 === 'string' ? params.text2.trim() : ''; + const text = [text1, text2].filter(Boolean).join(' — '); + if (!text) return; + + reportError({ + kind: 'toast', + message: text, + userMessage: text, + severity: isCancellationText(text) ? 'info' : 'error', + }); +}; + +/** Wrap `Toast.show` once; safe to call again (including after a fast refresh). */ +export const installToastReporter = () => { + try { + if (!isErrorIngestEnabled()) return; + const original = Toast.show as MarkedShow; + if (typeof original !== 'function' || original[REPORTER_MARK]) return; + + const show: MarkedShow = function (this: unknown, params: ToastShowParams) { + const result = original.call(this, params); + try { + reportErrorToast(params); + } catch { + // Showing the toast is what matters. + } + return result; + }; + show[REPORTER_MARK] = true; + Toast.show = show; + } catch { + // Leave Toast untouched if anything about it is unexpected. + } +}; diff --git a/lib/telemetry/reportError.ts b/lib/telemetry/reportError.ts new file mode 100644 index 000000000..5851246b2 --- /dev/null +++ b/lib/telemetry/reportError.ts @@ -0,0 +1,170 @@ +import { Platform } from 'react-native'; +import * as Application from 'expo-application'; +import Constants from 'expo-constants'; +import * as Crypto from 'expo-crypto'; +import { getDeviceId } from '@amplitude/analytics-react-native'; + +import { enqueueErrorEvent, isErrorIngestEnabled } from '@/lib/telemetry/errorIngestQueue'; +import { redactSecrets } from '@/lib/utils/userFacingError'; + +import type { ClientErrorEvent, ClientErrorKind, ErrorFlow, Severity } from '@/lib/telemetry/types'; + +/** + * Report an app error to the admin Errors page. + * + * The single entry point for error toasts, failed API calls, crashes and failed + * flows (see the installers next to this file). It fills in the device context, + * strips credentials from free text, trims every field to what the ingest + * endpoint accepts and queues the event; it never throws and never sends from a + * dev build. + */ + +export type ReportErrorInput = { + kind: ClientErrorKind; + /** Technical detail. Falls back to `userMessage`, then to the kind. */ + message?: string; + /** Exactly what the user was shown. */ + userMessage?: string; + flow?: ErrorFlow; + step?: string; + code?: string | number; + severity?: Severity; + httpStatus?: number; + endpoint?: string; + amplitudeEvent?: string; + glitchtipEventId?: string; + claimedUsername?: string; + refs?: ClientErrorEvent['refs']; + /** Defaults to the screen the root layout last reported. */ + screen?: string; +}; + +/** Field limits from the ingest contract. */ +export const ERROR_FIELD_LIMITS = { + step: 64, + code: 64, + message: 2000, + userMessage: 500, + screen: 200, + appVersion: 32, + endpoint: 200, + claimedUsername: 64, +} as const; + +let currentScreen: string | undefined; + +/** Kept current by the root layout, so reports know where the user was. */ +export const setCurrentScreen = (pathname: string | null | undefined) => { + currentScreen = pathname || undefined; +}; + +/** Trimmed, cut to `max` characters (ending in an ellipsis), or undefined when empty. */ +export const truncateField = (value: unknown, max: number): string | undefined => { + if (value === undefined || value === null) return undefined; + const text = String(value).trim(); + if (!text) return undefined; + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +}; + +const getPlatform = (): ClientErrorEvent['platform'] => + Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : 'web'; + +let appVersion: string | undefined; + +// The installed native version, as the promotions banner gate reads it. Web has +// no native version and always runs the latest build, so it reports the +// version in app.config.ts instead. +const getAppVersion = (): string | undefined => { + if (appVersion === undefined) { + try { + appVersion = + truncateField( + Application.nativeApplicationVersion ?? Constants.expoConfig?.version, + ERROR_FIELD_LIMITS.appVersion, + ) ?? ''; + } catch { + appVersion = ''; + } + } + return appVersion || undefined; +}; + +// Read from the SDK rather than lib/analytics, which imports this module. +const getAmplitudeDeviceId = (): string | undefined => { + try { + return getDeviceId() || undefined; + } catch { + return undefined; + } +}; + +const fallbackUuid = () => + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, char => { + const random = (Math.random() * 16) | 0; + return (char === 'x' ? random : (random & 0x3) | 0x8).toString(16); + }); + +const newEventId = (): string => { + try { + const id = Crypto.randomUUID(); + if (typeof id === 'string' && id) return id; + } catch { + // No native module (or no secure context on web): fall back below. + } + return fallbackUuid(); +}; + +const cleanRefs = (refs: ReportErrorInput['refs']): ClientErrorEvent['refs'] => { + if (!refs) return undefined; + const entries = Object.entries(refs).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && !!entry[1], + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +}; + +/** The event exactly as it will be sent. Exported for tests. */ +export const buildClientErrorEvent = ( + input: ReportErrorInput, + now: Date = new Date(), +): ClientErrorEvent => { + const userMessage = input.userMessage ? redactSecrets(input.userMessage) : undefined; + const message = + truncateField( + input.message?.trim() ? redactSecrets(input.message) : userMessage, + ERROR_FIELD_LIMITS.message, + ) ?? input.kind; + + return { + id: newEventId(), + ts: now.toISOString(), + kind: input.kind, + flow: input.flow, + step: truncateField(input.step, ERROR_FIELD_LIMITS.step), + code: truncateField(input.code, ERROR_FIELD_LIMITS.code), + message, + userMessage: truncateField(userMessage, ERROR_FIELD_LIMITS.userMessage), + severity: input.severity, + screen: truncateField(input.screen ?? currentScreen, ERROR_FIELD_LIMITS.screen), + platform: getPlatform(), + appVersion: getAppVersion(), + httpStatus: + typeof input.httpStatus === 'number' && Number.isFinite(input.httpStatus) + ? input.httpStatus + : undefined, + endpoint: truncateField(input.endpoint, ERROR_FIELD_LIMITS.endpoint), + amplitudeEvent: input.amplitudeEvent || undefined, + glitchtipEventId: input.glitchtipEventId || undefined, + deviceId: getAmplitudeDeviceId(), + claimedUsername: truncateField(input.claimedUsername, ERROR_FIELD_LIMITS.claimedUsername), + refs: cleanRefs(input.refs), + }; +}; + +export const reportError = (input: ReportErrorInput): void => { + try { + if (!isErrorIngestEnabled()) return; + enqueueErrorEvent(buildClientErrorEvent(input)); + } catch { + // Reporting an error must never become one. + } +}; diff --git a/lib/telemetry/sentryUser.ts b/lib/telemetry/sentryUser.ts new file mode 100644 index 000000000..b18bdb96b --- /dev/null +++ b/lib/telemetry/sentryUser.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/react-native'; + +import { selectSelectedUser, useUserStore } from '@/store/useUserStore'; + +import type { User } from '@/lib/types'; + +/** + * GlitchTip's idea of who is signed in. + * + * The selected user in the store is the one source every sign-in path ends at — + * login, signup, a restored session, switching accounts — and every sign-out + * path clears, so following it covers them all. Always the backend user id + * (what the admin tools search by), never the Turnkey sub-organization id, and + * never the email: `sendDefaultPii` stays off. + */ + +export type SentryUser = { id: string; username?: string }; + +let current: SentryUser | null = null; +let installed = false; + +/** The signed-in user as last given to Sentry, or null when signed out. */ +export const getSentryUser = (): SentryUser | null => current; + +export const syncSentryUser = (user: Pick | null | undefined) => { + const next: SentryUser | null = user?.userId + ? { id: user.userId, ...(user.username ? { username: user.username } : {}) } + : null; + if (next?.id === current?.id && next?.username === current?.username) return; + + current = next; + try { + Sentry.setUser(next); + } catch { + // Error tracking must never break sign-in. + } +}; + +/** Follow the selected user from now on. Safe to call more than once. */ +export const installSentryUserSync = () => { + if (installed) return; + installed = true; + syncSentryUser(selectSelectedUser(useUserStore.getState())); + useUserStore.subscribe(state => syncSentryUser(selectSelectedUser(state))); +}; diff --git a/lib/telemetry/types.ts b/lib/telemetry/types.ts new file mode 100644 index 000000000..6be0bf44e --- /dev/null +++ b/lib/telemetry/types.ts @@ -0,0 +1,54 @@ +/** + * The app's half of the Errors page contract, copied verbatim from the backend + * (solid-backend is the source of truth; keep these in step with it). + * + * POST {EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/errors/ingest + * Body: { events: ClientErrorEvent[] } (1..50 events). Response 202 { accepted: number }. + * Auth is the app's normal auth; unauthenticated calls are accepted too + * (pre-login errors) but throttled harder by IP. + */ + +export type Severity = 'info' | 'warning' | 'error' | 'critical'; + +export type ErrorFlow = + | 'deposit' + | 'withdraw' + | 'send' + | 'swap' + | 'bridge' + | 'card' + | 'card_deposit' + | 'kyc' + | 'signup' + | 'login' + | 'buy_crypto' + | 'cash_out' + | 'savings' + | 'rewards' + | 'tier' + | 'app' + | 'other'; + +export interface ClientErrorEvent { + id: string; // client-generated UUID (dedupe key) + ts: string; // ISO-8601 time it happened on device + kind: 'toast' | 'api' | 'crash' | 'flow' | 'network'; + flow?: ErrorFlow; // server infers from amplitudeEvent/endpoint if absent + step?: string; // ≤ 64 chars + code?: string; // machine code, ≤ 64 chars (e.g. backend `code`, TransFi code) + message: string; // technical message, already redacted on device, ≤ 2000 chars + userMessage?: string; // exactly what the user saw (toast text), ≤ 500 chars + severity?: Severity; // hint only; server decides + screen?: string; // expo-router pathname, ≤ 200 chars + platform: 'ios' | 'android' | 'web'; + appVersion?: string; // ≤ 32 chars + httpStatus?: number; + endpoint?: string; // API path WITHOUT host or query string, ≤ 200 chars + amplitudeEvent?: string; // Title-Cased Amplitude event name, e.g. "Deposit Error" + glitchtipEventId?: string; // id returned by Sentry.captureException + deviceId?: string; // Amplitude device id (lets us group anonymous errors) + claimedUsername?: string; // username typed on login (NOT trusted, display only), ≤ 64 + refs?: { clientTxId?: string; activityId?: string; workflowId?: string }; +} + +export type ClientErrorKind = ClientErrorEvent['kind']; diff --git a/lib/utils/__tests__/userFacingError.test.ts b/lib/utils/__tests__/userFacingError.test.ts new file mode 100644 index 000000000..3b7a3c63d --- /dev/null +++ b/lib/utils/__tests__/userFacingError.test.ts @@ -0,0 +1,187 @@ +/// +import { + GENERIC_ERROR_MESSAGE, + isNetworkError, + isTechnicalErrorText, + NETWORK_FEE_UNAVAILABLE_MESSAGE, + NETWORK_UNREACHABLE_MESSAGE, + redactSecrets, + redactSecretsDeep, + sanitizeDisplayText, + USER_CANCELLED_MESSAGE, + userFacingErrorMessage, +} from '@/lib/utils/userFacingError'; + +const FAKE_KEY = 'pim_TestKey1234567890abc'; + +/** The shape viem produced for the Sep 30 card-activation failure, key swapped out. */ +const sponsorshipFailureMessage = [ + 'An internal error was received.', + '', + `URL: https://api.pimlico.io/v2/122/rpc?apikey=${FAKE_KEY}`, + 'Request body: {"method":"pm_getPaymasterData","params":[{"callData":"0x541d63c8","paymaster":"0x777777777777AeC03fd955926DbF81597e66834C"},"0x0000000071727De22E5E9d8BAf0edAc6f37da032","0x7a",{"sponsorshipPolicyId":"sp_test"}]}', + '', + 'Details: Insufficient Pimlico balance for sponsorship, please top up - Balance required: 0.000219 USD, Balance available: -1006.81455 USD', + 'Version: viem@2.47.10', +].join('\n'); + +const viemError = (message: string, details = '') => + Object.assign(new Error(message), { + name: 'InternalRpcError', + shortMessage: message.split('\n')[0], + details, + version: 'viem@2.47.10', + }); + +describe('redactSecrets', () => { + it('removes an API key from a URL query string', () => { + const out = redactSecrets(`https://api.pimlico.io/v2/122/rpc?apikey=${FAKE_KEY}&x=1`); + expect(out).not.toContain(FAKE_KEY); + expect(out).toContain('apikey=[redacted]&x=1'); + }); + + it('removes a bare Pimlico key and bearer tokens', () => { + const out = redactSecrets(`key ${FAKE_KEY} sent with Authorization: Bearer abc.def.ghi`); + expect(out).not.toContain(FAKE_KEY); + expect(out).not.toContain('abc.def.ghi'); + }); + + it('leaves ordinary copy untouched', () => { + const copy = 'Cards are not available in Bangladesh (BD) yet.'; + expect(redactSecrets(copy)).toBe(copy); + }); +}); + +describe('isTechnicalErrorText', () => { + it('flags the viem dump', () => { + expect(isTechnicalErrorText(sponsorshipFailureMessage)).toBe(true); + }); + + it('flags JS runtime errors', () => { + expect( + isTechnicalErrorText("TypeError: Cannot read properties of undefined (reading 'x')"), + ).toBe(true); + }); + + it('accepts short human copy', () => { + expect(isTechnicalErrorText('Insufficient balance')).toBe(false); + expect(isTechnicalErrorText('Invalid verification code. Please try again.')).toBe(false); + }); +}); + +describe('sanitizeDisplayText', () => { + it('replaces a dump with the fallback', () => { + expect(sanitizeDisplayText(sponsorshipFailureMessage)).toBe(GENERIC_ERROR_MESSAGE); + expect(sanitizeDisplayText(sponsorshipFailureMessage, 'Nope')).toBe('Nope'); + }); + + it('never returns the key, even through the fallback path', () => { + expect(sanitizeDisplayText(`Failed: ${FAKE_KEY}`)).not.toContain(FAKE_KEY); + }); + + it('keeps human copy as is', () => { + expect(sanitizeDisplayText('Withdrawal failed')).toBe('Withdrawal failed'); + }); +}); + +describe('userFacingErrorMessage', () => { + it('turns the reported card-activation failure into our own copy', () => { + const out = userFacingErrorMessage(viemError(sponsorshipFailureMessage)); + expect(out).toBe(NETWORK_FEE_UNAVAILABLE_MESSAGE); + expect(out).not.toContain(FAKE_KEY); + expect(out).not.toContain('1006'); + }); + + it('finds the sponsorship failure in a cause chain', () => { + const outer = Object.assign(new Error('UserOperation failed'), { + cause: viemError(sponsorshipFailureMessage), + }); + expect(userFacingErrorMessage(outer)).toBe(NETWORK_FEE_UNAVAILABLE_MESSAGE); + }); + + it('does not call every user-op failure a sponsorship failure', () => { + // Every sponsored op carries a `paymaster` field in the request body. + const err = viemError( + [ + 'Signature provided for the User Operation is invalid.', + `URL: https://api.pimlico.io/v2/122/rpc?apikey=${FAKE_KEY}`, + 'Request body: {"method":"eth_sendUserOperation","params":[{"paymaster":"0x7777"}]}', + 'Details: AA24 signature error', + 'Version: viem@2.47.10', + ].join('\n'), + 'AA24 signature error', + ); + expect(userFacingErrorMessage(err, 'Withdrawal failed.')).toBe('Withdrawal failed.'); + }); + + it('never passes a viem message through, even a short one', () => { + expect(userFacingErrorMessage(viemError('Execution reverted.'), 'Borrow failed.')).toBe( + 'Borrow failed.', + ); + }); + + it('recognises a dismissed passkey prompt', () => { + const err = Object.assign(new Error('The operation either timed out or was not allowed.'), { + name: 'NotAllowedError', + }); + expect(userFacingErrorMessage(err)).toBe(USER_CANCELLED_MESSAGE); + }); + + it('recognises an unreachable network', () => { + expect(userFacingErrorMessage(new TypeError('Network request failed'))).toBe( + NETWORK_UNREACHABLE_MESSAGE, + ); + // Safari's `fetch` failure. + expect(userFacingErrorMessage(new TypeError('Load failed'))).toBe(NETWORK_UNREACHABLE_MESSAGE); + }); + + it('lets copy our own code or backend wrote through', () => { + const apiError = Object.assign(new Error('Cards are not available in Bangladesh (BD) yet.'), { + status: 400, + }); + expect(userFacingErrorMessage(apiError)).toBe( + 'Cards are not available in Bangladesh (BD) yet.', + ); + expect(userFacingErrorMessage(new Error('Insufficient balance'))).toBe('Insufficient balance'); + }); + + it('falls back for empty, non-error and technical values', () => { + expect(userFacingErrorMessage(undefined)).toBe(GENERIC_ERROR_MESSAGE); + expect(userFacingErrorMessage({})).toBe(GENERIC_ERROR_MESSAGE); + expect(userFacingErrorMessage(new Error(''), 'Try later')).toBe('Try later'); + expect(userFacingErrorMessage(new TypeError('x is not a function'), 'Try later')).toBe( + 'Try later', + ); + }); +}); + +describe('redactSecretsDeep', () => { + it('scrubs strings nested in a Sentry-shaped event', () => { + const event = { + message: `boom ${FAKE_KEY}`, + exception: { values: [{ value: `URL: https://x/rpc?apikey=${FAKE_KEY}` }] }, + breadcrumbs: [{ message: 'ok', data: { url: `https://x/rpc?apikey=${FAKE_KEY}`, n: 1 } }], + }; + const out = redactSecretsDeep(event); + expect(JSON.stringify(out)).not.toContain(FAKE_KEY); + expect(out.breadcrumbs[0].data.n).toBe(1); + expect(out.breadcrumbs[0].message).toBe('ok'); + }); +}); + +describe('isNetworkError', () => { + it("recognises Chrome's, Safari's and React Native's wording, through a cause chain", () => { + expect(isNetworkError(new TypeError('Failed to fetch'))).toBe(true); + expect(isNetworkError(new TypeError('Load failed'))).toBe(true); + expect( + isNetworkError( + new Error('Signup failed', { cause: new TypeError('Network request failed') }), + ), + ).toBe(true); + }); + + it('is false for an answer from the server', () => { + expect(isNetworkError(new Error('Bad Gateway'))).toBe(false); + expect(isNetworkError(undefined)).toBe(false); + }); +}); diff --git a/lib/utils/userFacingError.ts b/lib/utils/userFacingError.ts new file mode 100644 index 000000000..19e672fbe --- /dev/null +++ b/lib/utils/userFacingError.ts @@ -0,0 +1,198 @@ +/** + * Turning a thrown error into text that is safe to put in front of a person. + * + * The app talks to bundlers, paymasters and RPC nodes directly, and viem builds + * its error messages for developers: the failing URL, the full request body and + * the upstream reply. On Sep 30 a card activation that hit an empty Pimlico + * sponsorship balance printed exactly that into a toast — including + * `?apikey=pim_…` from the RPC URL and our negative sponsorship balance — and the + * customer pasted it into a support chat. `error.message` from anything below + * our own copy is never a sentence for the user; it is evidence for Sentry. + * + * Kept free of viem and React imports so the toast renderer and Sentry's + * `beforeSend` can use it without pulling either in. + */ + +export const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.'; + +/** + * Our paymaster refused to sponsor the operation — today that means the + * sponsorship balance ran out. Nothing was sent on-chain, and it is ours to fix, + * so the copy says both rather than suggesting the user did something wrong. + */ +export const NETWORK_FEE_UNAVAILABLE_MESSAGE = + 'We could not cover the network fee right now. Nothing was charged — please try again in a few minutes.'; + +export const NETWORK_UNREACHABLE_MESSAGE = + 'We could not reach the network. Check your connection and try again.'; + +export const USER_CANCELLED_MESSAGE = 'Request cancelled.'; + +const REDACTED = '[redacted]'; + +/** + * Credentials that ride along in URLs and headers. Pimlico keys sit in the + * query string (`?apikey=pim_…`), so the URL alone is enough to leak one. + */ +const SECRET_PATTERNS: [RegExp, string][] = [ + [ + /\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|token|secret|key)=([^&\s"'<>]+)/gi, + `$1=${REDACTED}`, + ], + [/\bpim_[A-Za-z0-9]{6,}/g, REDACTED], + [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`], +]; + +/** Strip credentials from free text. Leaves everything else as it was. */ +export const redactSecrets = (text: string): string => + SECRET_PATTERNS.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text); + +/** + * Signs that a string was written for a developer, not a user: viem's + * `URL: / Request body: / Details: / Version:` layout, raw URLs, JSON, long hex + * blobs, JS runtime errors and stack frames. + */ +const TECHNICAL_PATTERNS: RegExp[] = [ + /https?:\/\//i, + /\b(api[_-]?key|apikey)\b/i, + /\bpim_[A-Za-z0-9]/, + /\b(request body|raw call arguments|contract call|docs):/i, + /\bdetails:/i, + /\bversion:\s*\S+@\d/i, + /\bviem@\d/i, + /0x[0-9a-fA-F]{64,}/, + /[{[]\s*"[^"]+"\s*:/, + /\n\s+at\s+\S+/, + /\b(TypeError|ReferenceError|SyntaxError|RangeError):/, + /\b(undefined|null) is not (an object|a function)/i, + /\bcannot read propert(y|ies) of\b/i, + /\bis not a function\b/i, + /\b(pm|eth|pimlico)_[a-zA-Z]+\b/, +]; + +/** Past this a message is a dump, not a sentence, whatever it contains. */ +const MAX_USER_MESSAGE_LENGTH = 240; + +/** Whether `text` looks like a raw technical error rather than copy for a user. */ +export const isTechnicalErrorText = (text: string): boolean => + text.length > MAX_USER_MESSAGE_LENGTH || TECHNICAL_PATTERNS.some(pattern => pattern.test(text)); + +/** + * Text that is safe to render: credentials removed, and anything that still + * reads as a developer dump replaced by `fallback`. The last line of defence for + * surfaces that take arbitrary strings, such as the toast renderer. + */ +export const sanitizeDisplayText = (text: string, fallback = GENERIC_ERROR_MESSAGE): string => { + const redacted = redactSecrets(text); + return isTechnicalErrorText(redacted) ? fallback : redacted; +}; + +type ErrorLike = { + name?: unknown; + message?: unknown; + shortMessage?: unknown; + details?: unknown; + version?: unknown; + cause?: unknown; +}; + +/** Every message in the error and its cause chain, for classification only. */ +const collectErrorText = (error: unknown, depth = 0): string => { + if (error == null || depth > 5) return ''; + if (typeof error === 'string') return error; + if (typeof error !== 'object') return String(error); + + const err = error as ErrorLike; + const parts = [err.name, err.message, err.shortMessage, err.details].filter( + (part): part is string => typeof part === 'string', + ); + return [...parts, collectErrorText(err.cause, depth + 1)].join('\n'); +}; + +/** viem's BaseError carries `version: 'viem@x.y.z'`; nothing it writes is user copy. */ +const isViemError = (error: unknown): boolean => { + const version = (error as ErrorLike | null)?.version; + return typeof version === 'string' && version.startsWith('viem@'); +}; + +const isCancellation = (text: string): boolean => + /\bNotAllowedError\b|user (rejected|denied|cancell?ed)|rejected by user|aborted by the user|operation either timed out or was not allowed/i.test( + text, + ); + +/** + * Matched on phrases, not on the word "paymaster": viem's message embeds the + * request body, and every sponsored user operation carries a `paymaster` field + * there whatever actually went wrong. `pm_getPaymaster*Data` only appears when + * that call is the one that failed, and AA3x are the EntryPoint's paymaster codes. + */ +const isSponsorshipFailure = (text: string): boolean => + /insufficient pimlico balance|for sponsorship|sponsorship (policy|balance)|pm_getPaymaster(Stub)?Data|pm_sponsorUserOperation|\bAA3\d\b/i.test( + text, + ); + +// `Load failed` is Safari's wording for what Chrome calls `Failed to fetch`. +const isNetworkFailure = (text: string): boolean => + /network request failed|failed to fetch|\bload failed\b|\bnetworkerror\b|request timed out|took too long to respond/i.test( + text, + ); + +/** Whether the connection failed, rather than anything at the other end. */ +export const isNetworkError = (error: unknown): boolean => + isNetworkFailure(collectErrorText(error)); + +/** + * The sentence to show for a failure, never the raw `error.message` of a + * dependency. + * + * Recognised failures — a dismissed passkey prompt, a paymaster that would not + * sponsor, an unreachable network — get our own copy. Messages our own code or + * backend wrote for people (`new Error('Insufficient balance')`, an `ApiError` + * carrying the server's message) pass through once they are confirmed not to be + * a dump. Everything else becomes `fallback`, which callers should make specific + * to what failed ("Withdrawal failed. Please try again."). + */ +export const userFacingErrorMessage = ( + error: unknown, + fallback: string = GENERIC_ERROR_MESSAGE, +): string => { + const text = collectErrorText(error); + + if (isCancellation(text)) return USER_CANCELLED_MESSAGE; + if (isSponsorshipFailure(text)) return NETWORK_FEE_UNAVAILABLE_MESSAGE; + if (isNetworkFailure(text)) return NETWORK_UNREACHABLE_MESSAGE; + + if (isViemError(error)) return fallback; + + const message = + typeof error === 'string' + ? error + : typeof (error as ErrorLike | null)?.message === 'string' + ? ((error as ErrorLike).message as string) + : ''; + const trimmed = message.trim(); + if (!trimmed) return fallback; + + return sanitizeDisplayText(trimmed, fallback); +}; + +/** + * Credentials scrubbed out of an arbitrary value — strings anywhere inside + * objects and arrays — for telemetry payloads such as Sentry events, where the + * raw viem message (and the API key in its URL) otherwise lands verbatim. + */ +export const redactSecretsDeep = (value: T, depth = 0): T => { + if (depth > 8 || value == null) return value; + if (typeof value === 'string') return redactSecrets(value) as T; + if (Array.isArray(value)) return value.map(item => redactSecretsDeep(item, depth + 1)) as T; + if (typeof value === 'object') { + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return value; + const out: Record = {}; + for (const [key, item] of Object.entries(value as Record)) { + out[key] = redactSecretsDeep(item, depth + 1); + } + return out as T; + } + return value; +};