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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="mb-2 text-xl font-semibold text-red-400">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
<pre className="mb-4 overflow-auto rounded-xl bg-white/5 border border-border p-3 text-left text-xs text-red-400">
|
||||
{error.message}
|
||||
</pre>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button variant="secondary" onClick={resetError}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Refresh Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
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 (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="mb-2 text-xl font-semibold text-red-400">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<pre className="mb-4 overflow-auto rounded-xl bg-white/5 border border-border p-3 text-left text-xs text-red-400">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Refresh Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
export function ErrorBoundary({ children, fallback }: Props) {
|
||||
return (
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={({ error, resetError }) => {
|
||||
if (fallback) return fallback as React.ReactElement
|
||||
return <DefaultFallback error={error as Error} resetError={resetError} />
|
||||
}}
|
||||
showDialog
|
||||
>
|
||||
{children}
|
||||
</Sentry.ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<AuthState>()(
|
||||
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<AuthState>()(
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user