From 4d2f644bacba36269ec3b4078278da97ac0cb60f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 9 Mar 2026 00:03:29 -0400 Subject: [PATCH] feat: maximize Sentry free plan coverage for frontend and backend - ErrorBoundary: use Sentry.ErrorBoundary with crash feedback dialog - RouteError: capture route errors in Sentry (skip chunk load errors) - User context: set Sentry user on login (frontend + backend) - Backend: enable profiling (profiles_sample_rate) - Frontend: add feedback integration, lower replay rate to conserve quota - Add temporary verification message for production validation Co-Authored-By: Claude Opus 4.6 --- backend/app/api/deps.py | 4 + backend/app/main.py | 2 + .../src/components/common/ErrorBoundary.tsx | 95 +++++++++---------- frontend/src/components/common/RouteError.tsx | 8 ++ frontend/src/instrument.ts | 15 ++- frontend/src/store/authStore.ts | 5 + 6 files changed, 77 insertions(+), 52 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 4cdd8a94..f17858de 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -4,6 +4,7 @@ from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +import sentry_sdk from app.core.database import get_db from app.core.security import decode_token @@ -92,6 +93,9 @@ async def get_current_active_user( detail="password_change_required" ) + # Set Sentry user context for error attribution + sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email}) + # Lightweight trial expiry check if current_user.account_id: from app.models.subscription import Subscription diff --git a/backend/app/main.py b/backend/app/main.py index ccf3feb8..61e6a4e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,8 @@ if settings.SENTRY_DSN: environment="development" if settings.DEBUG else "production", send_default_pii=True, traces_sample_rate=1.0 if settings.DEBUG else 0.2, + # Profiling — included in free plan + profiles_sample_rate=1.0 if settings.DEBUG else 0.2, # Filter out noisy health check transactions traces_sampler=lambda ctx: ( 0.0 if ctx.get("transaction_context", {}).get("name", "").startswith("GET /health") else None diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx index e2f4a371..7d9b327d 100644 --- a/frontend/src/components/common/ErrorBoundary.tsx +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -1,60 +1,55 @@ -import { Component, type ReactNode } from 'react' +import * as Sentry from '@sentry/react' +import { type ReactNode } from 'react' import { Button } from '@/components/ui/Button' +interface FallbackProps { + error: Error + resetError: () => void +} + +function DefaultFallback({ error, resetError }: FallbackProps) { + return ( +
+
+

+ Something went wrong +

+

+ An unexpected error occurred. Please try refreshing the page. +

+
+          {error.message}
+        
+
+ + +
+
+
+ ) +} + interface Props { children: ReactNode fallback?: ReactNode } -interface State { - hasError: boolean - error: Error | null -} - -export class ErrorBoundary extends Component { - constructor(props: Props) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('ErrorBoundary caught an error:', error, errorInfo) - } - - render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback - } - - return ( -
-
-

- Something went wrong -

-

- An unexpected error occurred. Please try refreshing the page. -

- {this.state.error && ( -
-                {this.state.error.message}
-              
- )} - -
-
- ) - } - - return this.props.children - } +export function ErrorBoundary({ children, fallback }: Props) { + return ( + { + if (fallback) return fallback as React.ReactElement + return + }} + showDialog + > + {children} + + ) } export default ErrorBoundary diff --git a/frontend/src/components/common/RouteError.tsx b/frontend/src/components/common/RouteError.tsx index 01931aea..45ba1d71 100644 --- a/frontend/src/components/common/RouteError.tsx +++ b/frontend/src/components/common/RouteError.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom' +import * as Sentry from '@sentry/react' import { Button } from '@/components/ui/Button' function isChunkLoadError(error: unknown): boolean { @@ -19,6 +20,13 @@ export function RouteError() { const error = useRouteError() const navigate = useNavigate() + // Report route errors to Sentry (skip chunk load errors — those are deploy artifacts) + useEffect(() => { + if (error && !isChunkLoadError(error)) { + Sentry.captureException(error) + } + }, [error]) + // Auto-reload once on chunk load failures (stale deploy) useEffect(() => { if (isChunkLoadError(error)) { diff --git a/frontend/src/instrument.ts b/frontend/src/instrument.ts index 0e484d23..7fed0cc9 100644 --- a/frontend/src/instrument.ts +++ b/frontend/src/instrument.ts @@ -10,6 +10,11 @@ Sentry.init({ maskAllText: false, blockAllMedia: false, }), + // Crash feedback dialog — prompts users after unhandled errors + Sentry.feedbackIntegration({ + autoInject: false, + colorScheme: "dark", + }), ], // Tracing — capture 100% in dev, 20% in production @@ -19,7 +24,13 @@ Sentry.init({ /^https:\/\/api\.resolutionflow\.com/, ], - // Session Replay — record 10% of sessions, 100% of error sessions - replaysSessionSampleRate: 0.1, + // Session Replay — conserve free-plan quota + // 1% of normal sessions, 100% of error sessions + replaysSessionSampleRate: import.meta.env.PROD ? 0.01 : 0.0, replaysOnErrorSampleRate: 1.0, }); + +// TODO: Remove after verifying Sentry is receiving frontend events +if (import.meta.env.PROD) { + Sentry.captureMessage("ResolutionFlow frontend Sentry verification", "info"); +} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index a883f6d8..8ada3ccc 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import * as Sentry from '@sentry/react' import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types' import { authApi } from '@/api/auth' import { apiClient } from '@/api/client' @@ -81,6 +82,7 @@ export const useAuthStore = create()( localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') clearCachedQuota() + Sentry.setUser(null) set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null }) } }, @@ -104,6 +106,9 @@ export const useAuthStore = create()( throw reason } + // Set Sentry user context for error attribution + Sentry.setUser({ id: user.id, email: user.email }) + set({ user, account, subscription, isLoading: false }) } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Failed to fetch user'