Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <EXPO_PUBLIC_FLASH_API_BASE_URL>/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@<git-commit-sha> on
# web, <bundleId>@<version>+<build> on native). Only set this to override.
# EXPO_PUBLIC_SENTRY_RELEASE=
Expand Down
28 changes: 26 additions & 2 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -79,8 +83,9 @@ Sentry.init({
// `<bundleId>@<version>+<build>`, 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
Expand Down Expand Up @@ -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;
},

Expand All @@ -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 <AppErrorBoundary {...props} />;
}
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 20 additions & 6 deletions components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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]);
Expand Down
7 changes: 7 additions & 0 deletions lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
168 changes: 168 additions & 0 deletions lib/telemetry/__tests__/errorEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/// <reference types="jest" />

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<typeof reportError>;

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();
});
});
Loading
Loading