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 fastapi.security import OAuth2PasswordBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
@@ -92,6 +93,9 @@ async def get_current_active_user(
|
|||||||
detail="password_change_required"
|
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
|
# Lightweight trial expiry check
|
||||||
if current_user.account_id:
|
if current_user.account_id:
|
||||||
from app.models.subscription import Subscription
|
from app.models.subscription import Subscription
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ if settings.SENTRY_DSN:
|
|||||||
environment="development" if settings.DEBUG else "production",
|
environment="development" if settings.DEBUG else "production",
|
||||||
send_default_pii=True,
|
send_default_pii=True,
|
||||||
traces_sample_rate=1.0 if settings.DEBUG else 0.2,
|
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
|
# Filter out noisy health check transactions
|
||||||
traces_sampler=lambda ctx: (
|
traces_sampler=lambda ctx: (
|
||||||
0.0 if ctx.get("transaction_context", {}).get("name", "").startswith("GET /health") else None
|
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'
|
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 {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
fallback?: ReactNode
|
fallback?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
export function ErrorBoundary({ children, fallback }: Props) {
|
||||||
hasError: boolean
|
return (
|
||||||
error: Error | null
|
<Sentry.ErrorBoundary
|
||||||
}
|
fallback={({ error, resetError }) => {
|
||||||
|
if (fallback) return fallback as React.ReactElement
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
return <DefaultFallback error={error as Error} resetError={resetError} />
|
||||||
constructor(props: Props) {
|
}}
|
||||||
super(props)
|
showDialog
|
||||||
this.state = { hasError: false, error: null }
|
>
|
||||||
}
|
{children}
|
||||||
|
</Sentry.ErrorBoundary>
|
||||||
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 default ErrorBoundary
|
export default ErrorBoundary
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'
|
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'
|
||||||
|
import * as Sentry from '@sentry/react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
function isChunkLoadError(error: unknown): boolean {
|
function isChunkLoadError(error: unknown): boolean {
|
||||||
@@ -19,6 +20,13 @@ export function RouteError() {
|
|||||||
const error = useRouteError()
|
const error = useRouteError()
|
||||||
const navigate = useNavigate()
|
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)
|
// Auto-reload once on chunk load failures (stale deploy)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isChunkLoadError(error)) {
|
if (isChunkLoadError(error)) {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ Sentry.init({
|
|||||||
maskAllText: false,
|
maskAllText: false,
|
||||||
blockAllMedia: 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
|
// Tracing — capture 100% in dev, 20% in production
|
||||||
@@ -19,7 +24,13 @@ Sentry.init({
|
|||||||
/^https:\/\/api\.resolutionflow\.com/,
|
/^https:\/\/api\.resolutionflow\.com/,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Session Replay — record 10% of sessions, 100% of error sessions
|
// Session Replay — conserve free-plan quota
|
||||||
replaysSessionSampleRate: 0.1,
|
// 1% of normal sessions, 100% of error sessions
|
||||||
|
replaysSessionSampleRate: import.meta.env.PROD ? 0.01 : 0.0,
|
||||||
replaysOnErrorSampleRate: 1.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 { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
import * as Sentry from '@sentry/react'
|
||||||
import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types'
|
import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
@@ -81,6 +82,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
localStorage.removeItem('refresh_token')
|
localStorage.removeItem('refresh_token')
|
||||||
clearCachedQuota()
|
clearCachedQuota()
|
||||||
|
Sentry.setUser(null)
|
||||||
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: 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
|
throw reason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set Sentry user context for error attribution
|
||||||
|
Sentry.setUser({ id: user.id, email: user.email })
|
||||||
|
|
||||||
set({ user, account, subscription, isLoading: false })
|
set({ user, account, subscription, isLoading: false })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
||||||
|
|||||||
Reference in New Issue
Block a user