Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/agents/kieran-typescript-reviewer.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
```

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## 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
Expand Down
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
1 change: 1 addition & 0 deletions apps/expo/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
39 changes: 29 additions & 10 deletions apps/expo/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,51 @@ 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';

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';
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;
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

export {
// Catch any errors thrown by the Layout component.
Expand Down
24 changes: 10 additions & 14 deletions apps/expo/components/initial/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Sentry.ErrorBoundary
fallback={fallback ? fallback : DefaultFallback}
onReset={onReset}
onError={(error: unknown, componentStack: ErrorInfo['componentStack']) =>
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}
</Sentry.ErrorBoundary>
Expand Down
18 changes: 15 additions & 3 deletions apps/expo/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand All @@ -34,7 +43,10 @@
},
"production": {
"autoIncrement": true,
"channel": "production"
"channel": "production",
"env": {
"APP_VARIANT": "production"
}
}
},
"submit": {
Expand Down
29 changes: 29 additions & 0 deletions apps/expo/features/ai/lib/localModelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -183,6 +184,16 @@ export async function downloadLocalModel(): Promise<void> {
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(),
Expand All @@ -196,6 +207,10 @@ export async function downloadLocalModel(): Promise<void> {
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;
Expand All @@ -204,6 +219,10 @@ export async function downloadLocalModel(): Promise<void> {
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));
}
Expand Down Expand Up @@ -336,13 +355,23 @@ async function _initLlamaModel(): Promise<void> {

async function _prepareLlamaModel(): Promise<void> {
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();
llamaModelWrapper = new LlamaToolsWrapper(llamaModel);
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));
}
Expand Down
Loading
Loading