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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silent-lemons-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/mobile": patch
---

🥅 report expected errors as sentry warnings
8 changes: 5 additions & 3 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 18 additions & 11 deletions src/utils/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
},
Expand Down Expand Up @@ -274,8 +278,8 @@ queryClient.setQueryDefaults<EmbeddingContext>(["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";
Expand Down Expand Up @@ -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;
};
}
}
60 changes: 20 additions & 40 deletions src/utils/reportError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,17 @@ import { captureException, withScope } from "@sentry/react-native";
export default function reportError(error: unknown, hint?: Parameters<typeof captureException>[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<typeof captureException> | 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<typeof captureException> | 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
}
Expand All @@ -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.",
]);
Expand All @@ -43,27 +38,10 @@ const networkTypes = [
] as const;
type ParsedError = ReturnType<typeof parseError>;

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));
}
Expand Down Expand Up @@ -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" ||
Expand All @@ -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) {
Expand Down
11 changes: 9 additions & 2 deletions src/utils/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -62,7 +63,13 @@ queryClient.setQueryDefaults<number | undefined>(["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,
},
});

Expand Down
15 changes: 11 additions & 4 deletions src/utils/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 };
Expand All @@ -48,11 +48,13 @@ function handleError(
toast: ReturnType<typeof useToastController>,
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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -84,5 +90,6 @@ function handleError(
) {
onDomainError();
}
if (auth && queryClient.getQueryState(["auth"])?.error === error) return;
reportError(error);
}