From e9fa75e2a41b6b35a2e0149423aaa7995f66f726 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 17 Feb 2026 17:51:29 +0800 Subject: [PATCH 1/3] chore: agnet file --- AGENTS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a87b6dc6..22ff40e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,16 @@ Always consult these docs for detailed information: --- +## Core Philosophy + +1. Anchor on the user-critical failure before proposing solutions. +2. Prefer the smallest chokepoint change over broad instrumentation. +3. Avoid scope creep unless it is required to solve the root cause. +4. Do not claim repo facts without evidence (no invented counts). +5. Prevent double-capture, noisy heuristics, or duplicate logic. + +--- + ## 🛠️ Skills System This project uses **skills** for domain-specific patterns. Agents should load the relevant skill before working on related tasks. @@ -68,6 +78,34 @@ The skill injects detailed patterns and conventions into the conversation contex --- +## First-Principles Self-Review (Before Proposing Fixes) + +Before proposing a solution, add a short self-review: + +1. What user-critical failure are we solving? +2. Is the change scoped to the smallest chokepoint that fixes the root cause? +3. Does it avoid scope creep (new features) unless required to solve the root cause? +4. What evidence in the repo supports the claim? (no invented counts) +5. What is the simplest safe rollout path? +6. What would we NOT do to keep the change auditable and safe? +7. What could cause double-capture, noise, or duplicate logic? + +If you cannot answer these briefly, do not propose the change yet. + +--- + +## Plan Gate (Scope Check) + +If the proposed work touches more than 2 files, adds a new module, or changes runtime behavior, provide a short plan first and wait for confirmation. + +Plan format: +1. Goal +2. Smallest viable change +3. Files touched +4. Risk/rollback note + +--- + ## MANDATORY: Validate After Every Implementation Step **STOP after each implementation step and validate before moving on.** This is NOT optional. Do NOT batch all validation to the end. From bd65c8bb891b3de53aa6111cfe58733116c34de7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 17 Feb 2026 21:14:26 +0800 Subject: [PATCH 2/3] feat(sentry): capture handled query, transaction, and api errors --- app/api/admin/monarch-indexer/route.ts | 6 ++ app/api/monarch/metrics/route.ts | 6 ++ app/api/oracle-metadata/[chainId]/route.ts | 9 ++ src/OnchainProviders.tsx | 27 +++++- src/components/providers/QueryProvider.tsx | 85 +++++++++++++++- src/hooks/queries/useUserRewardsQuery.ts | 11 +++ src/hooks/useTransactionWithToast.tsx | 55 ++++++++++- src/utils/sentry-server.ts | 23 +++++ src/utils/sentry.ts | 108 +++++++++++++++++++++ 9 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 src/utils/sentry-server.ts create mode 100644 src/utils/sentry.ts diff --git a/app/api/admin/monarch-indexer/route.ts b/app/api/admin/monarch-indexer/route.ts index d2fbd66e..0882b2fe 100644 --- a/app/api/admin/monarch-indexer/route.ts +++ b/app/api/admin/monarch-indexer/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { reportApiRouteError } from '@/utils/sentry-server'; /** * Proxy API route for Monarch Indexer @@ -47,6 +48,11 @@ export async function POST(request: NextRequest) { const data = await response.json(); return NextResponse.json(data); } catch (error) { + reportApiRouteError(error, { + route: '/api/admin/monarch-indexer', + method: 'POST', + status: 500, + }); console.error('Monarch indexer proxy error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts index 2c022e3f..f737c6d2 100644 --- a/app/api/monarch/metrics/route.ts +++ b/app/api/monarch/metrics/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server'; import { MONARCH_API_KEY, getMonarchUrl } from '../utils'; +import { reportApiRouteError } from '@/utils/sentry-server'; export async function GET(req: NextRequest) { if (!MONARCH_API_KEY) { @@ -29,6 +30,11 @@ export async function GET(req: NextRequest) { return NextResponse.json(await response.json()); } catch (error) { + reportApiRouteError(error, { + route: '/api/monarch/metrics', + method: 'GET', + status: 500, + }); console.error('[Monarch Metrics API] Failed to fetch:', error); return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: 500 }); } diff --git a/app/api/oracle-metadata/[chainId]/route.ts b/app/api/oracle-metadata/[chainId]/route.ts index 61de9d40..fbc3f4b0 100644 --- a/app/api/oracle-metadata/[chainId]/route.ts +++ b/app/api/oracle-metadata/[chainId]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { reportApiRouteError } from '@/utils/sentry-server'; const ORACLE_GIST_BASE_URL = process.env.ORACLE_GIST_BASE_URL; @@ -31,6 +32,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ chai return NextResponse.json(data); } catch (error) { + reportApiRouteError(error, { + route: '/api/oracle-metadata/[chainId]', + method: 'GET', + status: 500, + extras: { + chainId, + }, + }); console.error('Failed to fetch oracle metadata:', error); return NextResponse.json({ error: 'Failed to fetch oracle metadata' }, { status: 500 }); } diff --git a/src/OnchainProviders.tsx b/src/OnchainProviders.tsx index 90bbd027..54bb28fa 100644 --- a/src/OnchainProviders.tsx +++ b/src/OnchainProviders.tsx @@ -1,7 +1,10 @@ 'use client'; +import { useEffect } from 'react'; import type { ReactNode } from 'react'; -import { WagmiProvider } from 'wagmi'; +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK requires namespace import +import * as Sentry from '@sentry/nextjs'; +import { useAccount, useChainId, WagmiProvider } from 'wagmi'; import { wagmiAdapter } from '@/config/appkit'; import { createWagmiConfig } from '@/store/createWagmiConfig'; import { ConnectRedirectProvider } from './components/providers/ConnectRedirectProvider'; @@ -9,6 +12,27 @@ import { CustomRpcProvider, useCustomRpcContext } from './components/providers/C type Props = { children: ReactNode }; +function SentryWalletScopeSync() { + const { address, isConnected } = useAccount(); + const chainId = useChainId(); + + useEffect(() => { + if (isConnected && address) { + const truncatedAddress = `${address.slice(0, 6)}...${address.slice(-4)}`; + Sentry.setUser({ id: truncatedAddress }); + Sentry.setTag('wallet_connected', 'true'); + Sentry.setTag('chain_id', String(chainId)); + return; + } + + Sentry.setUser(null); + Sentry.setTag('wallet_connected', 'false'); + Sentry.setTag('chain_id', 'unknown'); + }, [address, chainId, isConnected]); + + return null; +} + function WagmiConfigProvider({ children }: Props) { const { customRpcUrls } = useCustomRpcContext(); @@ -22,6 +46,7 @@ function WagmiConfigProvider({ children }: Props) { config={wagmiConfig} reconnectOnMount > + {children} ); diff --git a/src/components/providers/QueryProvider.tsx b/src/components/providers/QueryProvider.tsx index dd307f7f..cb18757a 100644 --- a/src/components/providers/QueryProvider.tsx +++ b/src/components/providers/QueryProvider.tsx @@ -1,15 +1,98 @@ 'use client'; import type { ReactNode } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider, type QueryKey } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { reportHandledError } from '@/utils/sentry'; type QueryProviderProps = { children: ReactNode; }; +const ACTIONABLE_QUERY_ROOT_KEYS = new Set([ + 'all-position-snapshots', + 'enhanced-positions', + 'fresh-markets-state', + 'historicalSupplierPositions', + 'marketData', + 'marketLiquidations', + 'market-metrics', + 'markets', + 'monarch-transactions', + 'merkl-campaigns', + 'morpho-market-v1-adapters', + 'oracle-data', + 'oracle-metadata', + 'morpho-vaults', + 'public-allocator-vaults', + 'tokenPrices', + 'majorPrices', + 'positions', + 'tokens', + 'user-vaults-v2', + 'user-rewards', + 'user-transactions', + 'vault-historical-apy', + 'vault-v2-data', + 'vault-allocations', +]); + +const getQueryRootKey = (queryKey: QueryKey): string => { + const root = queryKey[0]; + return typeof root === 'string' ? root : String(root); +}; + // Create a single QueryClient with merged configuration from both previous clients const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + // Skip background refetch failures when stale data is already available. + // These usually do not break UX and create noisy events. + if (query.state.data !== undefined) { + return; + } + + const rootKey = getQueryRootKey(query.queryKey); + if (!ACTIONABLE_QUERY_ROOT_KEYS.has(rootKey)) { + return; + } + + reportHandledError(error, { + scope: 'react_query', + operation: `query:${rootKey}`, + level: 'error', + tags: { + query_root_key: rootKey, + query_failure_count: query.state.fetchFailureCount, + }, + extras: { + query_key: query.queryKey, + query_failure_reason: + query.state.fetchFailureReason instanceof Error + ? query.state.fetchFailureReason.message + : String(query.state.fetchFailureReason), + }, + }); + }, + }), + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + const mutationKey = mutation.options.mutationKey; + const rootKey = Array.isArray(mutationKey) ? getQueryRootKey(mutationKey) : 'unknown'; + + reportHandledError(error, { + scope: 'react_query_mutation', + operation: `mutation:${rootKey}`, + level: 'error', + tags: { + mutation_root_key: rootKey, + }, + extras: { + mutation_key: mutationKey ?? null, + }, + }); + }, + }), defaultOptions: { queries: { // From ClientProviders - good for caching and UX diff --git a/src/hooks/queries/useUserRewardsQuery.ts b/src/hooks/queries/useUserRewardsQuery.ts index e9eba029..146b6bc5 100644 --- a/src/hooks/queries/useUserRewardsQuery.ts +++ b/src/hooks/queries/useUserRewardsQuery.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { Address } from 'viem'; import { merklClient } from '@/utils/merklApi'; +import { reportHandledError } from '@/utils/sentry'; import type { RewardResponseType } from '@/utils/types'; import { URLS } from '@/utils/urls'; import { ALL_SUPPORTED_NETWORKS } from '@/utils/networks'; @@ -152,10 +153,20 @@ async function fetchAllRewards(userAddress: string): Promise { const [morphoData, merklData] = await Promise.all([ fetchMorphoRewards(userAddress).catch((err) => { console.error('Morpho rewards fetch failed:', err); + reportHandledError(err, { + scope: 'user_rewards', + operation: 'fetch:morpho', + level: 'warning', + }); return { rewards: [] as RewardResponseType[], distributions: [] as DistributionResponseType[] }; }), fetchMerklRewards(userAddress).catch((err) => { console.error('Merkl rewards fetch failed:', err); + reportHandledError(err, { + scope: 'user_rewards', + operation: 'fetch:merkl', + level: 'warning', + }); return { rewards: [] as RewardResponseType[], rewardsWithProofs: [] as MerklRewardWithProofs[] }; }), ]); diff --git a/src/hooks/useTransactionWithToast.tsx b/src/hooks/useTransactionWithToast.tsx index d4095423..5ef2b3c7 100644 --- a/src/hooks/useTransactionWithToast.tsx +++ b/src/hooks/useTransactionWithToast.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { toast } from 'react-toastify'; import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi'; import { StyledToast, TransactionToast } from '@/components/ui/styled-toast'; +import { reportHandledError } from '@/utils/sentry'; import { getExplorerTxURL } from '../utils/external'; import type { SupportedNetworks } from '../utils/networks'; @@ -16,6 +17,15 @@ type UseTransactionWithToastProps = { onSuccess?: () => void; }; +const MAX_TOAST_MESSAGE_LENGTH = 160; + +const truncateToastMessage = (message: string): string => { + if (message.length <= MAX_TOAST_MESSAGE_LENGTH) { + return message; + } + return `${message.slice(0, MAX_TOAST_MESSAGE_LENGTH - 3)}...`; +}; + export function useTransactionWithToast({ toastId, pendingText, @@ -27,11 +37,13 @@ export function useTransactionWithToast({ onSuccess, }: UseTransactionWithToastProps) { const { data: hash, mutate: sendTransaction, error: txError, mutateAsync: sendTransactionAsync } = useSendTransaction(); + const reportedErrorKeyRef = useRef(null); const { isLoading: isConfirming, isSuccess: isConfirmed, isError, + error: receiptError, } = useWaitForTransactionReceipt({ hash, chainId: chainId, @@ -93,11 +105,35 @@ export function useTransactionWithToast({ } } if (isError || txError) { + const errorToReport = txError ?? receiptError ?? new Error('Transaction failed while waiting for confirmation'); + const reportKey = `${toastId}:${hash ?? 'no_hash'}:${errorToReport.message}`; + + if (reportedErrorKeyRef.current !== reportKey) { + reportHandledError(errorToReport, { + scope: 'transaction', + operation: pendingText, + level: 'error', + tags: { + tx_toast_id: toastId, + tx_chain_id: chainId ?? 'unknown', + tx_has_hash: Boolean(hash), + }, + extras: { + tx_hash: hash ?? null, + tx_error_message: errorToReport.message, + tx_error_name: errorToReport.name, + }, + }); + reportedErrorKeyRef.current = reportKey; + } + + const errorMessage = (txError ?? receiptError)?.message ?? 'Transaction Failed'; + toast.update(toastId, { render: ( ), type: 'error', @@ -106,8 +142,23 @@ export function useTransactionWithToast({ onClick, closeButton: true, }); + } else { + reportedErrorKeyRef.current = null; } - }, [hash, isConfirmed, isError, txError, successText, successDescription, errorText, toastId, onClick]); + }, [ + hash, + isConfirmed, + isError, + txError, + receiptError, + successText, + successDescription, + errorText, + toastId, + onClick, + chainId, + pendingText, + ]); return { sendTransactionAsync, sendTransaction, isConfirming, isConfirmed }; } diff --git a/src/utils/sentry-server.ts b/src/utils/sentry-server.ts new file mode 100644 index 00000000..c6088978 --- /dev/null +++ b/src/utils/sentry-server.ts @@ -0,0 +1,23 @@ +import 'server-only'; +import { reportHandledError } from '@/utils/sentry'; + +type ApiRouteErrorContext = { + route: string; + method: string; + status?: number; + extras?: Record; +}; + +export const reportApiRouteError = (error: unknown, context: ApiRouteErrorContext): void => { + reportHandledError(error, { + scope: 'api_route', + operation: `${context.method} ${context.route}`, + level: 'error', + tags: { + api_route: context.route, + api_method: context.method, + api_status: context.status ?? 500, + }, + extras: context.extras, + }); +}; diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts new file mode 100644 index 00000000..dce08f48 --- /dev/null +++ b/src/utils/sentry.ts @@ -0,0 +1,108 @@ +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK requires namespace import +import * as Sentry from '@sentry/nextjs'; + +type SentryTagValue = string | number | boolean; +type SentryLevel = 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug'; + +export type HandledErrorContext = { + scope: string; + operation?: string; + level?: SentryLevel; + tags?: Record; + extras?: Record; + fingerprint?: string[]; +}; + +const toError = (error: unknown): Error => { + if (error instanceof Error) { + return error; + } + if (typeof error === 'string') { + return new Error(error); + } + return new Error('Unknown error'); +}; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return ''; +}; + +export const isUserRejectedError = (error: unknown): boolean => { + const message = getErrorMessage(error).toLowerCase(); + if (!message) { + return false; + } + if (message.includes('user rejected') || message.includes('rejected by user')) { + return true; + } + if (message.includes('user denied') || message.includes('request rejected')) { + return true; + } + if (message.includes('denied transaction signature')) { + return true; + } + if (message.includes('action_rejected')) { + return true; + } + if (message.includes(' 4001') || message.includes('error 4001') || message.includes('code 4001')) { + return true; + } + if (message.includes('request rejected') || message.includes('request denied')) { + return true; + } + if (message.includes('signing rejected') || message.includes('signature rejected')) { + return true; + } + if (message.includes('user canceled') || message.includes('user cancelled')) { + return true; + } + return false; +}; + +export const reportHandledError = (error: unknown, context: HandledErrorContext): void => { + if (isUserRejectedError(error)) { + return; + } + + const normalizedError = toError(error); + + Sentry.withScope((scope) => { + scope.setTag('handled_error', 'true'); + scope.setTag('handled_error_scope', context.scope); + + if (context.operation) { + scope.setTag('handled_error_operation', context.operation); + } + + if (context.level) { + scope.setLevel(context.level); + } + + if (context.fingerprint) { + scope.setFingerprint(context.fingerprint); + } + + if (context.tags) { + for (const [key, value] of Object.entries(context.tags)) { + if (value === null || value === undefined) { + continue; + } + scope.setTag(key, String(value)); + } + } + + if (context.extras) { + for (const [key, value] of Object.entries(context.extras)) { + scope.setExtra(key, value); + } + } + + Sentry.captureException(normalizedError); + }); +}; From ec896bc0d9907a87125287241b6a3a9b65280b60 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 17 Feb 2026 21:29:15 +0800 Subject: [PATCH 3/3] chore: review fixes --- docs/TECHNICAL_OVERVIEW.md | 5 +++++ src/OnchainProviders.tsx | 5 ++--- src/components/providers/QueryProvider.tsx | 10 ++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index fd58f567..e3d035bb 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -24,6 +24,11 @@ Monarch is a client-side DeFi dashboard for the Morpho Blue lending protocol. It | @morpho-org/blue-sdk | 5.3.0 | Morpho Blue protocol SDK | | @cowprotocol/cow-sdk | 7.2.9 | Intent-based swaps | +**Wagmi v3 integration notes:** +- Prefer `useConnection()` when you need wallet state (`address`, `chainId`, `isConnected`) in one place. +- Avoid introducing `useAccount()` + `useChainId()` pairs for new wallet-state sync logic. +- `useTransactionWithToast` already reports `useSendTransaction` failures to Sentry; global React Query mutation telemetry should not re-capture `sendTransaction` mutation errors. + ### State Management | Technology | Version | Purpose | |-----------|---------|---------| diff --git a/src/OnchainProviders.tsx b/src/OnchainProviders.tsx index 54bb28fa..1b5abbb0 100644 --- a/src/OnchainProviders.tsx +++ b/src/OnchainProviders.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import type { ReactNode } from 'react'; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK requires namespace import import * as Sentry from '@sentry/nextjs'; -import { useAccount, useChainId, WagmiProvider } from 'wagmi'; +import { useConnection, WagmiProvider } from 'wagmi'; import { wagmiAdapter } from '@/config/appkit'; import { createWagmiConfig } from '@/store/createWagmiConfig'; import { ConnectRedirectProvider } from './components/providers/ConnectRedirectProvider'; @@ -13,8 +13,7 @@ import { CustomRpcProvider, useCustomRpcContext } from './components/providers/C type Props = { children: ReactNode }; function SentryWalletScopeSync() { - const { address, isConnected } = useAccount(); - const chainId = useChainId(); + const { address, chainId, isConnected } = useConnection(); useEffect(() => { if (isConnected && address) { diff --git a/src/components/providers/QueryProvider.tsx b/src/components/providers/QueryProvider.tsx index cb18757a..e74601d7 100644 --- a/src/components/providers/QueryProvider.tsx +++ b/src/components/providers/QueryProvider.tsx @@ -37,6 +37,10 @@ const ACTIONABLE_QUERY_ROOT_KEYS = new Set([ 'vault-allocations', ]); +const TRANSACTION_MUTATION_ROOT_KEYS = new Set([ + 'sendTransaction', +]); + const getQueryRootKey = (queryKey: QueryKey): string => { const root = queryKey[0]; return typeof root === 'string' ? root : String(root); @@ -80,6 +84,12 @@ const queryClient = new QueryClient({ const mutationKey = mutation.options.mutationKey; const rootKey = Array.isArray(mutationKey) ? getQueryRootKey(mutationKey) : 'unknown'; + // Transaction mutation failures are reported in `useTransactionWithToast`. + // Skip them here to avoid duplicate telemetry events. + if (TRANSACTION_MUTATION_ROOT_KEYS.has(rootKey)) { + return; + } + reportHandledError(error, { scope: 'react_query_mutation', operation: `mutation:${rootKey}`,