From ecb209716a2b185b4a21430b2c63a8fd213b5ad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 08:44:41 +0000 Subject: [PATCH 1/7] feat(sentry): add comprehensive error capture to API and Expo API (@packrat/api): - Add @sentry/cloudflare dependency and wrap the Worker fetch/queue handlers with withSentry for per-request scope isolation - New packages/api/src/utils/sentry.ts with captureApiException, apiAddBreadcrumb, setApiUser, clearApiUser helpers - Global onError handler captures all non-validation, non-404 errors with method/path/error_code tags; queue handler wraps processQueueBatch and handleEmbeddingsBatch with capture + rethrow - Auth middleware sets Sentry user context (id, email, role) after every successful session resolve; breadcrumbs on unauthenticated / forbidden rejections; captureException on getSession failures - weather routes: captureApiException in every catch block with operation name, userId, weather_operation tag, and relevant extras - chat route: captures missing AI provider config; breadcrumb before stream starts with contextType/packId/messageCount; captures streamText onError with provider + context tags - weatherService: captures HTTP error responses with http_status tag Expo (apps/expo): - _layout.tsx: add tracesSampleRate 0.2, sendDefaultPii false, environment tag, beforeBreadcrumb filter to strip /api/auth/ URLs; setUser with id/email/username on startup - ErrorBoundary: withScope in onError adds error_source tag and componentStack + errorName/errorMessage as Sentry extras before capture - useAuthActions: Sentry.addBreadcrumb for every auth flow step (sign-in attempt/success, Google/Apple/email, sign-up, sign-out, password reset, email verify); captureException in every catch with auth_method and auth_action tags; setUser after successful sign-in; setUser(null) on sign-out and account deletion - useAuthInit: setUser after background session refresh; breadcrumbs for session expiry and definitive auth failures; setUser(null) on clear; captureException for startup auth failures https://claude.ai/code/session_01HCrFacYwgoJ4xLTqu21gWb --- apps/expo/app/_layout.tsx | 31 +++- .../expo/components/initial/ErrorBoundary.tsx | 14 +- .../features/auth/hooks/useAuthActions.ts | 137 +++++++++++++++++- apps/expo/features/auth/hooks/useAuthInit.ts | 22 +++ packages/api/package.json | 1 + packages/api/src/index.ts | 66 +++++++-- packages/api/src/middleware/auth.ts | 73 ++++++++-- packages/api/src/routes/chat.ts | 26 +++- packages/api/src/routes/weather.ts | 56 +++++-- packages/api/src/services/weatherService.ts | 9 +- packages/api/src/utils/sentry.ts | 71 +++++++++ 11 files changed, 449 insertions(+), 57 deletions(-) create mode 100644 packages/api/src/utils/sentry.ts diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index b5cdf42ace..96e157445d 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -17,16 +17,37 @@ import { useEffect, useRef } from 'react'; Sentry.init({ dsn: clientEnvs.EXPO_PUBLIC_SENTRY_DSN, - // Adds more context data to events (IP address, cookies, user, etc.) - // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ - sendDefaultPii: true, - // Disable Sentry in local development or when no DSN is configured. enabled: clientEnvs.NODE_ENV !== 'development' && !!clientEnvs.EXPO_PUBLIC_SENTRY_DSN, + + // PII: email, IP, device fingerprint — off by default for GDPR; enable if you have consent. + sendDefaultPii: false, + + // Sample 20% of sessions for performance; 100% of errors always reach Sentry. + tracesSampleRate: 0.2, + + // Tag every event with environment so you can filter in the Sentry UI. + environment: clientEnvs.NODE_ENV ?? 'production', + + // Trim noisy console breadcrumbs in production; keep them in dev. + beforeBreadcrumb(breadcrumb) { + if (breadcrumb.type === 'http' && breadcrumb.data?.url) { + // Strip auth tokens from URLs in breadcrumbs + const url = String(breadcrumb.data.url); + if (url.includes('/api/auth/')) return null; + } + return breadcrumb; + }, }); +// Sync the authenticated user to Sentry on startup so every event is +// associated with the correct user identity. const user = userStore.peek(); if (user) { - Sentry.setUser(user); + Sentry.setUser({ + id: user.id, + email: user.email, + username: `${user.firstName} ${user.lastName}`.trim(), + }); } export { diff --git a/apps/expo/components/initial/ErrorBoundary.tsx b/apps/expo/components/initial/ErrorBoundary.tsx index 26f836efe8..e249dd8bb4 100644 --- a/apps/expo/components/initial/ErrorBoundary.tsx +++ b/apps/expo/components/initial/ErrorBoundary.tsx @@ -50,11 +50,21 @@ const DefaultFallback = () => { export function ErrorBoundary({ children, fallback, onReset, onError }: ErrorBoundaryProps) { const handleError = ({ error, info }: { error: unknown; info: { componentStack: string } }) => { - // Log the error to your preferred logging service console.error('Error caught by ErrorBoundary:', error); console.error('Component stack:', info.componentStack); - // Call the custom error handler if provided + // Attach the component stack as extra context so Sentry shows exactly + // which component tree caused the crash. + Sentry.withScope((scope) => { + scope.setTag('error_source', 'error_boundary'); + scope.setExtra('componentStack', info.componentStack); + if (error instanceof Error) { + scope.setExtra('errorName', error.name); + scope.setExtra('errorMessage', error.message); + } + Sentry.captureException(error); + }); + if (onError) { onError(error, info); } diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index 30a9a189ca..1991942254 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -5,6 +5,7 @@ import { isErrorWithCode, statusCodes, } from '@react-native-google-signin/google-signin'; +import * as Sentry from '@sentry/react-native'; import { userStore } from 'expo-app/features/auth/store'; import type { User } from 'expo-app/features/profile/types'; import { authClient } from 'expo-app/lib/auth-client'; @@ -64,18 +65,38 @@ export function useAuthActions() { }; const applySession = (user: Record) => { - userStore.set(mapToUser(user)); + const mappedUser = mapToUser(user); + userStore.set(mappedUser); + + // Identify the user in Sentry so all subsequent events are tagged. + Sentry.setUser({ + id: mappedUser.id, + email: mappedUser.email, + username: `${mappedUser.firstName} ${mappedUser.lastName}`.trim(), + }); + setNeedsReauth(false); redirect(redirectTo); }; const signIn = async ({ email, password }: { email: string; password: string }) => { setIsLoading(true); + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Email sign in attempt', + level: 'info', + data: { email }, + }); try { const { data, error } = await authClient.signIn.email({ email, password }); if (error) throw new Error(error.message ?? 'Sign in failed'); applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime + Sentry.addBreadcrumb({ category: 'auth', message: 'Email sign in succeeded', level: 'info' }); } catch (error) { + Sentry.captureException(error, { + tags: { auth_method: 'email', auth_action: 'sign_in' }, + extra: { email }, + }); console.error('Sign in error:', error); throw error; } finally { @@ -85,6 +106,7 @@ export function useAuthActions() { const signInWithGoogle = async () => { setIsLoading(true); + Sentry.addBreadcrumb({ category: 'auth', message: 'Google sign in attempt', level: 'info' }); try { await GoogleSignin.hasPlayServices(); await GoogleSignin.signIn(); @@ -97,17 +119,40 @@ export function useAuthActions() { idToken: { token: idToken }, }); if (error) throw new Error(error.message ?? t('auth.failedToSignInWithGoogle')); - if (data && 'user' in data && data.user) applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime + if (data && 'user' in data && data.user) { + applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Google sign in succeeded', + level: 'info', + }); + } } catch (error) { setIsLoading(false); if (isErrorWithCode(error) && error.code === statusCodes.SIGN_IN_CANCELLED) { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Google sign in cancelled by user', + level: 'info', + }); console.log(t('auth.userCancelledLogin')); } else if (isErrorWithCode(error) && error.code === statusCodes.IN_PROGRESS) { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Google sign in already in progress', + level: 'warning', + }); console.log(t('auth.signInInProgress')); } else if (isErrorWithCode(error) && error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) { + Sentry.captureException(error, { + tags: { auth_method: 'google', auth_action: 'sign_in', error_type: 'play_services' }, + }); console.log(t('auth.playServicesNotAvailable')); } else { + Sentry.captureException(error, { + tags: { auth_method: 'google', auth_action: 'sign_in' }, + }); console.error('Google sign in error:', error); } throw error; @@ -116,6 +161,7 @@ export function useAuthActions() { const signInWithApple = async () => { setIsLoading(true); + Sentry.addBreadcrumb({ category: 'auth', message: 'Apple sign in attempt', level: 'info' }); try { const isAvailable = await AppleAuthentication.isAvailableAsync(); if (!isAvailable) throw new Error(t('auth.appleSignInNotAvailable')); @@ -132,8 +178,18 @@ export function useAuthActions() { idToken: { token: credential.identityToken ?? '' }, }); if (error) throw new Error(error.message ?? 'Apple sign in failed'); - if (data && 'user' in data && data.user) applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime + if (data && 'user' in data && data.user) { + applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Apple sign in succeeded', + level: 'info', + }); + } } catch (error) { + Sentry.captureException(error, { + tags: { auth_method: 'apple', auth_action: 'sign_in' }, + }); console.error('Apple sign in error:', error); throw error; } finally { @@ -153,11 +209,27 @@ export function useAuthActions() { lastName?: string; }) => { setIsLoading(true); + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Email sign up attempt', + level: 'info', + data: { email, hasFirstName: !!firstName, hasLastName: !!lastName }, + }); try { const name = [firstName, lastName].filter(Boolean).join(' ') || email; const { error } = await authClient.signUp.email({ email, password, name }); if (error) throw new Error(error.message ?? 'Sign up failed'); + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Email sign up succeeded', + level: 'info', + data: { email }, + }); } catch (error) { + Sentry.captureException(error, { + tags: { auth_method: 'email', auth_action: 'sign_up' }, + extra: { email }, + }); console.error('Registration error:', error instanceof Error ? error.message : String(error)); throw error; } finally { @@ -170,11 +242,15 @@ export function useAuthActions() { // show a post-sign-out prompt and handle navigation itself. setSuppressSignOutNav(true); setIsLoading(true); + Sentry.addBreadcrumb({ category: 'auth', message: 'Sign out initiated', level: 'info' }); try { const isSignedIn = await GoogleSignin.hasPreviousSignIn(); if (isSignedIn) await GoogleSignin.signOut(); await authClient.signOut(); + // Clear user identity from Sentry on sign-out. + Sentry.setUser(null); } catch (error) { + Sentry.captureException(error, { tags: { auth_action: 'sign_out' } }); console.error('Sign out error:', error); } finally { userStore.set(null); @@ -188,11 +264,23 @@ export function useAuthActions() { }; const forgotPassword = async (email: string) => { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Password reset requested', + level: 'info', + data: { email }, + }); const { error } = await authClient.requestPasswordReset({ email, redirectTo: 'packrat://reset-password', }); - if (error) throw new Error(error.message ?? 'Forgot password failed'); + if (error) { + Sentry.captureException(new Error(error.message ?? 'Forgot password failed'), { + tags: { auth_action: 'forgot_password' }, + extra: { email }, + }); + throw new Error(error.message ?? 'Forgot password failed'); + } }; const resetPassword = async ({ @@ -201,16 +289,36 @@ export function useAuthActions() { email?: string; opts: { token: string; newPassword: string }; }) => { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Password reset submitted', + level: 'info', + }); const { error } = await authClient.resetPassword({ token: opts.token, newPassword: opts.newPassword, }); - if (error) throw new Error(error.message ?? 'Reset password failed'); + if (error) { + Sentry.captureException(new Error(error.message ?? 'Reset password failed'), { + tags: { auth_action: 'reset_password' }, + }); + throw new Error(error.message ?? 'Reset password failed'); + } }; const verifyEmail = async ({ token }: { _email?: string; token: string }) => { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Email verification submitted', + level: 'info', + }); const { data, error } = await authClient.verifyEmail({ query: { token } }); - if (error) throw new Error(error.message ?? 'Email verification failed'); + if (error) { + Sentry.captureException(new Error(error.message ?? 'Email verification failed'), { + tags: { auth_action: 'verify_email' }, + }); + throw new Error(error.message ?? 'Email verification failed'); + } const session = await authClient.getSession(); if (session.data?.user) { @@ -220,22 +328,37 @@ export function useAuthActions() { }; const resendVerificationEmail = async (email: string) => { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Verification email resend requested', + level: 'info', + data: { email }, + }); const { error } = await authClient.sendVerificationEmail({ email, callbackURL: 'packrat://verify-email', }); - if (error) throw new Error(error.message ?? 'Failed to resend verification email'); + if (error) { + Sentry.captureException(new Error(error.message ?? 'Failed to resend verification email'), { + tags: { auth_action: 'resend_verification' }, + extra: { email }, + }); + throw new Error(error.message ?? 'Failed to resend verification email'); + } }; const deleteAccount = async () => { setIsLoading(true); + Sentry.addBreadcrumb({ category: 'auth', message: 'Account deletion initiated', level: 'warning' }); try { const { error } = await authClient.deleteUser(); if (error) throw new Error(error.message ?? 'Delete account failed'); + Sentry.setUser(null); userStore.set(null); await clearLocalData(); await Updates.reloadAsync(); } catch (error) { + Sentry.captureException(error, { tags: { auth_action: 'delete_account' } }); console.error('Delete account error:', error); throw error; } finally { diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index b8e9714bc1..2fb32a5431 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -3,6 +3,7 @@ import { clientEnvs } from '@packrat/env/expo-client'; import { asBoolean, asString } from '@packrat/guards'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; +import * as Sentry from '@sentry/react-native'; import { userStore, userSyncState } from 'expo-app/features/auth/store'; import { authClient } from 'expo-app/lib/auth-client'; import { router } from 'expo-router'; @@ -25,6 +26,12 @@ async function runVersionGateMigration() { function applySessionUser(sessionUser: Record) { const name = asString(sessionUser.name) ?? ''; + const userId = asString(sessionUser.id) ?? ''; + const email = asString(sessionUser.email) ?? ''; + + // Keep Sentry user identity in sync with the session. + Sentry.setUser({ id: userId, email, username: name }); + userStore.set({ id: asString(sessionUser.id) ?? '', email: asString(sessionUser.email) ?? '', @@ -83,6 +90,13 @@ export function useAuthInit() { .then(({ data: session, error }) => { if (error) { if (isDefinitiveAuthFailure(error)) { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Background session refresh: definitive auth failure', + level: 'warning', + data: { status: (error as { status?: number })?.status }, + }); + Sentry.setUser(null); userStore.set(null); router.replace('/auth'); } @@ -93,12 +107,19 @@ export function useAuthInit() { applySessionUser(session.user as Record); } else { // Server confirmed the session is gone + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Background session refresh: session expired', + level: 'info', + }); + Sentry.setUser(null); userStore.set(null); router.replace('/auth'); } }) .catch((error) => { if (isDefinitiveAuthFailure(error)) { + Sentry.setUser(null); userStore.set(null); router.replace('/auth'); } @@ -124,6 +145,7 @@ export function useAuthInit() { params: { showSkipLoginBtn: 'true', redirectTo: '/' }, }); } catch (error) { + Sentry.captureException(error, { tags: { auth_action: 'init' } }); console.error('Failed to initialize auth:', error); router.replace('/auth'); } finally { diff --git a/packages/api/package.json b/packages/api/package.json index 7964bb1a55..0915f61e51 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -70,6 +70,7 @@ "workers-ai-provider": "^0.7.2", "ws": "catalog:", "youtube-transcript": "^1.3.0", + "@sentry/cloudflare": "^10.0.0", "zod": "catalog:", "zod-openapi": "^5.4.6" }, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b47b79ebd0..0b36caf6b5 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -16,6 +16,8 @@ import { processQueueBatch } from '@packrat/api/services/etl/queue'; import type { Env } from '@packrat/api/utils/env-validation'; import { getEnv, setWorkerEnv } from '@packrat/api/utils/env-validation'; import { packratOpenApi } from '@packrat/api/utils/openapi'; +import { captureApiException } from '@packrat/api/utils/sentry'; +import { withSentry } from '@sentry/cloudflare'; import { Elysia } from 'elysia'; import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker'; import type { CatalogETLMessage } from './services/etl/types'; @@ -45,8 +47,22 @@ export const app = new Elysia({ adapter: CloudflareAdapter }) }), ) .use(packratOpenApi) - .onError(({ error, code }) => { + .onError(({ error, code, request }) => { console.error('Error occurred:', error); + + // Only report unexpected server errors — not user-input or routing errors. + if (code !== 'VALIDATION' && code !== 'PARSE' && code !== 'NOT_FOUND') { + captureApiException(error, { + operation: 'elysia.onError', + tags: { + error_code: code, + method: request?.method ?? 'UNKNOWN', + path: request ? new URL(request.url).pathname : 'UNKNOWN', + }, + extra: { errorCode: code }, + }); + } + if (code === 'VALIDATION' || code === 'PARSE') { return new Response(JSON.stringify({ error: 'Validation failed' }), { status: 400, @@ -90,7 +106,7 @@ function enrichEnv(env: Env): Env { return env; } -export default { +const workerHandler = { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const e = enrichEnv(env); setWorkerEnv(e as unknown as Record); // safe-cast: setWorkerEnv accepts Record; ValidatedEnv has no index signature by design @@ -109,19 +125,39 @@ export default { async queue(batch: MessageBatch, env: Env): Promise { setWorkerEnv(enrichEnv(env) as unknown as Record); // safe-cast: same as fetch handler above - if (batch.queue === 'packrat-etl-queue' || batch.queue === 'packrat-etl-queue-dev') { - if (!env.ETL_QUEUE) throw new Error('ETL_QUEUE is not configured'); - await processQueueBatch({ batch: batch as MessageBatch, env }); // safe-cast: batch queue name checked above; MessageBatch is compatible at runtime - } else if ( - batch.queue === 'packrat-embeddings-queue' || - batch.queue === 'packrat-embeddings-queue-dev' - ) { - if (!env.EMBEDDINGS_QUEUE) throw new Error('EMBEDDINGS_QUEUE is not configured'); - await new CatalogService({ explicitEnv: env, useHttpDriver: true }).handleEmbeddingsBatch( - batch, - ); - } else { - throw new Error(`Unknown queue: ${batch.queue}`); + try { + if (batch.queue === 'packrat-etl-queue' || batch.queue === 'packrat-etl-queue-dev') { + if (!env.ETL_QUEUE) throw new Error('ETL_QUEUE is not configured'); + await processQueueBatch({ batch: batch as MessageBatch, env }); // safe-cast: batch queue name checked above; MessageBatch is compatible at runtime + } else if ( + batch.queue === 'packrat-embeddings-queue' || + batch.queue === 'packrat-embeddings-queue-dev' + ) { + if (!env.EMBEDDINGS_QUEUE) throw new Error('EMBEDDINGS_QUEUE is not configured'); + await new CatalogService({ explicitEnv: env, useHttpDriver: true }).handleEmbeddingsBatch( + batch, + ); + } else { + throw new Error(`Unknown queue: ${batch.queue}`); + } + } catch (error) { + captureApiException(error, { + operation: 'queue.handler', + tags: { queue_name: batch.queue }, + extra: { messageCount: batch.messages.length }, + }); + throw error; } }, } satisfies ExportedHandler; + +export default withSentry( + (env) => ({ + dsn: env.SENTRY_DSN, + environment: env.ENVIRONMENT ?? 'production', + tracesSampleRate: env.ENVIRONMENT === 'production' ? 0.1 : 1.0, + sendDefaultPii: false, + release: (env as unknown as Record).SENTRY_RELEASE as string | undefined, + }), + workerHandler, +); diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index 507378091b..e0ffe5962a 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -2,6 +2,7 @@ import { getAuth } from '@packrat/api/auth'; import { isValidApiKey } from '@packrat/api/utils/auth'; import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; import { getEnv } from '@packrat/api/utils/env-validation'; +import { apiAddBreadcrumb, captureApiException, setApiUser } from '@packrat/api/utils/sentry'; import { Elysia, status } from 'elysia'; export type AuthUser = { @@ -22,17 +23,40 @@ export const authPlugin = new Elysia({ name: 'packrat-auth' }).macro({ resolve: async ({ request }: { request: Request }) => { const env = getEnv() as ValidatedEnv; // safe-cast: Worker env validated at startup; TS can't narrow the return type const auth = await getAuth(env); - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) return status(401, { error: 'Unauthorized' }); - return { - user: { - userId: session.user.id, - role: (session.user as unknown as { role?: string }).role ?? 'USER', - email: session.user.email, - name: session.user.name, - }, + let session: Awaited>; + try { + session = await auth.api.getSession({ headers: request.headers }); + } catch (error) { + captureApiException(error, { + operation: 'auth.getSession', + tags: { path: new URL(request.url).pathname }, + }); + return status(500, { error: 'Authentication service unavailable' }); + } + + if (!session) { + apiAddBreadcrumb({ + category: 'auth', + message: 'Unauthenticated request rejected', + level: 'warning', + data: { path: new URL(request.url).pathname, method: request.method }, + }); + return status(401, { error: 'Unauthorized' }); + } + + const user = { + userId: session.user.id, + role: (session.user as unknown as { role?: string }).role ?? 'USER', + email: session.user.email, + name: session.user.name, }; + + // Attach user to the Sentry scope for this request so all subsequent + // captures are automatically associated with the authenticated user. + setApiUser({ id: user.userId, email: user.email, role: user.role }); + + return { user }; }, }, }); @@ -45,11 +69,32 @@ export const adminAuthPlugin = new Elysia({ name: 'packrat-admin-auth' }).use(au resolve: async ({ request }: { request: Request }) => { const env = getEnv() as ValidatedEnv; // safe-cast: Worker env validated at startup; TS can't narrow the return type const auth = await getAuth(env); - const session = await auth.api.getSession({ headers: request.headers }); + + let session: Awaited>; + try { + session = await auth.api.getSession({ headers: request.headers }); + } catch (error) { + captureApiException(error, { + operation: 'adminAuth.getSession', + tags: { path: new URL(request.url).pathname }, + }); + return status(500, { error: 'Authentication service unavailable' }); + } + if (!session) return status(401, { error: 'Unauthorized' }); const role = (session.user as unknown as { role?: string }).role; - if (role !== 'ADMIN') return status(403, { error: 'Forbidden' }); + if (role !== 'ADMIN') { + apiAddBreadcrumb({ + category: 'auth', + message: 'Admin access denied', + level: 'warning', + data: { userId: session.user.id, role, path: new URL(request.url).pathname }, + }); + return status(403, { error: 'Forbidden' }); + } + + setApiUser({ id: session.user.id, email: session.user.email, role: 'ADMIN' }); return { user: { @@ -70,6 +115,12 @@ export const apiKeyAuthPlugin = new Elysia({ name: 'packrat-api-key-auth' }).mac isValidApiKey: { resolve: ({ request }: { request: Request }) => { if (isValidApiKey(request.headers)) return { authorized: true }; + apiAddBreadcrumb({ + category: 'auth', + message: 'Invalid API key rejected', + level: 'warning', + data: { path: new URL(request.url).pathname }, + }); return status(401, { error: 'Unauthorized' }); }, }, diff --git a/packages/api/src/routes/chat.ts b/packages/api/src/routes/chat.ts index 85f2b7b991..ae55eb54a6 100644 --- a/packages/api/src/routes/chat.ts +++ b/packages/api/src/routes/chat.ts @@ -3,6 +3,7 @@ import { authPlugin } from '@packrat/api/middleware/auth'; import { createAIProvider } from '@packrat/api/utils/ai/provider'; import { createTools } from '@packrat/api/utils/ai/tools'; import { getEnv } from '@packrat/api/utils/env-validation'; +import { apiAddBreadcrumb, captureApiException } from '@packrat/api/utils/sentry'; import { reportedContent } from '@packrat/db'; import { ChatRequestSchema, @@ -93,9 +94,27 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) }); if (!aiProvider) { + captureApiException(new Error('AI provider not configured'), { + operation: 'chat.stream', + userId: user.userId, + tags: { ai_provider: AI_PROVIDER }, + }); return status(500, { error: 'AI provider not configured' }); } + apiAddBreadcrumb({ + category: 'ai.chat', + message: 'Starting AI chat stream', + level: 'info', + data: { + userId: user.userId, + contextType, + packId, + itemId, + messageCount: messages?.length ?? 0, + }, + }); + const result = streamText({ model: aiProvider(DEFAULT_MODELS.OPENAI_CHAT), system: systemPrompt, @@ -105,7 +124,12 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) temperature: 0.7, stopWhen: stepCountIs(5), onError: ({ error }) => { - console.error('streaming error', error); + captureApiException(error, { + operation: 'chat.stream.onError', + userId: user.userId, + tags: { ai_provider: AI_PROVIDER, context_type: contextType ?? 'none' }, + extra: { packId, itemId, messageCount: messages?.length ?? 0 }, + }); }, }); diff --git a/packages/api/src/routes/weather.ts b/packages/api/src/routes/weather.ts index 0920ab0861..7ac6667309 100644 --- a/packages/api/src/routes/weather.ts +++ b/packages/api/src/routes/weather.ts @@ -1,5 +1,6 @@ import { authPlugin } from '@packrat/api/middleware/auth'; import { getEnv } from '@packrat/api/utils/env-validation'; +import { captureApiException } from '@packrat/api/utils/sentry'; import { isString } from '@packrat/guards'; import { type WeatherAPICurrentResponse, @@ -20,7 +21,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) .use(authPlugin) .get( '/search', - async ({ query }) => { + async ({ query, user }) => { const { WEATHER_API_KEY } = getEnv(); const q = query.q; @@ -32,7 +33,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) const response = await fetch( `${WEATHER_API_BASE_URL}/search.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, ); - if (!response.ok) throw new Error(`API error: ${response.status}`); + if (!response.ok) throw new Error(`WeatherAPI HTTP ${response.status}`); const data = (await response.json()) as WeatherAPISearchResponse; // safe-cast: WeatherAPI.com response shape matches this type return data.map((item) => ({ @@ -44,7 +45,12 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) lon: isString(item.lon) ? Number.parseFloat(item.lon) : item.lon, })); } catch (error) { - console.error('Error searching weather locations:', error); + captureApiException(error, { + operation: 'weather.search', + userId: user?.userId, + tags: { weather_operation: 'search' }, + extra: { query: q }, + }); return status(500, { error: 'Internal server error', code: 'WEATHER_SEARCH_ERROR' }); } }, @@ -61,7 +67,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) ) .get( '/search-by-coordinates', - async ({ query }) => { + async ({ query, user }) => { const { WEATHER_API_KEY } = getEnv(); const latitude = Number.parseFloat(String(query.lat ?? '')); const longitude = Number.parseFloat(String(query.lon ?? '')); @@ -77,14 +83,14 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) const response = await fetch( `${WEATHER_API_BASE_URL}/search.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, ); - if (!response.ok) throw new Error(`API error: ${response.status}`); + if (!response.ok) throw new Error(`WeatherAPI HTTP ${response.status}`); const data = (await response.json()) as WeatherAPISearchResponse; // safe-cast: WeatherAPI.com response shape matches this type if (!data || data.length === 0) { const currentResponse = await fetch( `${WEATHER_API_BASE_URL}/current.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, ); - if (!currentResponse.ok) throw new Error(`API error: ${currentResponse.status}`); + if (!currentResponse.ok) throw new Error(`WeatherAPI HTTP ${currentResponse.status}`); const currentData = (await currentResponse.json()) as WeatherAPICurrentResponse; // safe-cast: WeatherAPI.com response shape matches this type if (currentData?.location) { @@ -111,7 +117,12 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) lon: isString(item.lon) ? Number.parseFloat(item.lon) : item.lon, })); } catch (error) { - console.error('Error searching weather locations by coordinates:', error); + captureApiException(error, { + operation: 'weather.searchByCoordinates', + userId: user?.userId, + tags: { weather_operation: 'search_by_coordinates' }, + extra: { latitude, longitude }, + }); return status(500, { error: 'Internal server error', code: 'WEATHER_COORD_SEARCH_ERROR', @@ -130,7 +141,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) ) .get( '/forecast', - async ({ query }) => { + async ({ query, user }) => { const { WEATHER_API_KEY } = getEnv(); const idParam = query.id; const id = Number(idParam); @@ -144,7 +155,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) const response = await fetch( `${WEATHER_API_BASE_URL}/forecast.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}&days=10&aqi=yes&alerts=yes`, ); - if (!response.ok) throw new Error(`API error: ${response.status}`); + if (!response.ok) throw new Error(`WeatherAPI HTTP ${response.status}`); const data = (await response.json()) as WeatherAPIForecastResponse; // safe-cast: WeatherAPI.com response shape matches this type return WeatherAPIForecastResponseSchema.parse({ @@ -157,10 +168,20 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) } catch (error) { if (error instanceof ZodError) { const invalidPaths = error.errors.map((e) => e.path.join('.')).join(', '); - console.error('Weather forecast response failed schema validation:', error.errors); + captureApiException(error, { + operation: 'weather.forecast.schemaValidation', + userId: user?.userId, + tags: { weather_operation: 'forecast', error_type: 'schema_validation' }, + extra: { locationId: id, invalidPaths }, + }); throw new Error(`Weather forecast response failed schema validation at: ${invalidPaths}`); } - console.error('Error fetching weather forecast:', error); + captureApiException(error, { + operation: 'weather.forecast', + userId: user?.userId, + tags: { weather_operation: 'forecast' }, + extra: { locationId: id }, + }); return status(500, { error: 'Internal server error', code: 'WEATHER_FORECAST_ERROR' }); } }, @@ -181,7 +202,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) // were doing. Returns 404 if no location matches. .get( '/by-name', - async ({ query }) => { + async ({ query, user }) => { const { WEATHER_API_KEY } = getEnv(); // Schema enforces z.string().min(2); Elysia rejects shorter values // before the handler runs. @@ -190,7 +211,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) const searchResponse = await fetch( `${WEATHER_API_BASE_URL}/search.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, ); - if (!searchResponse.ok) throw new Error(`API error: ${searchResponse.status}`); + if (!searchResponse.ok) throw new Error(`WeatherAPI HTTP ${searchResponse.status}`); const matches = (await searchResponse.json()) as WeatherAPISearchResponse; // safe-cast: WeatherAPI.com response shape matches this type const first = Array.isArray(matches) ? matches[0] : null; if (!first) { @@ -199,14 +220,19 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) const forecastResponse = await fetch( `${WEATHER_API_BASE_URL}/forecast.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(`id:${first.id}`)}&days=10&aqi=yes&alerts=yes`, ); - if (!forecastResponse.ok) throw new Error(`API error: ${forecastResponse.status}`); + if (!forecastResponse.ok) throw new Error(`WeatherAPI HTTP ${forecastResponse.status}`); const data = (await forecastResponse.json()) as WeatherAPIForecastResponse; // safe-cast: WeatherAPI.com response shape matches this type return { ...data, location: { ...data.location, id: Number(first.id) }, }; } catch (error) { - console.error('Error fetching weather by name:', error); + captureApiException(error, { + operation: 'weather.byName', + userId: user?.userId, + tags: { weather_operation: 'by_name' }, + extra: { query: q }, + }); return status(500, { error: 'Internal server error', code: 'WEATHER_BY_NAME_ERROR', diff --git a/packages/api/src/services/weatherService.ts b/packages/api/src/services/weatherService.ts index feffb9f3c0..065f48a210 100644 --- a/packages/api/src/services/weatherService.ts +++ b/packages/api/src/services/weatherService.ts @@ -1,4 +1,5 @@ import { getEnv } from '@packrat/api/utils/env-validation'; +import { captureApiException } from '@packrat/api/utils/sentry'; type WeatherData = { location: string; @@ -30,9 +31,15 @@ export class WeatherService { } catch { // response body not parseable — fall back to statusText } - throw new Error( + const error = new Error( `Weather API error ${response.status}: ${apiMessage} (location: "${location}")`, ); + captureApiException(error, { + operation: 'weatherService.getWeatherForLocation', + tags: { weather_api: 'openweathermap', http_status: String(response.status) }, + extra: { location, apiMessage }, + }); + throw error; } const data = (await response.json()) as { diff --git a/packages/api/src/utils/sentry.ts b/packages/api/src/utils/sentry.ts new file mode 100644 index 0000000000..ed0d0e0055 --- /dev/null +++ b/packages/api/src/utils/sentry.ts @@ -0,0 +1,71 @@ +/** + * Sentry helpers for the PackRat API (Cloudflare Workers). + * + * `withSentry` in index.ts initialises Sentry per-request via AsyncLocalStorage, + * so every function here safely operates on the current request scope. + */ + +import { + addBreadcrumb, + captureException, + captureMessage, + setUser, + withScope, +} from '@sentry/cloudflare'; + +export { addBreadcrumb, captureException, captureMessage, setUser, withScope }; + +export type SentryOperationContext = { + operation: string; + userId?: string; + tags?: Record; + extra?: Record; +}; + +/** + * Capture an exception with structured operation context. + * Logs to console as well so wrangler dev output is still useful. + */ +export function captureApiException(error: unknown, ctx: SentryOperationContext): void { + const { operation, userId, tags, extra } = ctx; + + withScope((scope) => { + scope.setTag('operation', operation); + if (userId) scope.setUser({ id: userId }); + if (tags) { + for (const [k, v] of Object.entries(tags)) scope.setTag(k, v); + } + if (extra) { + for (const [k, v] of Object.entries(extra)) scope.setExtra(k, v); + } + captureException(error); + }); + + console.error(`[sentry][${operation}]`, error); +} + +/** + * Add a structured breadcrumb. Falls back gracefully when Sentry is not init. + */ +export function apiAddBreadcrumb(opts: { + category: string; + message: string; + level?: 'debug' | 'info' | 'warning' | 'error'; + data?: Record; +}): void { + addBreadcrumb({ type: 'default', ...opts }); +} + +/** + * Set the authenticated user on the current request scope. + */ +export function setApiUser(user: { id: string; email: string; role: string }): void { + setUser({ id: user.id, email: user.email, username: user.role }); +} + +/** + * Clear user context (e.g. on sign-out or 401). + */ +export function clearApiUser(): void { + setUser(null); +} From 06348b5041fffb4ffcdfa809b5b4fc22f1051b10 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 10:16:03 +0100 Subject: [PATCH 2/7] fix(auth): surface meaningful error messages and capture root errors in Sentry Previously, Better Auth client error responses were wrapped in `new Error(error.message ?? fallback)` before being thrown and/or passed to Sentry. This had two problems: 1. Sentry received a synthetic error with no status code, error code, or original context. 2. Users saw either the server's raw message or a generic fallback with no actionable detail. Introduce `AuthClientError` and `toAuthError` (features/auth/lib/authErrors.ts) which: - Converts a Better Auth error object into a proper JS Error carrying `status` and `code` - Maps known Better Auth error codes (USER_ALREADY_EXISTS, INVALID_EMAIL_OR_PASSWORD, etc.) to clear user-facing copy - Falls back to a generic "something went wrong" for 5xx responses instead of leaking internal messages Update `useAuthActions` throughout: - Replace every `throw new Error(error.message ?? fallback)` with `toAuthError` - Fix the double-`new Error` antipattern in forgotPassword/resetPassword/verifyEmail/resendVerificationEmail where two separate error objects were created (one captured, one thrown) - Thread `httpStatus` and `errorCode` through every `captureException` extra so Sentry has the original HTTP context --- .../features/auth/hooks/useAuthActions.ts | 54 +++++++++++------ apps/expo/features/auth/lib/authErrors.ts | 60 +++++++++++++++++++ 2 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 apps/expo/features/auth/lib/authErrors.ts diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index 1991942254..da08579eaa 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -6,6 +6,7 @@ import { statusCodes, } from '@react-native-google-signin/google-signin'; import * as Sentry from '@sentry/react-native'; +import { AuthClientError, toAuthError } from 'expo-app/features/auth/lib/authErrors'; import { userStore } from 'expo-app/features/auth/store'; import type { User } from 'expo-app/features/profile/types'; import { authClient } from 'expo-app/lib/auth-client'; @@ -89,13 +90,16 @@ export function useAuthActions() { }); try { const { data, error } = await authClient.signIn.email({ email, password }); - if (error) throw new Error(error.message ?? 'Sign in failed'); + if (error) throw toAuthError(error, 'Sign in failed'); applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime Sentry.addBreadcrumb({ category: 'auth', message: 'Email sign in succeeded', level: 'info' }); } catch (error) { Sentry.captureException(error, { tags: { auth_method: 'email', auth_action: 'sign_in' }, - extra: { email }, + extra: { + email, + ...(error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}), + }, }); console.error('Sign in error:', error); throw error; @@ -118,7 +122,7 @@ export function useAuthActions() { provider: 'google', idToken: { token: idToken }, }); - if (error) throw new Error(error.message ?? t('auth.failedToSignInWithGoogle')); + if (error) throw toAuthError(error, t('auth.failedToSignInWithGoogle')); if (data && 'user' in data && data.user) { applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime Sentry.addBreadcrumb({ @@ -152,6 +156,7 @@ export function useAuthActions() { } else { Sentry.captureException(error, { tags: { auth_method: 'google', auth_action: 'sign_in' }, + extra: error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}, }); console.error('Google sign in error:', error); } @@ -177,7 +182,7 @@ export function useAuthActions() { provider: 'apple', idToken: { token: credential.identityToken ?? '' }, }); - if (error) throw new Error(error.message ?? 'Apple sign in failed'); + if (error) throw toAuthError(error, 'Apple sign in failed'); if (data && 'user' in data && data.user) { applySession(data.user as Record); // safe-cast: Better Auth user type omits additionalFields; role/preferredWeightUnit present at runtime Sentry.addBreadcrumb({ @@ -189,6 +194,7 @@ export function useAuthActions() { } catch (error) { Sentry.captureException(error, { tags: { auth_method: 'apple', auth_action: 'sign_in' }, + extra: error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}, }); console.error('Apple sign in error:', error); throw error; @@ -218,7 +224,7 @@ export function useAuthActions() { try { const name = [firstName, lastName].filter(Boolean).join(' ') || email; const { error } = await authClient.signUp.email({ email, password, name }); - if (error) throw new Error(error.message ?? 'Sign up failed'); + if (error) throw toAuthError(error, 'Sign up failed'); Sentry.addBreadcrumb({ category: 'auth', message: 'Email sign up succeeded', @@ -228,7 +234,10 @@ export function useAuthActions() { } catch (error) { Sentry.captureException(error, { tags: { auth_method: 'email', auth_action: 'sign_up' }, - extra: { email }, + extra: { + email, + ...(error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}), + }, }); console.error('Registration error:', error instanceof Error ? error.message : String(error)); throw error; @@ -275,11 +284,12 @@ export function useAuthActions() { redirectTo: 'packrat://reset-password', }); if (error) { - Sentry.captureException(new Error(error.message ?? 'Forgot password failed'), { + const err = toAuthError(error, 'Forgot password failed'); + Sentry.captureException(err, { tags: { auth_action: 'forgot_password' }, - extra: { email }, + extra: { email, httpStatus: error.status, errorCode: error.code }, }); - throw new Error(error.message ?? 'Forgot password failed'); + throw err; } }; @@ -299,10 +309,12 @@ export function useAuthActions() { newPassword: opts.newPassword, }); if (error) { - Sentry.captureException(new Error(error.message ?? 'Reset password failed'), { + const err = toAuthError(error, 'Reset password failed'); + Sentry.captureException(err, { tags: { auth_action: 'reset_password' }, + extra: { httpStatus: error.status, errorCode: error.code }, }); - throw new Error(error.message ?? 'Reset password failed'); + throw err; } }; @@ -314,10 +326,12 @@ export function useAuthActions() { }); const { data, error } = await authClient.verifyEmail({ query: { token } }); if (error) { - Sentry.captureException(new Error(error.message ?? 'Email verification failed'), { + const err = toAuthError(error, 'Email verification failed'); + Sentry.captureException(err, { tags: { auth_action: 'verify_email' }, + extra: { httpStatus: error.status, errorCode: error.code }, }); - throw new Error(error.message ?? 'Email verification failed'); + throw err; } const session = await authClient.getSession(); @@ -339,11 +353,12 @@ export function useAuthActions() { callbackURL: 'packrat://verify-email', }); if (error) { - Sentry.captureException(new Error(error.message ?? 'Failed to resend verification email'), { + const err = toAuthError(error, 'Failed to resend verification email'); + Sentry.captureException(err, { tags: { auth_action: 'resend_verification' }, - extra: { email }, + extra: { email, httpStatus: error.status, errorCode: error.code }, }); - throw new Error(error.message ?? 'Failed to resend verification email'); + throw err; } }; @@ -352,13 +367,16 @@ export function useAuthActions() { Sentry.addBreadcrumb({ category: 'auth', message: 'Account deletion initiated', level: 'warning' }); try { const { error } = await authClient.deleteUser(); - if (error) throw new Error(error.message ?? 'Delete account failed'); + if (error) throw toAuthError(error, 'Delete account failed'); Sentry.setUser(null); userStore.set(null); await clearLocalData(); await Updates.reloadAsync(); } catch (error) { - Sentry.captureException(error, { tags: { auth_action: 'delete_account' } }); + Sentry.captureException(error, { + tags: { auth_action: 'delete_account' }, + extra: error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}, + }); console.error('Delete account error:', error); throw error; } finally { diff --git a/apps/expo/features/auth/lib/authErrors.ts b/apps/expo/features/auth/lib/authErrors.ts new file mode 100644 index 0000000000..3cede353dc --- /dev/null +++ b/apps/expo/features/auth/lib/authErrors.ts @@ -0,0 +1,60 @@ +type BetterAuthError = { + message?: string | null; + status?: number; + statusText?: string; + code?: string; +}; + +// Maps Better Auth error codes to user-facing messages. +// Keep security-neutral where applicable (e.g. don't confirm whether a user exists). +const CODE_MESSAGES: Record = { + USER_ALREADY_EXISTS: 'An account with this email already exists. Try signing in instead.', + INVALID_EMAIL_OR_PASSWORD: 'Invalid email or password.', + INVALID_PASSWORD: 'Invalid email or password.', + USER_NOT_FOUND: 'Invalid email or password.', + EMAIL_NOT_VERIFIED: 'Please verify your email before signing in.', + TOO_MANY_REQUESTS: 'Too many attempts. Please wait a moment and try again.', + INVALID_TOKEN: 'This link has expired or is invalid. Please request a new one.', + EXPIRED_TOKEN: 'This link has expired. Please request a new one.', + PASSWORD_TOO_SHORT: 'Password is too short.', + SESSION_EXPIRED: 'Your session has expired. Please sign in again.', +}; + +/** + * Error thrown when a Better Auth client call returns an error response. + * Carries the original HTTP status and error code so Sentry has full context. + */ +export class AuthClientError extends Error { + readonly status: number; + readonly code: string | undefined; + + constructor(message: string, source: BetterAuthError) { + super(message); + this.name = 'AuthClientError'; + this.status = source.status ?? 0; + this.code = source.code; + } +} + +/** + * Converts a raw Better Auth error response into an AuthClientError with a + * user-friendly message. Maps known error codes to clear copy; falls back to + * the server message or a generic "try again" for 5xx responses. + */ +export function toAuthError(source: BetterAuthError, fallback: string): AuthClientError { + const code = source.code; + const status = source.status ?? 0; + + let message: string; + if (code && CODE_MESSAGES[code]) { + message = CODE_MESSAGES[code]; + } else if (status >= 500) { + message = 'Something went wrong on our end. Please try again in a moment.'; + } else if (source.message) { + message = source.message; + } else { + message = fallback; + } + + return new AuthClientError(message, source); +} From 08a8cd0847131b7ff366d8aa568510c309e0f0a3 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 21 May 2026 12:17:24 +0100 Subject: [PATCH 3/7] docs(agents): add comprehensive Sentry capture guidelines to agent instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Monitoring (Sentry)" section to CLAUDE.md and copilot-instructions.md covering: - Use addBreadcrumb before async operations - captureException in every catch block with tags and extra context - Never re-wrap the root error in new Error() before capturing — loses stack/status/code - Better Auth errors: use toAuthError from authErrors.ts to produce a single AuthClientError (one object to capture and throw, not two separate synthetic errors) - Include httpStatus and errorCode in extra for all HTTP errors - API side: use captureApiException from @packrat/api/utils/sentry Also adds a §10 "Sentry Instrumentation" check to the kieran-typescript-reviewer agent with concrete pass/fail examples so code reviews catch missing or broken capture patterns. --- .../kieran-typescript-reviewer.agent.md | 28 ++++++++++- CLAUDE.md | 49 +++++++++++++++++++ copilot-instructions.md | 48 ++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/.github/agents/kieran-typescript-reviewer.agent.md b/.github/agents/kieran-typescript-reviewer.agent.md index fd11a8df9d..8421b2012b 100644 --- a/.github/agents/kieran-typescript-reviewer.agent.md +++ b/.github/agents/kieran-typescript-reviewer.agent.md @@ -106,7 +106,33 @@ Consider extracting to a separate module when you see multiple of these: - Prefer immutable patterns over mutation - Use functional patterns where appropriate (map, filter, reduce) -## 10. CORE PHILOSOPHY +## 10. SENTRY INSTRUMENTATION + +Every async operation or external service call must have Sentry coverage: + +- **Breadcrumb before** — `Sentry.addBreadcrumb` (Expo) or `apiAddBreadcrumb` (API) before significant async steps. +- **`captureException` in every `catch`** — capture the actual thrown value, never a re-wrapped `new Error(error.message)`. Re-wrapping discards the original stack, HTTP status, and error code. +- **Better Auth errors**: plain objects `{ message, status, code }` must be converted via `toAuthError` from `expo-app/features/auth/lib/authErrors` before capturing and throwing. Never create two separate `new Error()` instances (one to capture, one to throw). +- **`extra` must include `httpStatus` and `errorCode`** for any HTTP error response so they're searchable in Sentry. +- On the API side, use `captureApiException` from `@packrat/api/utils/sentry` (not raw `captureException`). + +🔴 FAIL: +```ts +if (error) { + Sentry.captureException(new Error(error.message ?? 'failed'), { tags }); + throw new Error(error.message ?? 'failed'); +} +``` +✅ PASS: +```ts +if (error) { + const err = toAuthError(error, 'failed'); + Sentry.captureException(err, { tags, extra: { httpStatus: error.status, errorCode: error.code } }); + throw err; +} +``` + +## 11. CORE PHILOSOPHY - **Duplication > Complexity**: "I'd rather have four components with simple logic than three components that are all custom and have very complex things" - Simple, duplicated code that's easy to understand is BETTER than complex DRY abstractions diff --git a/CLAUDE.md b/CLAUDE.md index e86a7939f6..901dbecaf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,55 @@ features/{name}/ - TanStack React Query for data fetching - Zod for form validation +### Monitoring (Sentry) + +All new code that performs async operations or calls external services must include Sentry instrumentation. Sentry is already initialised per-platform — you only need to import and call the helpers. + +**Expo / React Native** — import from `@sentry/react-native`: + +```ts +import * as Sentry from '@sentry/react-native'; + +// Before an async operation +Sentry.addBreadcrumb({ category: 'feature', message: 'Action started', level: 'info', data: { ... } }); + +// In every catch block — capture the original error, never a re-wrapped one +} catch (error) { + Sentry.captureException(error, { + tags: { feature: 'myFeature', action: 'doThing' }, + extra: { userId, relevantId }, + }); + throw error; +} +``` + +- **Never wrap the root error** in `new Error(...)` before passing to `captureException` — that loses the original stack and context. +- **Better Auth errors** (plain objects with `{ message, status, code }`) are not JS Errors. Use `toAuthError` from `expo-app/features/auth/lib/authErrors` to convert them into an `AuthClientError` that carries `status` and `code`. Capture and throw that — do not create a separate synthetic error for Sentry and another for throwing. +- Include `httpStatus` and `errorCode` in `extra` for any HTTP error so they're searchable in Sentry. + +**API / Cloudflare Workers** — use helpers from `@packrat/api/utils/sentry`: + +```ts +import { apiAddBreadcrumb, captureApiException } from '@packrat/api/utils/sentry'; + +// Breadcrumb before significant async steps +apiAddBreadcrumb({ category: 'feature', message: 'Fetching external data', level: 'info' }); + +// In every catch block +} catch (error) { + captureApiException(error, { + operation: 'featureName.action', + userId, + tags: { feature: 'myFeature' }, + extra: { relevantId }, + }); + throw error; // or return an error response +} +``` + +- Use `captureApiException` (not raw `captureException`) — it wraps the call with structured operation context and also logs to console for wrangler dev output. +- Every route `catch` block and service method that interacts with the DB or an external API must have a `captureApiException` call. + ### API Client (`@packrat/api-client`) Use `createApiClient` from `@packrat/api-client` for all PackRat API calls in web apps. **Never write manual fetch wrappers for PackRat API endpoints.** diff --git a/copilot-instructions.md b/copilot-instructions.md index f52facf997..83baf28438 100644 --- a/copilot-instructions.md +++ b/copilot-instructions.md @@ -212,6 +212,54 @@ Always add new features behind a flag and default to `false` until the feature i - Tailwind CSS for all styling — no inline styles - Radix UI for accessible components +### Monitoring (Sentry) + +All new code that performs async operations or calls external services must include Sentry instrumentation. Sentry is already initialised per-platform — you only need to import and call the helpers. + +**Expo / React Native** — import from `@sentry/react-native`: + +```ts +import * as Sentry from '@sentry/react-native'; + +// Breadcrumb before async operations +Sentry.addBreadcrumb({ category: 'feature', message: 'Action started', level: 'info', data: { ... } }); + +// Every catch block must capture the original error +} catch (error) { + Sentry.captureException(error, { + tags: { feature: 'myFeature', action: 'doThing' }, + extra: { userId, relevantId }, + }); + throw error; +} +``` + +Rules: +- **Never wrap the root error** in `new Error(...)` before passing to `captureException` — wrapping loses the original stack trace and drops properties like HTTP status and error codes. +- **Better Auth client errors** are plain objects `{ message, status, code }`, not JS Errors. Use `toAuthError` from `expo-app/features/auth/lib/authErrors` to convert them into an `AuthClientError` carrying `.status` and `.code`. Capture that single error — do not create two separate `new Error()` objects (one to capture, one to throw). +- Include `httpStatus` and `errorCode` in `extra` for any HTTP error. + +**API / Cloudflare Workers** — use helpers from `@packrat/api/utils/sentry`: + +```ts +import { apiAddBreadcrumb, captureApiException } from '@packrat/api/utils/sentry'; + +apiAddBreadcrumb({ category: 'feature', message: 'Calling external service', level: 'info' }); + +} catch (error) { + captureApiException(error, { + operation: 'featureName.action', + userId, + tags: { feature: 'myFeature' }, + extra: { relevantId }, + }); + throw error; +} +``` + +- Use `captureApiException` (not the raw `captureException`) — it adds structured operation context and also logs to console for `wrangler dev` output. +- Every route `catch` block and service method touching the DB or an external API needs a `captureApiException` call. + ## Repository Structure ``` From b64562d928284ff54e146b3ea0b51ff0cff8ca67 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 23 May 2026 14:52:49 +0100 Subject: [PATCH 4/7] feat(sentry): instrument on-device AI, fix EAS environment, add RQ global handler, align route catches - localModelManager: capture download failures (HTTP status + network) and _prepareLlamaModel errors with device OS/version context; add breadcrumbs before each operation so the event trail is visible in Sentry - _layout.tsx + eas.json + app.config.ts: read APP_VARIANT from Constants.expoConfig.extra instead of NODE_ENV so development/preview EAS builds no longer report as environment:'production' in Sentry - TanstackProvider: wire QueryCache + MutationCache global onError handlers with 401/429/404 noise filter; every future hook gets coverage for free - trails, trailConditions, wildlife, packs, user: add captureApiException to catch blocks that were swallowing errors before the global handler; user routes capture then rethrow for double-context; trails skips intentional 503 --- apps/expo/app.config.ts | 1 + apps/expo/app/_layout.tsx | 5 ++- apps/expo/eas.json | 15 +++++++-- .../expo/features/ai/lib/localModelManager.ts | 29 +++++++++++++++++ apps/expo/providers/TanstackProvider.tsx | 30 +++++++++++++++-- packages/api/src/routes/packs/index.ts | 32 +++++++++++++++---- .../api/src/routes/trailConditions/reports.ts | 31 +++++++++++++++--- packages/api/src/routes/trails/index.ts | 19 +++++++++-- packages/api/src/routes/user/index.ts | 13 ++++++-- packages/api/src/routes/wildlife/index.ts | 8 +++-- 10 files changed, 159 insertions(+), 24 deletions(-) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index a60680d0cc..918a2bf31a 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -163,6 +163,7 @@ export default (): ExpoConfig => eas: { projectId: '267945b1-d9ac-4621-8541-826a2c70576d', }, + appVariant: IS_DEV ? 'development' : IS_PREVIEW ? 'preview' : 'production', }, updates: { url: 'https://u.expo.dev/267945b1-d9ac-4621-8541-826a2c70576d', diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index 96e157445d..d5c87e1eb4 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -2,6 +2,7 @@ import '../polyfills'; import { ThemeProvider as NavThemeProvider } from '@react-navigation/native'; import 'expo-app/lib/devClient'; +import Constants from 'expo-constants'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import '../global.css'; @@ -26,7 +27,9 @@ Sentry.init({ tracesSampleRate: 0.2, // Tag every event with environment so you can filter in the Sentry UI. - environment: clientEnvs.NODE_ENV ?? 'production', + // APP_VARIANT is set per EAS build profile and exposed via app.config.ts extra. + // Using it instead of NODE_ENV prevents all EAS builds from reporting as 'production'. + environment: (Constants.expoConfig?.extra?.appVariant as string) ?? 'production', // Trim noisy console breadcrumbs in production; keep them in dev. beforeBreadcrumb(breadcrumb) { diff --git a/apps/expo/eas.json b/apps/expo/eas.json index 38175655af..a085bda85d 100644 --- a/apps/expo/eas.json +++ b/apps/expo/eas.json @@ -7,12 +7,18 @@ "development": { "developmentClient": true, "distribution": "internal", - "channel": "development" + "channel": "development", + "env": { + "APP_VARIANT": "development" + } }, "preview": { "distribution": "internal", "autoIncrement": true, - "channel": "preview" + "channel": "preview", + "env": { + "APP_VARIANT": "preview" + } }, "e2e": { "environment": "preview", @@ -34,7 +40,10 @@ }, "production": { "autoIncrement": true, - "channel": "production" + "channel": "production", + "env": { + "APP_VARIANT": "production" + } } }, "submit": { diff --git a/apps/expo/features/ai/lib/localModelManager.ts b/apps/expo/features/ai/lib/localModelManager.ts index 6138a00f4a..e3292ae99c 100644 --- a/apps/expo/features/ai/lib/localModelManager.ts +++ b/apps/expo/features/ai/lib/localModelManager.ts @@ -11,6 +11,7 @@ import { isString } from '@packrat/guards'; import type { LlamaLanguageModel } from '@react-native-ai/llama'; import { llama } from '@react-native-ai/llama'; +import * as Sentry from '@sentry/react-native'; import type { LanguageModel } from 'ai'; import { store } from 'expo-app/atoms/store'; import { Platform } from 'react-native'; @@ -172,6 +173,16 @@ export async function downloadLocalModel(): Promise { if (!dirExists) { await RNBlobUtil.fs.mkdir(LLAMA_MODELS_DIR); } + Sentry.addBreadcrumb({ + category: 'localModel', + message: 'Model download started', + level: 'info', + data: { + modelId: LLAMA_MODEL_ID, + platform: Platform.OS, + osVersion: String(Platform.Version), + }, + }); activeDownloadTask = RNBlobUtil.config({ path: _getLlamaModelPath(), fileCache: true }).fetch( 'GET', _getLlamaDownloadUrl(), @@ -184,6 +195,10 @@ export async function downloadLocalModel(): Promise { const httpStatus = downloadRes.respInfo?.status ?? 0; if (httpStatus < 200 || httpStatus >= 300) { await RNBlobUtil.fs.unlink(_getLlamaModelPath()).catch(() => {}); + Sentry.captureException(new Error(`Model download failed: HTTP ${httpStatus}`), { + tags: { feature: 'localModel', action: 'download' }, + extra: { httpStatus, modelId: LLAMA_MODEL_ID, osVersion: String(Platform.Version) }, + }); store.set(localModelStatusAtom, 'error'); store.set(localModelErrorAtom, `Download failed: HTTP ${httpStatus}`); return; @@ -191,6 +206,10 @@ export async function downloadLocalModel(): Promise { } catch (err) { activeDownloadTask = null; if (_isCancellingDownload) return; + Sentry.captureException(err, { + tags: { feature: 'localModel', action: 'download' }, + extra: { modelId: LLAMA_MODEL_ID, osVersion: String(Platform.Version) }, + }); store.set(localModelStatusAtom, 'error'); store.set(localModelErrorAtom, err instanceof Error ? err.message : String(err)); return; @@ -315,6 +334,12 @@ async function _initLlamaModel(): Promise { async function _prepareLlamaModel(): Promise { store.set(localModelStatusAtom, 'preparing'); + Sentry.addBreadcrumb({ + category: 'localModel', + message: 'Model prepare started', + level: 'info', + data: { modelId: LLAMA_MODEL_ID, platform: Platform.OS, osVersion: String(Platform.Version) }, + }); try { if (!llamaModel) throw new Error('llamaModel is not initialised'); await llamaModel.prepare(); @@ -322,6 +347,10 @@ async function _prepareLlamaModel(): Promise { store.set(localModelFileAvailableAtom, true); store.set(localModelStatusAtom, 'ready'); } catch (err) { + Sentry.captureException(err, { + tags: { feature: 'localModel', action: 'prepare' }, + extra: { modelId: LLAMA_MODEL_ID, osVersion: String(Platform.Version) }, + }); store.set(localModelStatusAtom, 'error'); store.set(localModelErrorAtom, err instanceof Error ? err.message : String(err)); } diff --git a/apps/expo/providers/TanstackProvider.tsx b/apps/expo/providers/TanstackProvider.tsx index acd19b334d..a2265152f4 100644 --- a/apps/expo/providers/TanstackProvider.tsx +++ b/apps/expo/providers/TanstackProvider.tsx @@ -1,8 +1,34 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as Sentry from '@sentry/react-native'; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type React from 'react'; +// 401 = handled by auth refresh cycle; 429 = transient rate-limit; 404 = intentional not-found. +// Capturing these would flood Sentry with recoverable, non-actionable noise. +function shouldCapture(error: unknown): boolean { + const status = (error as { status?: number })?.status; + return status !== 401 && status !== 429 && status !== 404; +} + // Create a client -export const queryClient = new QueryClient(); +export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError(error, query) { + if (!shouldCapture(error)) return; + Sentry.captureException(error, { + tags: { feature: 'reactQuery', action: 'query' }, + extra: { queryKey: query.queryKey }, + }); + }, + }), + mutationCache: new MutationCache({ + onError(error) { + if (!shouldCapture(error)) return; + Sentry.captureException(error, { + tags: { feature: 'reactQuery', action: 'mutation' }, + }); + }, + }), +}); export function TanstackProvider({ children }: { children: React.ReactNode }) { return {children}; diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index cba9bb4dc5..828cf8e62d 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -12,6 +12,7 @@ import { getPackDetails } from '@packrat/api/utils/DbUtils'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; import { getPresignedUrl } from '@packrat/api/utils/getPresignedUrl'; +import { captureApiException } from '@packrat/api/utils/sentry'; import { catalogItems, type NewPack, @@ -195,7 +196,6 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) return result; } catch (error) { - console.error('Error analyzing image:', error); if (error instanceof Error) { if ( error.message.includes('Invalid image') || @@ -204,9 +204,17 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) ) { return status(400, { error: error.message }); } - return status(500, { error: `Failed to analyze image: ${error.message}` }); } - return status(500, { error: 'Failed to analyze image' }); + captureApiException(error, { + operation: 'packs.analyzeImage', + tags: { feature: 'packs' }, + }); + return status(500, { + error: + error instanceof Error + ? `Failed to analyze image: ${error.message}` + : 'Failed to analyze image', + }); } }, { @@ -259,7 +267,11 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) if (!canAccess) return status(403, { error: 'Unauthorized' }); return computePackBreakdown(pack); } catch (error) { - console.error('Error computing pack breakdown:', error); + captureApiException(error, { + operation: 'packs.weightBreakdown', + tags: { feature: 'packs' }, + extra: { packId: params.packId }, + }); return status(500, { error: 'Failed to compute breakdown' }); } }, @@ -308,7 +320,11 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) if (!updatedPack) return status(404, { error: 'Pack not found' }); return computePackWeights({ pack: updatedPack }); } catch (error) { - console.error('Error updating pack:', error); + captureApiException(error, { + operation: 'packs.update', + tags: { feature: 'packs' }, + extra: { packId: params.packId, userId: user.userId }, + }); return status(500, { error: 'Failed to update pack' }); } }, @@ -429,7 +445,11 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) updatedAt: entry.createdAt, })); } catch (error) { - console.error('Pack weight history API error:', error); + captureApiException(error, { + operation: 'packs.createWeightHistory', + tags: { feature: 'packs' }, + extra: { packId: params.packId, userId: user.userId }, + }); return status(500, { error: 'Failed to create weight history entry' }); } }, diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index 22b8722d1e..02f7953176 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -1,5 +1,6 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; +import { captureApiException } from '@packrat/api/utils/sentry'; import type { NewTrailConditionReport } from '@packrat/db'; import { trailConditionReports } from '@packrat/db'; import { @@ -61,7 +62,11 @@ export const trailConditionRoutes = new Elysia() return reports.map(toReportResponse); } catch (error) { - console.error('Error listing trail condition reports:', error); + captureApiException(error, { + operation: 'trailConditions.list', + tags: { feature: 'trailConditions' }, + extra: { trailName, limit }, + }); return status(500, { error: 'Failed to list trail condition reports' }); } }, @@ -122,7 +127,11 @@ export const trailConditionRoutes = new Elysia() if (existing) return toReportResponse(existing); return status(409, { error: 'Report ID already in use by another user' }); } - console.error('Error creating trail condition report:', error); + captureApiException(error, { + operation: 'trailConditions.create', + tags: { feature: 'trailConditions' }, + extra: { reportId: data.id, userId: user.userId }, + }); return status(500, { error: 'Failed to submit trail condition report' }); } }, @@ -159,7 +168,11 @@ export const trailConditionRoutes = new Elysia() return reports.map(toReportResponse); } catch (error) { - console.error('Error listing user trail condition reports:', error); + captureApiException(error, { + operation: 'trailConditions.listMine', + tags: { feature: 'trailConditions' }, + extra: { userId: user.userId, updatedAt }, + }); return status(500, { error: 'Failed to list trail condition reports' }); } }, @@ -214,7 +227,11 @@ export const trailConditionRoutes = new Elysia() return toReportResponse(updated); } catch (error) { - console.error('Error updating trail condition report:', error); + captureApiException(error, { + operation: 'trailConditions.update', + tags: { feature: 'trailConditions' }, + extra: { reportId, userId: user.userId }, + }); return status(500, { error: 'Failed to update trail condition report' }); } }, @@ -251,7 +268,11 @@ export const trailConditionRoutes = new Elysia() return { success: true }; } catch (error) { - console.error('Error deleting trail condition report:', error); + captureApiException(error, { + operation: 'trailConditions.delete', + tags: { feature: 'trailConditions' }, + extra: { reportId, userId: user.userId }, + }); return status(500, { error: 'Failed to delete trail condition report' }); } }, diff --git a/packages/api/src/routes/trails/index.ts b/packages/api/src/routes/trails/index.ts index 72e2a56ee5..4b4e4f61aa 100644 --- a/packages/api/src/routes/trails/index.ts +++ b/packages/api/src/routes/trails/index.ts @@ -1,6 +1,7 @@ import { createOsmDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; import { stitchRouteGeometry } from '@packrat/api/services/trails'; +import { captureApiException } from '@packrat/api/utils/sentry'; import { RouteDetailRowSchema, RouteSearchRowSchema } from '@packrat/schemas/trails'; import { sql } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; @@ -89,7 +90,11 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) if (error instanceof Error && error.message.includes('not configured')) { return status(503, { error: 'Trail features are not enabled on this server' }); } - console.error('Trail search error:', error); + captureApiException(error, { + operation: 'trails.search', + tags: { feature: 'trails' }, + extra: { q, lat, lon, radius, sport }, + }); return status(500, { error: 'Trail search failed' }); } }, @@ -171,7 +176,11 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) if (error instanceof Error && error.message.includes('not configured')) { return status(503, { error: 'Trail features are not enabled on this server' }); } - console.error('Trail geometry error:', error); + captureApiException(error, { + operation: 'trails.geometry', + tags: { feature: 'trails' }, + extra: { osmId: String(osmId) }, + }); return status(500, { error: 'Failed to fetch trail geometry' }); } }, @@ -234,7 +243,11 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) if (error instanceof Error && error.message.includes('not configured')) { return status(503, { error: 'Trail features are not enabled on this server' }); } - console.error('Trail fetch error:', error); + captureApiException(error, { + operation: 'trails.getById', + tags: { feature: 'trails' }, + extra: { osmId: String(osmId) }, + }); return status(500, { error: 'Failed to fetch trail' }); } }, diff --git a/packages/api/src/routes/user/index.ts b/packages/api/src/routes/user/index.ts index 6a097b22e1..000e72aacd 100644 --- a/packages/api/src/routes/user/index.ts +++ b/packages/api/src/routes/user/index.ts @@ -1,5 +1,6 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; +import { captureApiException } from '@packrat/api/utils/sentry'; import { users } from '@packrat/db'; import { ErrorResponseSchema } from '@packrat/schemas/shared'; import { @@ -48,7 +49,11 @@ export const userRoutes = new Elysia({ prefix: '/user' }) }, }); } catch (error) { - console.error('Error fetching user profile:', error); + captureApiException(error, { + operation: 'user.getProfile', + userId: user.userId, + tags: { feature: 'user' }, + }); throw error; } }, @@ -120,7 +125,11 @@ export const userRoutes = new Elysia({ prefix: '/user' }) }, }); } catch (error) { - console.error('Error updating user profile:', error); + captureApiException(error, { + operation: 'user.updateProfile', + userId: user.userId, + tags: { feature: 'user' }, + }); throw error; } }, diff --git a/packages/api/src/routes/wildlife/index.ts b/packages/api/src/routes/wildlife/index.ts index a896af25fd..021081ab58 100644 --- a/packages/api/src/routes/wildlife/index.ts +++ b/packages/api/src/routes/wildlife/index.ts @@ -3,6 +3,7 @@ import { authPlugin } from '@packrat/api/middleware/auth'; import { WildlifeIdentificationService } from '@packrat/api/services/wildlifeIdentificationService'; import { getEnv } from '@packrat/api/utils/env-validation'; import { getPresignedUrl } from '@packrat/api/utils/getPresignedUrl'; +import { captureApiException } from '@packrat/api/utils/sentry'; import { WildlifeIdentifyRequestSchema } from '@packrat/schemas/wildlife'; import { Elysia, status } from 'elysia'; @@ -34,8 +35,6 @@ export const wildlifeRoutes = new Elysia({ prefix: '/wildlife' }).use(authPlugin try { identification = await service.identifySpecies(imageUrl); } catch (error) { - console.error('Error identifying wildlife:', error); - // Clean up temp upload on error await PACKRAT_BUCKET.delete(image).catch((err: unknown) => { console.error('Failed to delete temp upload from R2:', err); @@ -50,6 +49,11 @@ export const wildlifeRoutes = new Elysia({ prefix: '/wildlife' }).use(authPlugin } } + captureApiException(error, { + operation: 'wildlife.identify', + userId: user.userId, + tags: { feature: 'wildlife' }, + }); return status(500, { error: 'Failed to identify species' }); } From 5e050856a6e4c1df4f0f020df61d07b1fcd954ce Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 23 May 2026 14:53:10 +0100 Subject: [PATCH 5/7] chore(api): sort package.json keys --- packages/api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/package.json b/packages/api/package.json index 0915f61e51..70277816ce 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -50,6 +50,7 @@ "@packrat/schemas": "workspace:*", "@packrat/types": "workspace:*", "@packrat/units": "workspace:*", + "@sentry/cloudflare": "^10.0.0", "@sinclair/typebox": "^0.34.15", "@types/nodemailer": "^6.4.17", "ai": "catalog:", @@ -70,7 +71,6 @@ "workers-ai-provider": "^0.7.2", "ws": "catalog:", "youtube-transcript": "^1.3.0", - "@sentry/cloudflare": "^10.0.0", "zod": "catalog:", "zod-openapi": "^5.4.6" }, From 47326977f69116b5cdeda01df38963dd6b5a9286 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 23 May 2026 14:53:50 +0100 Subject: [PATCH 6/7] fix(api): add SENTRY_RELEASE to Env type, remove unsafe cast in index.ts --- packages/api/src/index.ts | 2 +- packages/api/src/utils/env-validation.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0b36caf6b5..17ac7c68cc 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -157,7 +157,7 @@ export default withSentry( environment: env.ENVIRONMENT ?? 'production', tracesSampleRate: env.ENVIRONMENT === 'production' ? 0.1 : 1.0, sendDefaultPii: false, - release: (env as unknown as Record).SENTRY_RELEASE as string | undefined, + release: env.SENTRY_RELEASE, }), workerHandler, ); diff --git a/packages/api/src/utils/env-validation.ts b/packages/api/src/utils/env-validation.ts index 8f65926c96..31d5351cba 100644 --- a/packages/api/src/utils/env-validation.ts +++ b/packages/api/src/utils/env-validation.ts @@ -7,6 +7,7 @@ export const apiEnvSchema = z.object({ // Environment & Deployment ENVIRONMENT: z.enum(['development', 'production']).default('production'), SENTRY_DSN: z.string().url().optional(), + SENTRY_RELEASE: z.string().optional(), // Database NEON_DATABASE_URL: z.string().url(), From f80794ddca3776fa72a1280483fb60063ede9e59 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sun, 24 May 2026 15:17:16 +0100 Subject: [PATCH 7/7] fix(sentry): address PR review comments on #2469 - signInWithGoogle: move setIsLoading(false) to finally block so loading state always resets, even on successful sign-in - Redact raw email from Sentry breadcrumbs and extra (use emailDomain) to reduce PII exposure while keeping auth flow context - Add httpStatus/errorCode to sign-out, query/mutation cache, auth middleware, chat, packs, trailConditions, trails, weather Sentry captures so HTTP errors are searchable in Sentry - authErrors: align EXPIRED_TOKEN message with INVALID_TOKEN for consistent security-neutral copy - eas.json: add APP_VARIANT=preview to e2e profile so Sentry tags e2e builds as preview instead of falling back to production - _layout.tsx: replace auth-URL breadcrumb drop with URL param scrubbing for all HTTP breadcrumbs; remove module-level Sentry.setUser race (rely on applySession in useAuthActions) - ErrorBoundary: use beforeCapture to set error_source tag instead of manually calling captureException inside onError (avoids duplicate events); drop redundant errorName/errorMessage extras - sentry.ts: tag userId instead of calling setUser to avoid overwriting richer user context already set by setApiUser - index.ts: remove duplicate console.error (captureApiException already logs); coerce Elysia error code to string to fix TS2322 - weather.ts: return 500 response after capturing ZodError instead of re-throwing a synthetic Error (prevents double-capture by global onError) - weatherService.ts: move http_status from tags to extra as httpStatus, add errorCode for searchability --- .../kieran-typescript-reviewer.agent.md | 3 + apps/expo/app/_layout.tsx | 27 ++++----- .../expo/components/initial/ErrorBoundary.tsx | 34 ++++------- apps/expo/eas.json | 3 + .../features/auth/hooks/useAuthActions.ts | 60 +++++++++++++------ apps/expo/features/auth/lib/authErrors.ts | 2 +- apps/expo/providers/TanstackProvider.tsx | 21 +++++-- bun.lock | 21 ++++++- packages/api/src/index.ts | 6 +- packages/api/src/middleware/auth.ts | 2 + packages/api/src/routes/chat.ts | 1 + packages/api/src/routes/packs/index.ts | 28 ++++++--- .../api/src/routes/trailConditions/reports.ts | 30 ++++++++-- packages/api/src/routes/trails/index.ts | 6 +- packages/api/src/routes/weather.ts | 17 ++++-- packages/api/src/services/weatherService.ts | 9 ++- packages/api/src/utils/sentry.ts | 4 +- 17 files changed, 178 insertions(+), 96 deletions(-) diff --git a/.github/agents/kieran-typescript-reviewer.agent.md b/.github/agents/kieran-typescript-reviewer.agent.md index 8421b2012b..e8aaa69b27 100644 --- a/.github/agents/kieran-typescript-reviewer.agent.md +++ b/.github/agents/kieran-typescript-reviewer.agent.md @@ -117,13 +117,16 @@ Every async operation or external service call must have Sentry coverage: - On the API side, use `captureApiException` from `@packrat/api/utils/sentry` (not raw `captureException`). 🔴 FAIL: + ```ts if (error) { Sentry.captureException(new Error(error.message ?? 'failed'), { tags }); throw new Error(error.message ?? 'failed'); } ``` + ✅ PASS: + ```ts if (error) { const err = toAuthError(error, 'failed'); diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index d5c87e1eb4..496596bf01 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -10,7 +10,6 @@ import '../global.css'; import { clientEnvs } from '@packrat/env/expo-client'; import { Alert, type AlertMethods } from '@packrat/ui/nativewindui'; import * as Sentry from '@sentry/react-native'; -import { userStore } from 'expo-app/features/auth/store'; import { useColorScheme, useInitialAndroidBarSync } from 'expo-app/lib/hooks/useColorScheme'; import { Providers } from 'expo-app/providers'; import { NAV_THEME } from 'expo-app/theme'; @@ -31,28 +30,24 @@ Sentry.init({ // Using it instead of NODE_ENV prevents all EAS builds from reporting as 'production'. environment: (Constants.expoConfig?.extra?.appVariant as string) ?? 'production', - // Trim noisy console breadcrumbs in production; keep them in dev. + // Scrub sensitive query parameters from all HTTP breadcrumbs to prevent token leakage. beforeBreadcrumb(breadcrumb) { if (breadcrumb.type === 'http' && breadcrumb.data?.url) { - // Strip auth tokens from URLs in breadcrumbs - const url = String(breadcrumb.data.url); - if (url.includes('/api/auth/')) return null; + try { + const parsed = new URL(String(breadcrumb.data.url)); + const SENSITIVE_PARAMS = ['token', 'access_token', 'auth', 'password', 'jwt', 'session']; + for (const key of SENSITIVE_PARAMS) { + if (parsed.searchParams.has(key)) parsed.searchParams.set(key, '[REDACTED]'); + } + breadcrumb.data.url = parsed.toString(); + } catch { + // URL parsing failed — leave breadcrumb unchanged + } } return breadcrumb; }, }); -// Sync the authenticated user to Sentry on startup so every event is -// associated with the correct user identity. -const user = userStore.peek(); -if (user) { - Sentry.setUser({ - id: user.id, - email: user.email, - username: `${user.firstName} ${user.lastName}`.trim(), - }); -} - export { // Catch any errors thrown by the Layout component. ErrorBoundary, diff --git a/apps/expo/components/initial/ErrorBoundary.tsx b/apps/expo/components/initial/ErrorBoundary.tsx index e249dd8bb4..28b5003ace 100644 --- a/apps/expo/components/initial/ErrorBoundary.tsx +++ b/apps/expo/components/initial/ErrorBoundary.tsx @@ -49,34 +49,20 @@ const DefaultFallback = () => { }; export function ErrorBoundary({ children, fallback, onReset, onError }: ErrorBoundaryProps) { - const handleError = ({ error, info }: { error: unknown; info: { componentStack: string } }) => { - console.error('Error caught by ErrorBoundary:', error); - console.error('Component stack:', info.componentStack); - - // Attach the component stack as extra context so Sentry shows exactly - // which component tree caused the crash. - Sentry.withScope((scope) => { - scope.setTag('error_source', 'error_boundary'); - scope.setExtra('componentStack', info.componentStack); - if (error instanceof Error) { - scope.setExtra('errorName', error.name); - scope.setExtra('errorMessage', error.message); - } - Sentry.captureException(error); - }); - - if (onError) { - onError(error, info); - } - }; - return ( - handleError({ error, info: { componentStack: componentStack || '' } }) - } + beforeCapture={(scope) => { + scope.setTag('error_source', 'error_boundary'); + }} + onError={(error: unknown, componentStack: ErrorInfo['componentStack']) => { + console.error('Error caught by ErrorBoundary:', error); + console.error('Component stack:', componentStack); + if (onError) { + onError(error, { componentStack: componentStack || '' }); + } + }} > {children} diff --git a/apps/expo/eas.json b/apps/expo/eas.json index a085bda85d..3ee33efd87 100644 --- a/apps/expo/eas.json +++ b/apps/expo/eas.json @@ -24,6 +24,9 @@ "environment": "preview", "distribution": "internal", "channel": "preview", + "env": { + "APP_VARIANT": "preview" + }, "ios": { "simulator": true }, diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index da08579eaa..1762edda44 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -86,7 +86,7 @@ export function useAuthActions() { category: 'auth', message: 'Email sign in attempt', level: 'info', - data: { email }, + data: { emailDomain: email.split('@')[1] }, }); try { const { data, error } = await authClient.signIn.email({ email, password }); @@ -97,8 +97,9 @@ export function useAuthActions() { Sentry.captureException(error, { tags: { auth_method: 'email', auth_action: 'sign_in' }, extra: { - email, - ...(error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}), + ...(error instanceof AuthClientError + ? { httpStatus: error.status, errorCode: error.code } + : {}), }, }); console.error('Sign in error:', error); @@ -132,8 +133,6 @@ export function useAuthActions() { }); } } catch (error) { - setIsLoading(false); - if (isErrorWithCode(error) && error.code === statusCodes.SIGN_IN_CANCELLED) { Sentry.addBreadcrumb({ category: 'auth', @@ -156,11 +155,16 @@ export function useAuthActions() { } else { Sentry.captureException(error, { tags: { auth_method: 'google', auth_action: 'sign_in' }, - extra: error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}, + extra: + error instanceof AuthClientError + ? { httpStatus: error.status, errorCode: error.code } + : {}, }); console.error('Google sign in error:', error); } throw error; + } finally { + setIsLoading(false); } }; @@ -194,7 +198,10 @@ export function useAuthActions() { } catch (error) { Sentry.captureException(error, { tags: { auth_method: 'apple', auth_action: 'sign_in' }, - extra: error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}, + extra: + error instanceof AuthClientError + ? { httpStatus: error.status, errorCode: error.code } + : {}, }); console.error('Apple sign in error:', error); throw error; @@ -219,7 +226,11 @@ export function useAuthActions() { category: 'auth', message: 'Email sign up attempt', level: 'info', - data: { email, hasFirstName: !!firstName, hasLastName: !!lastName }, + data: { + emailDomain: email.split('@')[1], + hasFirstName: !!firstName, + hasLastName: !!lastName, + }, }); try { const name = [firstName, lastName].filter(Boolean).join(' ') || email; @@ -229,14 +240,14 @@ export function useAuthActions() { category: 'auth', message: 'Email sign up succeeded', level: 'info', - data: { email }, }); } catch (error) { Sentry.captureException(error, { tags: { auth_method: 'email', auth_action: 'sign_up' }, extra: { - email, - ...(error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}), + ...(error instanceof AuthClientError + ? { httpStatus: error.status, errorCode: error.code } + : {}), }, }); console.error('Registration error:', error instanceof Error ? error.message : String(error)); @@ -259,7 +270,13 @@ export function useAuthActions() { // Clear user identity from Sentry on sign-out. Sentry.setUser(null); } catch (error) { - Sentry.captureException(error, { tags: { auth_action: 'sign_out' } }); + Sentry.captureException(error, { + tags: { auth_action: 'sign_out' }, + extra: + error instanceof AuthClientError + ? { httpStatus: error.status, errorCode: error.code } + : {}, + }); console.error('Sign out error:', error); } finally { userStore.set(null); @@ -277,7 +294,7 @@ export function useAuthActions() { category: 'auth', message: 'Password reset requested', level: 'info', - data: { email }, + data: { emailDomain: email.split('@')[1] }, }); const { error } = await authClient.requestPasswordReset({ email, @@ -287,7 +304,7 @@ export function useAuthActions() { const err = toAuthError(error, 'Forgot password failed'); Sentry.captureException(err, { tags: { auth_action: 'forgot_password' }, - extra: { email, httpStatus: error.status, errorCode: error.code }, + extra: { httpStatus: error.status, errorCode: error.code }, }); throw err; } @@ -346,7 +363,7 @@ export function useAuthActions() { category: 'auth', message: 'Verification email resend requested', level: 'info', - data: { email }, + data: { emailDomain: email.split('@')[1] }, }); const { error } = await authClient.sendVerificationEmail({ email, @@ -356,7 +373,7 @@ export function useAuthActions() { const err = toAuthError(error, 'Failed to resend verification email'); Sentry.captureException(err, { tags: { auth_action: 'resend_verification' }, - extra: { email, httpStatus: error.status, errorCode: error.code }, + extra: { httpStatus: error.status, errorCode: error.code }, }); throw err; } @@ -364,7 +381,11 @@ export function useAuthActions() { const deleteAccount = async () => { setIsLoading(true); - Sentry.addBreadcrumb({ category: 'auth', message: 'Account deletion initiated', level: 'warning' }); + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Account deletion initiated', + level: 'warning', + }); try { const { error } = await authClient.deleteUser(); if (error) throw toAuthError(error, 'Delete account failed'); @@ -375,7 +396,10 @@ export function useAuthActions() { } catch (error) { Sentry.captureException(error, { tags: { auth_action: 'delete_account' }, - extra: error instanceof AuthClientError ? { httpStatus: error.status, errorCode: error.code } : {}, + extra: + error instanceof AuthClientError + ? { httpStatus: error.status, errorCode: error.code } + : {}, }); console.error('Delete account error:', error); throw error; diff --git a/apps/expo/features/auth/lib/authErrors.ts b/apps/expo/features/auth/lib/authErrors.ts index 3cede353dc..cfe773764c 100644 --- a/apps/expo/features/auth/lib/authErrors.ts +++ b/apps/expo/features/auth/lib/authErrors.ts @@ -15,7 +15,7 @@ const CODE_MESSAGES: Record = { EMAIL_NOT_VERIFIED: 'Please verify your email before signing in.', TOO_MANY_REQUESTS: 'Too many attempts. Please wait a moment and try again.', INVALID_TOKEN: 'This link has expired or is invalid. Please request a new one.', - EXPIRED_TOKEN: 'This link has expired. Please request a new one.', + EXPIRED_TOKEN: 'This link has expired or is invalid. Please request a new one.', PASSWORD_TOO_SHORT: 'Password is too short.', SESSION_EXPIRED: 'Your session has expired. Please sign in again.', }; diff --git a/apps/expo/providers/TanstackProvider.tsx b/apps/expo/providers/TanstackProvider.tsx index a2265152f4..d2719353ec 100644 --- a/apps/expo/providers/TanstackProvider.tsx +++ b/apps/expo/providers/TanstackProvider.tsx @@ -4,27 +4,36 @@ import type React from 'react'; // 401 = handled by auth refresh cycle; 429 = transient rate-limit; 404 = intentional not-found. // Capturing these would flood Sentry with recoverable, non-actionable noise. -function shouldCapture(error: unknown): boolean { - const status = (error as { status?: number })?.status; - return status !== 401 && status !== 429 && status !== 404; +function getHttpMeta(error: unknown): { + capture: boolean; + httpStatus?: number; + errorCode?: string; +} { + const e = error as { status?: number; code?: string; errorCode?: string }; + const httpStatus = e?.status; + if (httpStatus === 401 || httpStatus === 429 || httpStatus === 404) return { capture: false }; + return { capture: true, httpStatus, errorCode: e?.errorCode ?? e?.code }; } // Create a client export const queryClient = new QueryClient({ queryCache: new QueryCache({ onError(error, query) { - if (!shouldCapture(error)) return; + const { capture, httpStatus, errorCode } = getHttpMeta(error); + if (!capture) return; Sentry.captureException(error, { tags: { feature: 'reactQuery', action: 'query' }, - extra: { queryKey: query.queryKey }, + extra: { queryKey: query.queryKey, httpStatus, errorCode }, }); }, }), mutationCache: new MutationCache({ onError(error) { - if (!shouldCapture(error)) return; + const { capture, httpStatus, errorCode } = getHttpMeta(error); + if (!capture) return; Sentry.captureException(error, { tags: { feature: 'reactQuery', action: 'mutation' }, + extra: { httpStatus, errorCode }, }); }, }), diff --git a/bun.lock b/bun.lock index 95b75188be..2c1120ce74 100644 --- a/bun.lock +++ b/bun.lock @@ -473,6 +473,7 @@ "@packrat/schemas": "workspace:*", "@packrat/types": "workspace:*", "@packrat/units": "workspace:*", + "@sentry/cloudflare": "^10.0.0", "@sinclair/typebox": "^0.34.15", "@types/nodemailer": "^6.4.17", "ai": "catalog:", @@ -1980,7 +1981,9 @@ "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.4", "", { "os": "win32", "cpu": "x64" }, "sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w=="], - "@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + "@sentry/cloudflare": ["@sentry/cloudflare@10.53.1", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.53.1" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-iSohVibGRAKg7zLUflfA2ePG69Uw6bqm6iCQLM18hoG2gT4DGigaKcjJmZLTfAtW1DInMCb0DYc/mltCznxMrQ=="], + + "@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], "@sentry/hub": ["@sentry/hub@6.19.7", "", { "dependencies": { "@sentry/types": "6.19.7", "@sentry/utils": "6.19.7", "tslib": "^1.9.3" } }, "sha512-y3OtbYFAqKHCWezF0EGGr5lcyI2KbaXW2Ik7Xp8Mu9TxbSTuwTe4rTntwg8ngPjUQU3SUHzgjqVB8qjiGqFXCA=="], @@ -5090,6 +5093,16 @@ "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], + "@sentry-internal/browser-utils/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + + "@sentry-internal/feedback/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + + "@sentry-internal/replay/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + + "@sentry-internal/replay-canvas/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + + "@sentry/browser/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "@sentry/cli/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -5112,6 +5125,12 @@ "@sentry/node/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@sentry/react/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + + "@sentry/react-native/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + + "@sentry/types/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + "@sentry/utils/@sentry/types": ["@sentry/types@6.19.7", "", {}, "sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg=="], "@sentry/utils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 17ac7c68cc..9511b86275 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -48,18 +48,16 @@ export const app = new Elysia({ adapter: CloudflareAdapter }) ) .use(packratOpenApi) .onError(({ error, code, request }) => { - console.error('Error occurred:', error); - // Only report unexpected server errors — not user-input or routing errors. if (code !== 'VALIDATION' && code !== 'PARSE' && code !== 'NOT_FOUND') { captureApiException(error, { operation: 'elysia.onError', tags: { - error_code: code, + error_code: String(code), method: request?.method ?? 'UNKNOWN', path: request ? new URL(request.url).pathname : 'UNKNOWN', }, - extra: { errorCode: code }, + extra: { errorCode: String(code), httpStatus: 500 }, }); } diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index e0ffe5962a..c3677e55d3 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -31,6 +31,7 @@ export const authPlugin = new Elysia({ name: 'packrat-auth' }).macro({ captureApiException(error, { operation: 'auth.getSession', tags: { path: new URL(request.url).pathname }, + extra: { httpStatus: 500, errorCode: 'AUTH_SESSION_UNAVAILABLE' }, }); return status(500, { error: 'Authentication service unavailable' }); } @@ -77,6 +78,7 @@ export const adminAuthPlugin = new Elysia({ name: 'packrat-admin-auth' }).use(au captureApiException(error, { operation: 'adminAuth.getSession', tags: { path: new URL(request.url).pathname }, + extra: { httpStatus: 500, errorCode: 'AUTH_SESSION_UNAVAILABLE' }, }); return status(500, { error: 'Authentication service unavailable' }); } diff --git a/packages/api/src/routes/chat.ts b/packages/api/src/routes/chat.ts index ae55eb54a6..4654a88d86 100644 --- a/packages/api/src/routes/chat.ts +++ b/packages/api/src/routes/chat.ts @@ -98,6 +98,7 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) operation: 'chat.stream', userId: user.userId, tags: { ai_provider: AI_PROVIDER }, + extra: { httpStatus: 500, errorCode: 'AI_PROVIDER_NOT_CONFIGURED' }, }); return status(500, { error: 'AI provider not configured' }); } diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 828cf8e62d..8bab42f285 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -208,13 +208,9 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) captureApiException(error, { operation: 'packs.analyzeImage', tags: { feature: 'packs' }, + extra: { httpStatus: 500, errorCode: 'PACKS_ANALYZE_IMAGE_ERROR' }, }); - return status(500, { - error: - error instanceof Error - ? `Failed to analyze image: ${error.message}` - : 'Failed to analyze image', - }); + return status(500, { error: 'Failed to analyze image' }); } }, { @@ -270,7 +266,11 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) captureApiException(error, { operation: 'packs.weightBreakdown', tags: { feature: 'packs' }, - extra: { packId: params.packId }, + extra: { + packId: params.packId, + httpStatus: 500, + errorCode: 'PACKS_WEIGHT_BREAKDOWN_ERROR', + }, }); return status(500, { error: 'Failed to compute breakdown' }); } @@ -323,7 +323,12 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) captureApiException(error, { operation: 'packs.update', tags: { feature: 'packs' }, - extra: { packId: params.packId, userId: user.userId }, + extra: { + packId: params.packId, + userId: user.userId, + httpStatus: 500, + errorCode: 'PACKS_UPDATE_ERROR', + }, }); return status(500, { error: 'Failed to update pack' }); } @@ -448,7 +453,12 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) captureApiException(error, { operation: 'packs.createWeightHistory', tags: { feature: 'packs' }, - extra: { packId: params.packId, userId: user.userId }, + extra: { + packId: params.packId, + userId: user.userId, + httpStatus: 500, + errorCode: 'PACKS_WEIGHT_HISTORY_ERROR', + }, }); return status(500, { error: 'Failed to create weight history entry' }); } diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index 02f7953176..63366a5e89 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -65,7 +65,7 @@ export const trailConditionRoutes = new Elysia() captureApiException(error, { operation: 'trailConditions.list', tags: { feature: 'trailConditions' }, - extra: { trailName, limit }, + extra: { trailName, limit, httpStatus: 500, errorCode: 'TRAIL_CONDITIONS_LIST_ERROR' }, }); return status(500, { error: 'Failed to list trail condition reports' }); } @@ -130,7 +130,12 @@ export const trailConditionRoutes = new Elysia() captureApiException(error, { operation: 'trailConditions.create', tags: { feature: 'trailConditions' }, - extra: { reportId: data.id, userId: user.userId }, + extra: { + reportId: data.id, + userId: user.userId, + httpStatus: 500, + errorCode: 'TRAIL_CONDITIONS_CREATE_ERROR', + }, }); return status(500, { error: 'Failed to submit trail condition report' }); } @@ -171,7 +176,12 @@ export const trailConditionRoutes = new Elysia() captureApiException(error, { operation: 'trailConditions.listMine', tags: { feature: 'trailConditions' }, - extra: { userId: user.userId, updatedAt }, + extra: { + userId: user.userId, + updatedAt, + httpStatus: 500, + errorCode: 'TRAIL_CONDITIONS_LIST_MINE_ERROR', + }, }); return status(500, { error: 'Failed to list trail condition reports' }); } @@ -230,7 +240,12 @@ export const trailConditionRoutes = new Elysia() captureApiException(error, { operation: 'trailConditions.update', tags: { feature: 'trailConditions' }, - extra: { reportId, userId: user.userId }, + extra: { + reportId, + userId: user.userId, + httpStatus: 500, + errorCode: 'TRAIL_CONDITIONS_UPDATE_ERROR', + }, }); return status(500, { error: 'Failed to update trail condition report' }); } @@ -271,7 +286,12 @@ export const trailConditionRoutes = new Elysia() captureApiException(error, { operation: 'trailConditions.delete', tags: { feature: 'trailConditions' }, - extra: { reportId, userId: user.userId }, + extra: { + reportId, + userId: user.userId, + httpStatus: 500, + errorCode: 'TRAIL_CONDITIONS_DELETE_ERROR', + }, }); return status(500, { error: 'Failed to delete trail condition report' }); } diff --git a/packages/api/src/routes/trails/index.ts b/packages/api/src/routes/trails/index.ts index 4b4e4f61aa..be7db762a6 100644 --- a/packages/api/src/routes/trails/index.ts +++ b/packages/api/src/routes/trails/index.ts @@ -93,7 +93,7 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) captureApiException(error, { operation: 'trails.search', tags: { feature: 'trails' }, - extra: { q, lat, lon, radius, sport }, + extra: { q, lat, lon, radius, sport, httpStatus: 500, errorCode: 'TRAILS_SEARCH_ERROR' }, }); return status(500, { error: 'Trail search failed' }); } @@ -179,7 +179,7 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) captureApiException(error, { operation: 'trails.geometry', tags: { feature: 'trails' }, - extra: { osmId: String(osmId) }, + extra: { osmId: String(osmId), httpStatus: 500, errorCode: 'TRAILS_GEOMETRY_ERROR' }, }); return status(500, { error: 'Failed to fetch trail geometry' }); } @@ -246,7 +246,7 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) captureApiException(error, { operation: 'trails.getById', tags: { feature: 'trails' }, - extra: { osmId: String(osmId) }, + extra: { osmId: String(osmId), httpStatus: 500, errorCode: 'TRAILS_GET_BY_ID_ERROR' }, }); return status(500, { error: 'Failed to fetch trail' }); } diff --git a/packages/api/src/routes/weather.ts b/packages/api/src/routes/weather.ts index 7ac6667309..91e912a337 100644 --- a/packages/api/src/routes/weather.ts +++ b/packages/api/src/routes/weather.ts @@ -49,7 +49,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) operation: 'weather.search', userId: user?.userId, tags: { weather_operation: 'search' }, - extra: { query: q }, + extra: { query: q, httpStatus: 500, errorCode: 'WEATHER_SEARCH_ERROR' }, }); return status(500, { error: 'Internal server error', code: 'WEATHER_SEARCH_ERROR' }); } @@ -121,7 +121,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) operation: 'weather.searchByCoordinates', userId: user?.userId, tags: { weather_operation: 'search_by_coordinates' }, - extra: { latitude, longitude }, + extra: { latitude, longitude, httpStatus: 500, errorCode: 'WEATHER_COORD_SEARCH_ERROR' }, }); return status(500, { error: 'Internal server error', @@ -172,15 +172,20 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) operation: 'weather.forecast.schemaValidation', userId: user?.userId, tags: { weather_operation: 'forecast', error_type: 'schema_validation' }, - extra: { locationId: id, invalidPaths }, + extra: { + locationId: id, + invalidPaths, + httpStatus: 500, + errorCode: 'WEATHER_FORECAST_SCHEMA_ERROR', + }, }); - throw new Error(`Weather forecast response failed schema validation at: ${invalidPaths}`); + return status(500, { error: 'Internal server error', code: 'WEATHER_FORECAST_ERROR' }); } captureApiException(error, { operation: 'weather.forecast', userId: user?.userId, tags: { weather_operation: 'forecast' }, - extra: { locationId: id }, + extra: { locationId: id, httpStatus: 500, errorCode: 'WEATHER_FORECAST_ERROR' }, }); return status(500, { error: 'Internal server error', code: 'WEATHER_FORECAST_ERROR' }); } @@ -231,7 +236,7 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) operation: 'weather.byName', userId: user?.userId, tags: { weather_operation: 'by_name' }, - extra: { query: q }, + extra: { query: q, httpStatus: 500, errorCode: 'WEATHER_BY_NAME_ERROR' }, }); return status(500, { error: 'Internal server error', diff --git a/packages/api/src/services/weatherService.ts b/packages/api/src/services/weatherService.ts index 065f48a210..0b7f46cdc1 100644 --- a/packages/api/src/services/weatherService.ts +++ b/packages/api/src/services/weatherService.ts @@ -36,8 +36,13 @@ export class WeatherService { ); captureApiException(error, { operation: 'weatherService.getWeatherForLocation', - tags: { weather_api: 'openweathermap', http_status: String(response.status) }, - extra: { location, apiMessage }, + tags: { weather_api: 'openweathermap' }, + extra: { + location, + apiMessage, + httpStatus: response.status, + errorCode: 'OPENWEATHERMAP_HTTP_ERROR', + }, }); throw error; } diff --git a/packages/api/src/utils/sentry.ts b/packages/api/src/utils/sentry.ts index ed0d0e0055..c888d598cb 100644 --- a/packages/api/src/utils/sentry.ts +++ b/packages/api/src/utils/sentry.ts @@ -31,7 +31,9 @@ export function captureApiException(error: unknown, ctx: SentryOperationContext) withScope((scope) => { scope.setTag('operation', operation); - if (userId) scope.setUser({ id: userId }); + // Use a tag for userId rather than setUser to avoid overwriting richer + // user context (email/role) already set on the scope by setApiUser. + if (userId) scope.setTag('user_id', userId); if (tags) { for (const [k, v] of Object.entries(tags)) scope.setTag(k, v); }