From dee80290b9d02b6eec8c37006ad927183b0e7e14 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Sat, 27 Jun 2026 01:09:06 +0200 Subject: [PATCH] feat(widget): add scoped better stack error tracking --- packages/widget/package.json | 1 + packages/widget/src/App.tsx | 88 +++++- .../molecules/rich-error-modal/index.tsx | 49 ++- packages/widget/src/hooks/use-rich-errors.ts | 2 +- .../src/providers/error-tracking/index.tsx | 290 ++++++++++++++++++ packages/widget/src/providers/index.tsx | 95 +++--- .../src/translation/English/translations.json | 2 + .../src/translation/French/translations.json | 2 + pnpm-lock.yaml | 70 +++++ pnpm-workspace.yaml | 1 + 10 files changed, 531 insertions(+), 69 deletions(-) create mode 100644 packages/widget/src/providers/error-tracking/index.tsx diff --git a/packages/widget/package.json b/packages/widget/package.json index c26d1b74..3e095310 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -91,6 +91,7 @@ "@rolldown/plugin-babel": "catalog:", "@safe-global/safe-apps-provider": "catalog:", "@safe-global/safe-apps-sdk": "catalog:", + "@sentry/react": "catalog:", "@solana/wallet-adapter-base": "catalog:", "@solana/wallet-adapter-react": "catalog:", "@solana/wallet-adapter-wallets": "catalog:", diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx index 8bc4be7c..d26e2979 100644 --- a/packages/widget/src/App.tsx +++ b/packages/widget/src/App.tsx @@ -3,17 +3,34 @@ import "./translation"; import "./utils/extend-purify"; import "./styles/theme/global.css"; import type { ComponentProps, RefObject } from "react"; -import { createRef, useImperativeHandle, useState } from "react"; +import { + createRef, + useEffect, + useImperativeHandle, + useReducer, + useState, +} from "react"; import ReactDOM from "react-dom/client"; -import { createMemoryRouter, RouterProvider } from "react-router"; +import { I18nextProvider } from "react-i18next"; +import { + createMemoryRouter, + RouterProvider, + useRouteError, +} from "react-router"; import { preloadImages } from "./assets/images"; import { Box } from "./components/atoms/box"; import { Dashboard } from "./Dashboard"; import { Providers } from "./providers"; +import { + captureWidgetException, + ErrorTrackingProvider, + WidgetErrorDialog, +} from "./providers/error-tracking"; import { SettingsContextProvider, useSettings } from "./providers/settings"; import type { SettingsProps, VariantProps } from "./providers/settings/types"; +import { ThemeWrapper } from "./providers/theme-wrapper"; import { appContainer } from "./style.css"; -import { useLoadErrorTranslations } from "./translation"; +import { i18nInstance, useLoadErrorTranslations } from "./translation"; import { Widget } from "./Widget"; preloadImages(); @@ -32,6 +49,18 @@ const Root = () => ( ); +const RouteErrorBoundary = ({ onRetry }: { onRetry: () => void }) => { + const error = useRouteError(); + + useEffect(() => { + captureWidgetException(error, { + mechanism: "react-router-error-boundary", + }); + }, [error]); + + return ; +}; + export type SKAppProps = SettingsProps & (VariantProps | { variant?: never }); export const SKApp = (props: SKAppProps) => { @@ -40,19 +69,34 @@ export const SKApp = (props: SKAppProps) => { ? { variant: props.variant, chainModal: props.chainModal } : { variant: props.variant ?? "default" }; + const [routerVersion, resetRouter] = useReducer((state) => state + 1, 0); const [router] = useState(() => - createMemoryRouter([{ path: "*", Component: Root }]) + createMemoryRouter([ + { + path: "*", + Component: Root, + errorElement: ( + + ), + }, + ]) ); return ( - - - + + + + + + + + + ); }; @@ -79,7 +123,27 @@ export const renderSKWidget = ({ }) => { if (!rest.apiKey) throw new Error("API key is required"); - const root = ReactDOM.createRoot(container); + const root = ReactDOM.createRoot(container, { + onCaughtError: (error, errorInfo) => { + captureWidgetException(error, { + mechanism: "react-root-caught-error", + componentStack: errorInfo.componentStack ?? undefined, + }); + }, + onRecoverableError: (error, errorInfo) => { + captureWidgetException(error, { + mechanism: "react-root-recoverable-error", + componentStack: errorInfo.componentStack ?? undefined, + }); + }, + onUncaughtError: (error, errorInfo) => { + captureWidgetException(error, { + mechanism: "react-root-uncaught-error", + componentStack: errorInfo.componentStack ?? undefined, + handled: false, + }); + }, + }); const appRef = createRef<{ rerender: () => void }>() as NonNullable< BundledSKWidgetProps["ref"] diff --git a/packages/widget/src/components/molecules/rich-error-modal/index.tsx b/packages/widget/src/components/molecules/rich-error-modal/index.tsx index 6d8240df..43db83ba 100644 --- a/packages/widget/src/components/molecules/rich-error-modal/index.tsx +++ b/packages/widget/src/components/molecules/rich-error-modal/index.tsx @@ -1,16 +1,33 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { images } from "../../../assets/images"; -import { useRichErrors } from "../../../hooks/use-rich-errors"; +import { type RichError, useRichErrors } from "../../../hooks/use-rich-errors"; import { Box } from "../../atoms/box"; +import { Button } from "../../atoms/button"; import { SelectModal } from "../../atoms/select-modal"; import { Heading } from "../../atoms/typography/heading"; import { Text } from "../../atoms/typography/text"; import { imageStyle } from "./style.css"; -export const RichErrorModal = () => { +type Props = { + error?: RichError | null; + isOpen?: boolean; + onClose?: () => void; + action?: { label: string; onClick: () => void }; + description?: string; +}; + +export const RichErrorModal = ({ + error: controlledError, + isOpen, + onClose, + action, + description, +}: Props = {}) => { const { i18n, t } = useTranslation(); - const { error, resetError } = useRichErrors(); + const { error: richError, resetError } = useRichErrors(); + const error = controlledError === undefined ? richError : controlledError; + const close = onClose ?? resetError; const { message, details } = error ?? {}; const hasKnownMessage = message ? i18n.exists(`errors.${message}`) : false; @@ -18,8 +35,8 @@ export const RichErrorModal = () => { return ( !isOpen && resetError() }} - onClose={resetError} + state={{ isOpen: isOpen ?? !!error, setOpen: (open) => !open && close() }} + onClose={close} > { > {!message && ( - + {t("shared.something_went_wrong")} + + {description && ( + + {description} + + )} )} @@ -88,6 +115,16 @@ export const RichErrorModal = () => { )} + + {action && ( + + + + )} ); diff --git a/packages/widget/src/hooks/use-rich-errors.ts b/packages/widget/src/hooks/use-rich-errors.ts index a761cae7..8def5656 100644 --- a/packages/widget/src/hooks/use-rich-errors.ts +++ b/packages/widget/src/hooks/use-rich-errors.ts @@ -2,7 +2,7 @@ import { useCallback, useSyncExternalStore } from "react"; import { BehaviorSubject } from "rxjs"; import { config } from "../config"; -interface RichError { +export interface RichError { message: string; details?: { [key: string]: unknown }; } diff --git a/packages/widget/src/providers/error-tracking/index.tsx b/packages/widget/src/providers/error-tracking/index.tsx new file mode 100644 index 00000000..41d388b8 --- /dev/null +++ b/packages/widget/src/providers/error-tracking/index.tsx @@ -0,0 +1,290 @@ +import { + BrowserClient, + defaultStackParser, + type ErrorEvent, + makeFetchTransport, + Scope, +} from "@sentry/react"; +import { + Component, + type ErrorInfo, + type PropsWithChildren, + useEffect, +} from "react"; +import { useTranslation } from "react-i18next"; +import { RichErrorModal } from "../../components/molecules/rich-error-modal"; +import { config } from "../../config"; +import { useSKLocation } from "../location"; +import { useSettings } from "../settings"; +import { useSKWallet } from "../sk-wallet"; + +const betterStackDsn = + "https://k3dgyiYydYXyFwe7MPsNWk3N@s2546777.us-east-9.betterstackdata.com/2546777"; + +type WidgetErrorContext = { + walletAddress?: string; + network?: string; + chainId?: number; + route?: string; + variant?: string; + mode?: "dashboard" | "widget"; +}; + +type CaptureDetails = { + mechanism: string; + componentStack?: string; + handled?: boolean; +}; + +type ErrorTrackingState = { + client: BrowserClient; + scope: Scope; +}; + +let errorTrackingState: ErrorTrackingState | null = null; +let latestWidgetContext: WidgetErrorContext = {}; +const capturedErrorObjects = new WeakSet(); + +const sensitiveKeyPattern = + /authorization|password|secret|private[-_]?key|seed|mnemonic|rawarguments|unsignedtransaction|signedtx|payload|balance|amount/i; + +const redacted = "[Filtered]"; + +const redactedString = (value: string) => + value + .replace(/(authorization)(["'`\s:=]+)([^"',\s}]+)/gi, `$1$2${redacted}`) + .replace(/(https?:\/\/)([^@\s]+)@/gi, `$1${redacted}@`); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const scrubValue = (value: unknown, depth = 0): unknown => { + if (typeof value === "string") { + return redactedString(value); + } + + if (depth > 4) { + return "[Truncated]"; + } + + if (Array.isArray(value)) { + return value.map((item) => scrubValue(item, depth + 1)); + } + + if (!isRecord(value)) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, innerValue]) => [ + key, + sensitiveKeyPattern.test(key) + ? redacted + : scrubValue(innerValue, depth + 1), + ]) + ); +}; + +const scrubEvent = (event: ErrorEvent): ErrorEvent => ({ + ...event, + breadcrumbs: undefined, + contexts: scrubValue(event.contexts) as ErrorEvent["contexts"], + extra: scrubValue(event.extra) as ErrorEvent["extra"], + request: scrubValue(event.request) as ErrorEvent["request"], +}); + +const beforeSend = (event: ErrorEvent) => scrubEvent(event); + +const getErrorTrackingState = () => { + if (!errorTrackingState) { + const client = new BrowserClient({ + dsn: betterStackDsn, + enabled: !config.env.isTestMode, + environment: import.meta.env.MODE, + integrations: [], + maxBreadcrumbs: 0, + normalizeDepth: 4, + sendDefaultPii: false, + stackParser: defaultStackParser, + tracesSampleRate: undefined, + transport: makeFetchTransport, + beforeSend, + }); + const scope = new Scope(); + + client.init(); + scope.setClient(client); + + errorTrackingState = { client, scope }; + } + + return errorTrackingState; +}; + +const setTag = ( + scope: Scope, + key: string, + value: string | number | boolean | undefined +) => { + if (value === undefined) { + return; + } + + scope.setTag(key, value); +}; + +const applyWidgetContext = (scope: Scope, details: CaptureDetails) => { + const context = latestWidgetContext; + + scope.setLevel("error"); + scope.setTag("widget", "stakekit"); + setTag(scope, "widget.mode", context.mode); + setTag(scope, "widget.variant", context.variant); + setTag(scope, "widget.route", context.route); + setTag(scope, "walletAddress", context.walletAddress); + setTag(scope, "wallet.network", context.network); + setTag(scope, "wallet.chainId", context.chainId); + + if (context.walletAddress) { + scope.setUser({ id: context.walletAddress }); + } + + scope.setContext("stakekit_widget", { + ...context, + captureMechanism: details.mechanism, + }); + + if (context.walletAddress || context.network || context.chainId) { + scope.setContext("wallet", { + address: context.walletAddress, + network: context.network, + chainId: context.chainId, + }); + } + + if (details.componentStack) { + scope.setContext("react", { + componentStack: details.componentStack, + }); + } +}; + +const trackCapturedObject = (error: unknown) => { + if (!isRecord(error)) { + return false; + } + + if (capturedErrorObjects.has(error)) { + return true; + } + + capturedErrorObjects.add(error); + return false; +}; + +export const captureWidgetException = ( + error: unknown, + details: CaptureDetails +) => { + if (trackCapturedObject(error)) { + return; + } + + const state = getErrorTrackingState(); + + if (!state) { + return; + } + + const scope = state.scope.clone(); + + applyWidgetContext(scope, details); + + scope.captureException(error, { + mechanism: { + handled: details.handled ?? true, + type: details.mechanism, + }, + }); +}; + +const updateWidgetErrorContext = (context: WidgetErrorContext) => { + latestWidgetContext = context; +}; + +export const WidgetErrorDialog = ({ onRetry }: { onRetry: () => void }) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +class WidgetErrorBoundary extends Component< + PropsWithChildren, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + captureWidgetException(error, { + mechanism: "react-error-boundary", + componentStack: errorInfo.componentStack ?? undefined, + }); + } + + reset = () => { + this.setState({ hasError: false }); + }; + + render() { + if (this.state.hasError) { + return ; + } + + return this.props.children; + } +} + +export const ErrorTrackingProvider = ({ children }: PropsWithChildren) => ( + {children} +); + +export const WidgetErrorTrackingContextSync = () => { + const { dashboardVariant, variant } = useSettings(); + const { current } = useSKLocation(); + const wallet = useSKWallet(); + const chainId = wallet.chain?.id; + const network = wallet.network ?? undefined; + const walletAddress = wallet.address ?? undefined; + + useEffect(() => { + updateWidgetErrorContext({ + chainId, + mode: dashboardVariant ? "dashboard" : "widget", + network, + route: current.pathname, + variant, + walletAddress, + }); + }, [ + chainId, + current.pathname, + dashboardVariant, + network, + variant, + walletAddress, + ]); + + return null; +}; diff --git a/packages/widget/src/providers/index.tsx b/packages/widget/src/providers/index.tsx index fbe44d8d..10c1020c 100644 --- a/packages/widget/src/providers/index.tsx +++ b/packages/widget/src/providers/index.tsx @@ -1,16 +1,15 @@ import type { ComponentProps, PropsWithChildren } from "react"; import { StrictMode } from "react"; -import { I18nextProvider } from "react-i18next"; import { HeaderHeightProvider } from "../components/molecules/header/use-sync-header-height"; import { SummaryProvider } from "../hooks/use-summary"; import { DisableTransitionDurationProvider } from "../navigation/containers/animation-layout"; import { CurrentLayoutProvider } from "../pages/components/layout/layout-context"; import { PoweredByHeightProvider } from "../pages/components/powered-by"; import { EarnPageStateProvider } from "../pages/details/earn-page/state/earn-page-state-context"; -import { i18nInstance } from "../translation"; import { ActivityProvider } from "./activity-provider"; import { SKApiClientProvider } from "./api/api-client-provider"; import { EnterStakeStoreProvider } from "./enter-stake-store"; +import { WidgetErrorTrackingContextSync } from "./error-tracking"; import { ExitStakeStoreProvider } from "./exit-stake-store"; import { ListStateContextProvider } from "./list-state"; import { SKLocationProvider } from "./location"; @@ -22,7 +21,6 @@ import { RootElementProvider } from "./root-element"; import { SKWalletProvider } from "./sk-wallet"; import { SolanaProvider } from "./solana"; import { ActionHistoryContextProvider } from "./stake-history"; -import { ThemeWrapper } from "./theme-wrapper"; import { TrackingContextProviderWithProps } from "./tracking"; import { WagmiConfigProvider } from "./wagmi/provider"; @@ -32,53 +30,50 @@ export const Providers = ({ return ( - - - - - - - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + + + + + ); diff --git a/packages/widget/src/translation/English/translations.json b/packages/widget/src/translation/English/translations.json index 3964f83a..0b84e709 100644 --- a/packages/widget/src/translation/English/translations.json +++ b/packages/widget/src/translation/English/translations.json @@ -807,6 +807,8 @@ "up_to_rate_type": "UP TO {{rateType}}" }, "error_modal": { + "unexpected_description": "An unexpected error occurred. Reload the app and try again.", + "reload_app": "Reload app", "solution": "Potential solution:" }, "chain_modal_disclaimer": "Powered by Yield.xyz", diff --git a/packages/widget/src/translation/French/translations.json b/packages/widget/src/translation/French/translations.json index b8cbade5..514244ac 100644 --- a/packages/widget/src/translation/French/translations.json +++ b/packages/widget/src/translation/French/translations.json @@ -680,6 +680,8 @@ "up_to_rate_type": "JUSQU'À {{rateType}}" }, "error_modal": { + "unexpected_description": "Une erreur inattendue s'est produite. Rechargez l'application et réessayez.", + "reload_app": "Recharger l'application", "solution": "Solution potentielle :" }, "chain_modal_disclaimer": "Alimenté par Yield.xyz", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ee83a2a..928e4ff7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ catalogs: '@safe-global/safe-apps-sdk': specifier: ^9.1.0 version: 9.1.0 + '@sentry/react': + specifier: 10.60.0 + version: 10.60.0 '@solana/wallet-adapter-base': specifier: ^0.9.27 version: 0.9.27 @@ -475,6 +478,9 @@ importers: '@safe-global/safe-apps-sdk': specifier: 'catalog:' version: 9.1.0(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@sentry/react': + specifier: 'catalog:' + version: 10.60.0(react@19.2.7) '@solana/wallet-adapter-base': specifier: 'catalog:' version: 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@6.0.3)(utf-8-validate@5.0.10)) @@ -3679,6 +3685,36 @@ packages: '@scure/starknet@1.1.0': resolution: {integrity: sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ==} + '@sentry/browser-utils@10.60.0': + resolution: {integrity: sha512-YhdPeMJnMaKVi5NQ2tD9RJ2AxU7cav5khmiMHFppmbP3I3Os2EHVaQjAVb6/ePAINr/d5mj5SxpnWmFX17iXsg==} + engines: {node: '>=18'} + + '@sentry/browser@10.60.0': + resolution: {integrity: sha512-20vzPKGrmupJPCaWd+soCOLkZRwKZxt0AVF4XGPNoGp7D7wVPRlHf+FWwVMx+rRRkahKupFefM2D9YoiUiBeag==} + engines: {node: '>=18'} + + '@sentry/core@10.60.0': + resolution: {integrity: sha512-szN7ccOJAEaLb1BBQzCQhABGMTJmKNUk0G2sc7rWhajeXoZoMKIbNkI9RvJrFuV69cbad/d/BKGBjbpJhySAzw==} + engines: {node: '>=18'} + + '@sentry/feedback@10.60.0': + resolution: {integrity: sha512-RcGUgaI8yrIXunhNLpdNLsBUJIDvnEGDRmFhC5v0oMltoGtovrIqrEhPXEiSWQvNB0x4q33ejkLeJRJoSDOp2w==} + engines: {node: '>=18'} + + '@sentry/react@10.60.0': + resolution: {integrity: sha512-ALRIDOj7T1Vk9t9IyI12Tq2t3MKoV2F/mKn140AiUBk3WzQhq2gXQUFpcsaDYA2FBsrYoYc6qdqrny6mMbA0Xg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + + '@sentry/replay-canvas@10.60.0': + resolution: {integrity: sha512-mYyQRJbhRRaqUkRvkJZyqt9AWdomh4108LOAph1YJvV1jW7tKYPPFNAUveJd7YkYCyhUOtekN0DKNJveK802qA==} + engines: {node: '>=18'} + + '@sentry/replay@10.60.0': + resolution: {integrity: sha512-j+w774BP1p+v/ga1hJAJSn0cXgTiB2VWwQCAxukF7AEXscny/OCXzTTsaPxdovw9kDHI641vKV+/yixLV2nKIQ==} + engines: {node: '>=18'} + '@simple-libs/child-process-utils@1.0.2': resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} engines: {node: '>=18'} @@ -14401,6 +14437,40 @@ snapshots: '@noble/curves': 1.7.0 '@noble/hashes': 1.6.0 + '@sentry/browser-utils@10.60.0': + dependencies: + '@sentry/core': 10.60.0 + + '@sentry/browser@10.60.0': + dependencies: + '@sentry/browser-utils': 10.60.0 + '@sentry/core': 10.60.0 + '@sentry/feedback': 10.60.0 + '@sentry/replay': 10.60.0 + '@sentry/replay-canvas': 10.60.0 + + '@sentry/core@10.60.0': {} + + '@sentry/feedback@10.60.0': + dependencies: + '@sentry/core': 10.60.0 + + '@sentry/react@10.60.0(react@19.2.7)': + dependencies: + '@sentry/browser': 10.60.0 + '@sentry/core': 10.60.0 + react: 19.2.7 + + '@sentry/replay-canvas@10.60.0': + dependencies: + '@sentry/core': 10.60.0 + '@sentry/replay': 10.60.0 + + '@sentry/replay@10.60.0': + dependencies: + '@sentry/browser-utils': 10.60.0 + '@sentry/core': 10.60.0 + '@simple-libs/child-process-utils@1.0.2': dependencies: '@simple-libs/stream-utils': 1.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd60fa2c..9cb7ba33 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ catalog: "@rolldown/plugin-babel": ^0.2.3 "@safe-global/safe-apps-provider": ^0.18.6 "@safe-global/safe-apps-sdk": ^9.1.0 + "@sentry/react": 10.60.0 "@solana/wallet-adapter-base": ^0.9.27 "@solana/wallet-adapter-react": ^0.15.39 "@solana/wallet-adapter-wallets": ^0.19.38