From 77817dbbc62d6ee5b4ee2d91d405929027566044 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:51:42 +0000 Subject: [PATCH 1/3] feat(telemetry): queue app errors for the admin Errors page Adds the app's half of the Errors page contract: `reportError()` builds a `ClientErrorEvent` (uuid, device time, platform, app version, current screen, Amplitude device id), strips credentials from the message and the text the user saw, cuts every field to the contract limits and queues it. The queue batches to POST /accounts/v1/errors/ingest every 2s or at 20 events (at most 50 per request, 200 held, oldest dropped), sends an identical kind+code+message+endpoint once per 5s, retries a failed batch once and then drops it, and flushes when the app goes to the background or the web page is hidden. It sends with the app's normal auth (Bearer + X-Platform on native, cookies on web) and resends a 401 once anonymously instead of refreshing, so an expired session cannot loop with the error being reported. Nothing is sent from dev builds, during web pre-rendering, or when EXPO_PUBLIC_ERRORS_INGEST_ENABLED=false. qa does not have lib/utils/userFacingError yet (master does, from the Pimlico key leak fix); it is copied verbatim from master, with its test, so the two merge cleanly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017rhTzZBfBRybucitFXRFMW --- .env.example | 4 + lib/config.ts | 7 + .../__tests__/errorIngestQueue.test.ts | 212 ++++++++++++++++++ lib/telemetry/__tests__/reportError.test.ts | 157 +++++++++++++ lib/telemetry/errorIngestQueue.ts | 203 +++++++++++++++++ lib/telemetry/reportError.ts | 170 ++++++++++++++ lib/telemetry/types.ts | 54 +++++ lib/utils/__tests__/userFacingError.test.ts | 187 +++++++++++++++ lib/utils/userFacingError.ts | 198 ++++++++++++++++ 9 files changed, 1192 insertions(+) create mode 100644 lib/telemetry/__tests__/errorIngestQueue.test.ts create mode 100644 lib/telemetry/__tests__/reportError.test.ts create mode 100644 lib/telemetry/errorIngestQueue.ts create mode 100644 lib/telemetry/reportError.ts create mode 100644 lib/telemetry/types.ts create mode 100644 lib/utils/__tests__/userFacingError.test.ts create mode 100644 lib/utils/userFacingError.ts 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/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__/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__/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/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/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/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; +}; From 27fc734d82eb808d2e47001bbfcf712c4e0c9515 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:51:54 +0000 Subject: [PATCH 2/3] feat(telemetry): report failed flows, error toasts, API failures and crashes - Failed flows: track() mirrors every error event (`*_failed`, `*_error`, `*_rejected`, `*_declined`, `*_blocked`, `*_unavailable`, plus region_unavailable_shown and qr_scanner_permission_denied) with its Title-Cased Amplitude name, a flow inferred from the name, and the reason read from whichever key the call site used. It does so even with `{ amplitude: false }`, which is about double-counting in Amplitude. The store-review and Trustpilot "unavailable" events are not app failures and stay out. - Error toasts: `Toast.show` is wrapped once, so every `type: 'error'` toast is reported with exactly the text it showed, without touching the call sites. A cancelled prompt is reported as info. - API failures: the global fetch is wrapped once. For our own services only (accounts, rewards, analytics, vault manager; never the ingest endpoint itself), a 4xx/5xx is reported with the backend's code and message read from a clone, and a connection failure is reported as a warning. The caller gets the same Response or the same error; other hosts get the original promise. 401s (the token refresh retries them) and 404s on reads (card status, Bridge customer, What's New and others answer "none" that way on every app open) are not failures. - Crashes: the error boundary sends one crash event carrying the id Sentry.captureException returned, so the Errors page can link to GlitchTip; its error_boundary track is not mirrored a second time. The installers are wired up in the next commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017rhTzZBfBRybucitFXRFMW --- components/ErrorBoundary.tsx | 26 +- lib/analytics.ts | 7 + lib/telemetry/__tests__/errorEvents.test.ts | 168 ++++++++++++ .../__tests__/installFetchReporter.test.ts | 257 ++++++++++++++++++ .../__tests__/installToastReporter.test.ts | 99 +++++++ .../__tests__/trackErrorEvents.test.ts | 89 ++++++ lib/telemetry/errorEvents.ts | 150 ++++++++++ lib/telemetry/installFetchReporter.ts | 190 +++++++++++++ lib/telemetry/installToastReporter.ts | 64 +++++ 9 files changed, 1044 insertions(+), 6 deletions(-) create mode 100644 lib/telemetry/__tests__/errorEvents.test.ts create mode 100644 lib/telemetry/__tests__/installFetchReporter.test.ts create mode 100644 lib/telemetry/__tests__/installToastReporter.test.ts create mode 100644 lib/telemetry/__tests__/trackErrorEvents.test.ts create mode 100644 lib/telemetry/errorEvents.ts create mode 100644 lib/telemetry/installFetchReporter.ts create mode 100644 lib/telemetry/installToastReporter.ts 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/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__/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__/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/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. + } +}; From b39fcb15b05c2aa13521eceab72fe373cec5f1b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:51:56 +0000 Subject: [PATCH 3/3] feat(telemetry): wire error reporting at startup and tell GlitchTip the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root layout installs the fetch and toast reporters before the app renders and keeps the current screen for error reports. GlitchTip now knows who hit an error: it follows the selected user in the store, which every sign-in path (login, signup, restored session, account switch) ends at and every sign-out clears, and sets `{ id: userId, username }` — the backend user id, never the Turnkey suborg id or the email; sendDefaultPii stays off. Captures that still pass `user: { id: suborgId }` inline replace the scope user for that event, so beforeSend puts the signed-in backend id back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017rhTzZBfBRybucitFXRFMW --- app/_layout.tsx | 28 +++++++++- lib/telemetry/__tests__/sentryUser.test.ts | 61 ++++++++++++++++++++++ lib/telemetry/sentryUser.ts | 45 ++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 lib/telemetry/__tests__/sentryUser.test.ts create mode 100644 lib/telemetry/sentryUser.ts 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/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/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))); +};