diff --git a/.github/agents/kieran-typescript-reviewer.agent.md b/.github/agents/kieran-typescript-reviewer.agent.md
index fd11a8df9d..e8aaa69b27 100644
--- a/.github/agents/kieran-typescript-reviewer.agent.md
+++ b/.github/agents/kieran-typescript-reviewer.agent.md
@@ -106,7 +106,36 @@ 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/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 b5cdf42ace..496596bf01 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';
@@ -9,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';
@@ -17,17 +17,36 @@ 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,
-});
-const user = userStore.peek();
-if (user) {
- Sentry.setUser(user);
-}
+ // 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.
+ // 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',
+
+ // Scrub sensitive query parameters from all HTTP breadcrumbs to prevent token leakage.
+ beforeBreadcrumb(breadcrumb) {
+ if (breadcrumb.type === 'http' && breadcrumb.data?.url) {
+ 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;
+ },
+});
export {
// Catch any errors thrown by the Layout component.
diff --git a/apps/expo/components/initial/ErrorBoundary.tsx b/apps/expo/components/initial/ErrorBoundary.tsx
index 26f836efe8..28b5003ace 100644
--- a/apps/expo/components/initial/ErrorBoundary.tsx
+++ b/apps/expo/components/initial/ErrorBoundary.tsx
@@ -49,24 +49,20 @@ 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
- 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 38175655af..3ee33efd87 100644
--- a/apps/expo/eas.json
+++ b/apps/expo/eas.json
@@ -7,17 +7,26 @@
"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",
"distribution": "internal",
"channel": "preview",
+ "env": {
+ "APP_VARIANT": "preview"
+ },
"ios": {
"simulator": true
},
@@ -34,7 +43,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 771b183271..a79b3bacc8 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 { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
@@ -183,6 +184,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(),
@@ -196,6 +207,10 @@ export async function downloadLocalModel(): Promise {
console.log('[KeepAwake] download finished, httpStatus=', httpStatus);
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;
@@ -204,6 +219,10 @@ export async function downloadLocalModel(): Promise {
activeDownloadTask = null;
console.log('[KeepAwake] catch, _isCancellingDownload=', _isCancellingDownload, 'err=', err);
if (!_isCancellingDownload) {
+ 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));
}
@@ -336,6 +355,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();
@@ -343,6 +368,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/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts
index 30a9a189ca..1762edda44 100644
--- a/apps/expo/features/auth/hooks/useAuthActions.ts
+++ b/apps/expo/features/auth/hooks/useAuthActions.ts
@@ -5,6 +5,8 @@ import {
isErrorWithCode,
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';
@@ -64,18 +66,42 @@ 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: { emailDomain: email.split('@')[1] },
+ });
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: {
+ ...(error instanceof AuthClientError
+ ? { httpStatus: error.status, errorCode: error.code }
+ : {}),
+ },
+ });
console.error('Sign in error:', error);
throw error;
} finally {
@@ -85,6 +111,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();
@@ -96,26 +123,54 @@ export function useAuthActions() {
provider: 'google',
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 (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({
+ 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' },
+ extra:
+ error instanceof AuthClientError
+ ? { httpStatus: error.status, errorCode: error.code }
+ : {},
+ });
console.error('Google sign in error:', error);
}
throw error;
+ } finally {
+ setIsLoading(false);
}
};
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'));
@@ -131,9 +186,23 @@ export function useAuthActions() {
provider: 'apple',
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 (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({
+ category: 'auth',
+ message: 'Apple sign in succeeded',
+ level: 'info',
+ });
+ }
} 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;
} finally {
@@ -153,11 +222,34 @@ export function useAuthActions() {
lastName?: string;
}) => {
setIsLoading(true);
+ Sentry.addBreadcrumb({
+ category: 'auth',
+ message: 'Email sign up attempt',
+ level: 'info',
+ data: {
+ emailDomain: email.split('@')[1],
+ 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');
+ if (error) throw toAuthError(error, 'Sign up failed');
+ Sentry.addBreadcrumb({
+ category: 'auth',
+ message: 'Email sign up succeeded',
+ level: 'info',
+ });
} catch (error) {
+ Sentry.captureException(error, {
+ tags: { auth_method: 'email', auth_action: 'sign_up' },
+ extra: {
+ ...(error instanceof AuthClientError
+ ? { httpStatus: error.status, errorCode: error.code }
+ : {}),
+ },
+ });
console.error('Registration error:', error instanceof Error ? error.message : String(error));
throw error;
} finally {
@@ -170,11 +262,21 @@ 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' },
+ extra:
+ error instanceof AuthClientError
+ ? { httpStatus: error.status, errorCode: error.code }
+ : {},
+ });
console.error('Sign out error:', error);
} finally {
userStore.set(null);
@@ -188,11 +290,24 @@ export function useAuthActions() {
};
const forgotPassword = async (email: string) => {
+ Sentry.addBreadcrumb({
+ category: 'auth',
+ message: 'Password reset requested',
+ level: 'info',
+ data: { emailDomain: email.split('@')[1] },
+ });
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: 'packrat://reset-password',
});
- if (error) throw new Error(error.message ?? 'Forgot password failed');
+ if (error) {
+ const err = toAuthError(error, 'Forgot password failed');
+ Sentry.captureException(err, {
+ tags: { auth_action: 'forgot_password' },
+ extra: { httpStatus: error.status, errorCode: error.code },
+ });
+ throw err;
+ }
};
const resetPassword = async ({
@@ -201,16 +316,40 @@ 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) {
+ const err = toAuthError(error, 'Reset password failed');
+ Sentry.captureException(err, {
+ tags: { auth_action: 'reset_password' },
+ extra: { httpStatus: error.status, errorCode: error.code },
+ });
+ throw err;
+ }
};
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) {
+ const err = toAuthError(error, 'Email verification failed');
+ Sentry.captureException(err, {
+ tags: { auth_action: 'verify_email' },
+ extra: { httpStatus: error.status, errorCode: error.code },
+ });
+ throw err;
+ }
const session = await authClient.getSession();
if (session.data?.user) {
@@ -220,22 +359,48 @@ export function useAuthActions() {
};
const resendVerificationEmail = async (email: string) => {
+ Sentry.addBreadcrumb({
+ category: 'auth',
+ message: 'Verification email resend requested',
+ level: 'info',
+ data: { emailDomain: email.split('@')[1] },
+ });
const { error } = await authClient.sendVerificationEmail({
email,
callbackURL: 'packrat://verify-email',
});
- if (error) throw new Error(error.message ?? 'Failed to resend verification email');
+ if (error) {
+ const err = toAuthError(error, 'Failed to resend verification email');
+ Sentry.captureException(err, {
+ tags: { auth_action: 'resend_verification' },
+ extra: { httpStatus: error.status, errorCode: error.code },
+ });
+ throw err;
+ }
};
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');
+ 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' },
+ 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/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/apps/expo/features/auth/lib/authErrors.ts b/apps/expo/features/auth/lib/authErrors.ts
new file mode 100644
index 0000000000..cfe773764c
--- /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 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.',
+};
+
+/**
+ * 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);
+}
diff --git a/apps/expo/providers/TanstackProvider.tsx b/apps/expo/providers/TanstackProvider.tsx
index acd19b334d..d2719353ec 100644
--- a/apps/expo/providers/TanstackProvider.tsx
+++ b/apps/expo/providers/TanstackProvider.tsx
@@ -1,8 +1,43 @@
-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 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();
+export const queryClient = new QueryClient({
+ queryCache: new QueryCache({
+ onError(error, query) {
+ const { capture, httpStatus, errorCode } = getHttpMeta(error);
+ if (!capture) return;
+ Sentry.captureException(error, {
+ tags: { feature: 'reactQuery', action: 'query' },
+ extra: { queryKey: query.queryKey, httpStatus, errorCode },
+ });
+ },
+ }),
+ mutationCache: new MutationCache({
+ onError(error) {
+ const { capture, httpStatus, errorCode } = getHttpMeta(error);
+ if (!capture) return;
+ Sentry.captureException(error, {
+ tags: { feature: 'reactQuery', action: 'mutation' },
+ extra: { httpStatus, errorCode },
+ });
+ },
+ }),
+});
export function TanstackProvider({ children }: { children: React.ReactNode }) {
return {children};
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/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
```
diff --git a/packages/api/package.json b/packages/api/package.json
index 8cd17032ee..963f2a9632 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -54,6 +54,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:",
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index b47b79ebd0..9511b86275 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,20 @@ export const app = new Elysia({ adapter: CloudflareAdapter })
}),
)
.use(packratOpenApi)
- .onError(({ error, code }) => {
- console.error('Error occurred:', error);
+ .onError(({ error, code, request }) => {
+ // 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: String(code),
+ method: request?.method ?? 'UNKNOWN',
+ path: request ? new URL(request.url).pathname : 'UNKNOWN',
+ },
+ extra: { errorCode: String(code), httpStatus: 500 },
+ });
+ }
+
if (code === 'VALIDATION' || code === 'PARSE') {
return new Response(JSON.stringify({ error: 'Validation failed' }), {
status: 400,
@@ -90,7 +104,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 +123,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.SENTRY_RELEASE,
+ }),
+ workerHandler,
+);
diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts
index 507378091b..c3677e55d3 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,41 @@ 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 },
+ extra: { httpStatus: 500, errorCode: 'AUTH_SESSION_UNAVAILABLE' },
+ });
+ 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 +70,33 @@ 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 },
+ extra: { httpStatus: 500, errorCode: 'AUTH_SESSION_UNAVAILABLE' },
+ });
+ 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 +117,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..4654a88d86 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,28 @@ 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 },
+ extra: { httpStatus: 500, errorCode: 'AI_PROVIDER_NOT_CONFIGURED' },
+ });
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 +125,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/packs/index.ts b/packages/api/src/routes/packs/index.ts
index cba9bb4dc5..8bab42f285 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,8 +204,12 @@ export const packsRoutes = new Elysia({ prefix: '/packs' })
) {
return status(400, { error: error.message });
}
- return status(500, { error: `Failed to analyze image: ${error.message}` });
}
+ captureApiException(error, {
+ operation: 'packs.analyzeImage',
+ tags: { feature: 'packs' },
+ extra: { httpStatus: 500, errorCode: 'PACKS_ANALYZE_IMAGE_ERROR' },
+ });
return status(500, { error: 'Failed to analyze image' });
}
},
@@ -259,7 +263,15 @@ 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,
+ httpStatus: 500,
+ errorCode: 'PACKS_WEIGHT_BREAKDOWN_ERROR',
+ },
+ });
return status(500, { error: 'Failed to compute breakdown' });
}
},
@@ -308,7 +320,16 @@ 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,
+ httpStatus: 500,
+ errorCode: 'PACKS_UPDATE_ERROR',
+ },
+ });
return status(500, { error: 'Failed to update pack' });
}
},
@@ -429,7 +450,16 @@ 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,
+ 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 22b8722d1e..63366a5e89 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, httpStatus: 500, errorCode: 'TRAIL_CONDITIONS_LIST_ERROR' },
+ });
return status(500, { error: 'Failed to list trail condition reports' });
}
},
@@ -122,7 +127,16 @@ 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,
+ httpStatus: 500,
+ errorCode: 'TRAIL_CONDITIONS_CREATE_ERROR',
+ },
+ });
return status(500, { error: 'Failed to submit trail condition report' });
}
},
@@ -159,7 +173,16 @@ 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,
+ httpStatus: 500,
+ errorCode: 'TRAIL_CONDITIONS_LIST_MINE_ERROR',
+ },
+ });
return status(500, { error: 'Failed to list trail condition reports' });
}
},
@@ -214,7 +237,16 @@ 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,
+ httpStatus: 500,
+ errorCode: 'TRAIL_CONDITIONS_UPDATE_ERROR',
+ },
+ });
return status(500, { error: 'Failed to update trail condition report' });
}
},
@@ -251,7 +283,16 @@ 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,
+ 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 72e2a56ee5..be7db762a6 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, httpStatus: 500, errorCode: 'TRAILS_SEARCH_ERROR' },
+ });
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), httpStatus: 500, errorCode: 'TRAILS_GEOMETRY_ERROR' },
+ });
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), httpStatus: 500, errorCode: 'TRAILS_GET_BY_ID_ERROR' },
+ });
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/weather.ts b/packages/api/src/routes/weather.ts
index 0920ab0861..91e912a337 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, httpStatus: 500, errorCode: 'WEATHER_SEARCH_ERROR' },
+ });
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, httpStatus: 500, errorCode: 'WEATHER_COORD_SEARCH_ERROR' },
+ });
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,25 @@ 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);
- throw new Error(`Weather forecast response failed schema validation at: ${invalidPaths}`);
+ captureApiException(error, {
+ operation: 'weather.forecast.schemaValidation',
+ userId: user?.userId,
+ tags: { weather_operation: 'forecast', error_type: 'schema_validation' },
+ extra: {
+ locationId: id,
+ invalidPaths,
+ httpStatus: 500,
+ errorCode: 'WEATHER_FORECAST_SCHEMA_ERROR',
+ },
+ });
+ return status(500, { error: 'Internal server error', code: 'WEATHER_FORECAST_ERROR' });
}
- console.error('Error fetching weather forecast:', error);
+ captureApiException(error, {
+ operation: 'weather.forecast',
+ userId: user?.userId,
+ tags: { weather_operation: 'forecast' },
+ extra: { locationId: id, httpStatus: 500, errorCode: 'WEATHER_FORECAST_ERROR' },
+ });
return status(500, { error: 'Internal server error', code: 'WEATHER_FORECAST_ERROR' });
}
},
@@ -181,7 +207,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 +216,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 +225,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, httpStatus: 500, errorCode: 'WEATHER_BY_NAME_ERROR' },
+ });
return status(500, {
error: 'Internal server error',
code: 'WEATHER_BY_NAME_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' });
}
diff --git a/packages/api/src/services/weatherService.ts b/packages/api/src/services/weatherService.ts
index feffb9f3c0..0b7f46cdc1 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,20 @@ 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' },
+ extra: {
+ location,
+ apiMessage,
+ httpStatus: response.status,
+ errorCode: 'OPENWEATHERMAP_HTTP_ERROR',
+ },
+ });
+ throw error;
}
const data = (await response.json()) as {
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(),
diff --git a/packages/api/src/utils/sentry.ts b/packages/api/src/utils/sentry.ts
new file mode 100644
index 0000000000..c888d598cb
--- /dev/null
+++ b/packages/api/src/utils/sentry.ts
@@ -0,0 +1,73 @@
+/**
+ * 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);
+ // 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);
+ }
+ 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);
+}