diff --git a/.changeset/silent-lemons-applaud.md b/.changeset/silent-lemons-applaud.md new file mode 100644 index 000000000..e9830c577 --- /dev/null +++ b/.changeset/silent-lemons-applaud.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🥅 report expected errors as sentry warnings diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 3b59d1a7a..7dc220c55 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -113,15 +113,17 @@ init({ integrations: [routingInstrumentation, userFeedback, ...(__DEV__ || e2e ? [] : [mobileReplayIntegration()])], _experiments: __DEV__ || e2e ? undefined : { replaysOnErrorSampleRate: 1, replaysSessionSampleRate: 0.01 }, beforeSend: (event, hint) => { + let known = false; for (const source of [ hint.originalException, ...(event.exception?.values?.map(({ value }) => value) ?? []), event.message, ]) { - const { expected, fingerprint } = classifyError(source); - if (expected) return null; - event.fingerprint ??= fingerprint; + const classification = classifyError(source); + if (classification.known) known = true; + event.fingerprint ??= classification.fingerprint; } + if (known) event.level = "warning"; return event; }, spotlight: __DEV__ || !!e2e, diff --git a/src/utils/queryClient.ts b/src/utils/queryClient.ts index 5d08baf78..845fcb153 100644 --- a/src/utils/queryClient.ts +++ b/src/utils/queryClient.ts @@ -16,12 +16,12 @@ import type { PersistedClient } from "@tanstack/query-persist-client-core"; import type { Address } from "viem"; const INVALIDATE_ON_UPGRADE = new Set(["kyc", "card", "pax"]); -const expected = (error: unknown) => - error instanceof APIError && - (error.text === "no kyc" || - error.text === "not started" || - error.text === "bad kyc" || - error.text === "kyc required"); + +function triage(error: unknown) { + if (!(error instanceof APIError)) return; + if (error.text === "bad kyc") return "warn"; + if (error.text === "no kyc" || error.text === "not started" || error.text === "kyc required") return "drop"; +} function versionAwareDeserialize(cache: string): PersistedClient { const persistedClient: PersistedClient = deserialize(cache); @@ -42,11 +42,15 @@ export const persister = createAsyncStoragePersister({ const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { - if (query.meta?.suppressError?.(error)) return; + if (query.meta?.dropError?.(error)) return; + if (query.meta?.warnError?.(error)) return reportError(error, { level: "warning" }); + if (query.queryKey[0] !== "auth" && queryClient.getQueryState(["auth"])?.error === error) return; if (error instanceof Error && error.message === "don't refetch") return; if (error instanceof APIError) { if (error.code === 401 && error.text === "unauthorized") return; - if (expected(error)) return; + const value = triage(error); + if (value === "warn") return reportError(error, { level: "warning" }); + if (value === "drop") return; } reportError(error); }, @@ -274,8 +278,8 @@ queryClient.setQueryDefaults(["embedding-context"], { queryClient.setQueryDefaults(["kyc", "status"], { staleTime: 5 * 60_000, gcTime: 60 * 60_000, - retry: (count, error) => count < 3 && !expected(error), - meta: { suppressError: expected }, + retry: (count, error) => count < 3 && triage(error) === undefined, + meta: { warnError: (error) => triage(error) === "warn" }, }); export type AuthMethod = "siwe" | "webauthn"; @@ -314,6 +318,9 @@ export class APIError extends Error { declare module "@tanstack/react-query" { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- module augmentation requires interface merging interface Register { - queryMeta: { suppressError?: (error: unknown) => boolean | undefined }; + queryMeta: { + dropError?: (error: unknown) => boolean | undefined; + warnError?: (error: unknown) => boolean | undefined; + }; } } diff --git a/src/utils/reportError.ts b/src/utils/reportError.ts index 0323fc404..572b2125e 100644 --- a/src/utils/reportError.ts +++ b/src/utils/reportError.ts @@ -3,22 +3,17 @@ import { captureException, withScope } from "@sentry/react-native"; export default function reportError(error: unknown, hint?: Parameters[1]) { console.error(error); // eslint-disable-line no-console const classification = classify(parseError(error)); - if (classification.expected) return; try { - if (hint) { - const value = classification.fingerprint; - if (value === undefined) return captureException(error, hint); - let eventId: ReturnType | undefined; - withScope((scope) => { - scope.setFingerprint(value); - eventId = captureException(error, hint); - }); - return eventId; - } - return captureException( - error, - classification.fingerprint ? { fingerprint: classification.fingerprint } : undefined, - ); + const value = classification.fingerprint; + const known = classification.known; + if (!known && value === undefined) return captureException(error, hint); + let eventId: ReturnType | undefined; + withScope((scope) => { + if (known) scope.setLevel("warning"); + if (value !== undefined) scope.setFingerprint(value); + eventId = captureException(error, hint); + }); + return eventId; } catch (sentryError) { console.error(sentryError); // eslint-disable-line no-console } @@ -30,7 +25,7 @@ const passkeyCancelledMessages = new Set([ "The operation couldn’t be completed. Device must be unlocked to perform request.", "UserCancelled", ]); -const passkeyExpectedMessages = new Set([ +const passkeyKnownMessages = new Set([ ...passkeyCancelledMessages, "The operation couldn’t be completed. Stolen Device Protection is enabled and biometry is required.", ]); @@ -43,27 +38,10 @@ const networkTypes = [ ] as const; type ParsedError = ReturnType; -export function isPasskeyExpected(error: unknown) { - const classification = classify(parseError(error)); - return classification.passkeyExpected || classification.passkeyNameExpected; -} - export function isPasskeyCancelled(error: unknown) { return classify(parseError(error)).passkeyCancelled; } -export function isAuthExpected(error: unknown) { - return classify(parseError(error)).authExpected; -} - -export function isExpected(error: unknown) { - return classify(parseError(error)).expected; -} - -export function fingerprint(error: unknown) { - return classify(parseError(error)).fingerprint; -} - export function classifyError(error: unknown) { return classify(parseError(error)); } @@ -109,19 +87,21 @@ function parseError(error: unknown) { } function classify({ code, name, message, status }: ParsedError) { - const passkeyNameExpected = name === "NotAllowedError"; + const passkeyNotAllowed = + name === "NotAllowedError" || (message !== undefined && authPrefixes.some((prefix) => message.startsWith(prefix))); const passkeyCancelled = message !== undefined && passkeyCancelledMessages.has(message); - const passkeyExpected = + const passkeyKnown = message !== undefined && - (passkeyExpectedMessages.has(message) || + (passkeyKnownMessages.has(message) || message.includes("Biometrics must be enabled") || message.includes("There is already a pending passkey request") || authPrefixes.some((prefix) => message.startsWith(prefix))); + const passkeyWarning = passkeyKnown && !passkeyCancelled && !passkeyNotAllowed; const biometric = code === "ERR_BIOMETRIC"; - const authExpected = passkeyExpected || passkeyNameExpected || biometric || message === "invalid operation"; + const authKnown = passkeyKnown || passkeyNotAllowed || biometric || message === "invalid operation"; const network = classifyNetwork(message); - const expected = - passkeyExpected || + const known = + passkeyKnown || biometric || message === "invalid operation" || message === "Network request failed" || @@ -145,7 +125,7 @@ function classify({ code, name, message, status }: ParsedError) { : ["{{ default }}", fingerprintMessage] : ["{{ default }}", network] : ["{{ default }}", code]); - return { passkeyExpected, passkeyCancelled, passkeyNameExpected, authExpected, expected, fingerprint: value }; + return { passkeyKnown, passkeyCancelled, passkeyNotAllowed, passkeyWarning, authKnown, known, fingerprint: value }; } function normalizeMessage(message: string) { diff --git a/src/utils/server.ts b/src/utils/server.ts index 483eaed70..984d7171f 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -5,6 +5,7 @@ import { sdk } from "@farcaster/miniapp-sdk"; import { getConnection, signMessage } from "@wagmi/core"; import { hc } from "hono/client"; import { check, number, parse, pipe, safeParse, ValiError } from "valibot"; +import { UserRejectedRequestError } from "viem"; import AUTH_EXPIRY from "@exactly/common/AUTH_EXPIRY"; import deriveAddress from "@exactly/common/deriveAddress"; @@ -14,7 +15,7 @@ import { Credential } from "@exactly/common/validation"; import { login as loginIntercom, logout as logoutIntercom } from "./intercom"; import { decrypt, decryptPIN, encryptPIN, session } from "./panda"; import queryClient, { APIError, type AuthMethod } from "./queryClient"; -import { isPasskeyExpected } from "./reportError"; +import { classifyError } from "./reportError"; import ownerConfig from "./wagmi/owner"; import type { ExaAPI } from "@exactly/server/api"; // eslint-disable-line @nx/enforce-module-boundaries @@ -62,7 +63,13 @@ queryClient.setQueryDefaults(["auth"], { return parse(Auth, expires); }, meta: { - suppressError: (error) => error instanceof ValiError || isPasskeyExpected(error), + dropError: (error) => { + if (error instanceof ValiError) return true; + if (error instanceof UserRejectedRequestError) return true; + const { passkeyCancelled, passkeyNotAllowed } = classifyError(error); + return passkeyCancelled || passkeyNotAllowed; + }, + warnError: (error) => classifyError(error).passkeyWarning, }, }); diff --git a/src/utils/useAuth.ts b/src/utils/useAuth.ts index 7d73e7a79..ff5baaf9f 100644 --- a/src/utils/useAuth.ts +++ b/src/utils/useAuth.ts @@ -12,7 +12,7 @@ import chain from "@exactly/common/generated/chain"; import alchemyConnector from "./alchemyConnector"; import queryClient, { type AuthMethod } from "./queryClient"; -import reportError, { isAuthExpected } from "./reportError"; +import reportError, { classifyError } from "./reportError"; import { APIError, createCredential, getCredential } from "./server"; import ownerConfig, { getConnector as getOwnerConnector } from "./wagmi/owner"; @@ -36,8 +36,8 @@ export default function useAuth(onDomainError: () => void, onSuccess?: (credenti return credential; }, onSuccess, - onError: (error: unknown) => { - handleError(error, toast, onDomainError, t); + onError: (error: unknown, { method, register }) => { + handleError(error, toast, onDomainError, t, method === "siwe" || !register); }, }); return { signIn, ...mutation }; @@ -48,11 +48,13 @@ function handleError( toast: ReturnType, onDomainError: () => void, t: TFunction, + auth: boolean, ) { if ( error instanceof Error && (("code" in error && error.code === "ERR_BIOMETRIC") || error.message.includes("Biometrics must be enabled")) ) { + if (!auth) reportError(error); queryClient.setQueryData(["method"], undefined); toast.show(t("Biometrics must be enabled to use passkeys. Please enable biometrics in your device settings"), { native: true, @@ -61,7 +63,10 @@ function handleError( }); return; } - if (isAuthExpected(error) || error instanceof UserRejectedRequestError) { + const { authKnown, passkeyCancelled, passkeyNotAllowed } = classifyError(error); + if (authKnown || error instanceof UserRejectedRequestError) { + const cancelled = passkeyCancelled || passkeyNotAllowed || error instanceof UserRejectedRequestError; + if (!cancelled && !auth) reportError(error); queryClient.setQueryData(["method"], undefined); toast.show(t("Authentication cancelled"), { native: true, @@ -71,6 +76,7 @@ function handleError( return; } if (error instanceof APIError && error.text === "backup eligibility required") { + reportError(error, { level: "warning" }); toast.show(t("Your password manager does not support passkey backups. Please try a different one"), { native: true, duration: 1000, @@ -84,5 +90,6 @@ function handleError( ) { onDomainError(); } + if (auth && queryClient.getQueryState(["auth"])?.error === error) return; reportError(error); }