diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..481a886 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Sentry Configuration +# EXPO_PUBLIC_SENTRY_DSN= + +# Sentry Source Map Upload (set these as EAS Secrets for production builds) +# SENTRY_AUTH_TOKEN= +# SENTRY_ORG=stepfi-org +# SENTRY_PROJECT=stepfi-app \ No newline at end of file diff --git a/.gitignore b/.gitignore index a23cacd..3fe909d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Environment secrets +.env +.env.local + node_modules/ .expo/ dist/ diff --git a/app.json b/app.json index 26c77e8..154bee3 100644 --- a/app.json +++ b/app.json @@ -17,7 +17,15 @@ "expo-router", "expo-secure-store", "expo-font", - "expo-local-authentication" + "expo-local-authentication", + [ + "@sentry/react-native", + { + "organization": "stepfi-org", + "project": "stepfi-app", + "url": "https://sentry.io/" + } + ] ], "orientation": "portrait", "icon": "./assets/icon.png", diff --git a/app/_layout.tsx b/app/_layout.tsx index 3c630f5..f253af0 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,8 +10,19 @@ import { BiometricGate } from '../src/components/BiometricGate'; import { useConnectivityStore } from '../src/offline/connectivity.store'; import { processQueue } from '../src/offline/offline-sync'; import { initPromise } from '../src/locales/i18n'; +import { SentryErrorBoundary } from '../components/SentryErrorBoundary'; +import { + initSentry, + setSentryUser, + clearSentryUser, + addBreadcrumb, + Sentry, +} from '../services/sentry'; import '../global.css'; +// Initialise Sentry as early as possible (module‑level, before any component) +initSentry(); + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; function useAuthGuard() { @@ -33,7 +44,22 @@ function useAuthGuard() { }, [isAuthenticated, isLoading, segments, router]); } -export default function RootLayout() { +// Sentry user‑context sync — attach / clear wallet identity on auth changes +function useSentryUserContext() { + const walletAddress = useAuthStore((s) => s.walletAddress); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (isAuthenticated && walletAddress) { + setSentryUser(walletAddress); + addBreadcrumb('auth', 'User context set', { hasWallet: true }); + } else { + clearSentryUser(); + } + }, [isAuthenticated, walletAddress]); +} + +function RootLayout() { const [i18nReady, setI18nReady] = useState(false); const hydrate = useAuthStore((s) => s.hydrate); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -66,6 +92,7 @@ export default function RootLayout() { }, [hydrate]); useAuthGuard(); + useSentryUserContext(); useEffect(() => { if (!isLoading && isAuthenticated && !biometricCheckDone) { @@ -84,6 +111,8 @@ export default function RootLayout() { useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { + addBreadcrumb('app.lifecycle', `AppState → ${state}`); + if (state === 'active') { const elapsed = Date.now() - lastActiveRef.current; if ( @@ -140,24 +169,28 @@ export default function RootLayout() { } return ( - - {isLocked && isAuthenticated ? ( - - ) : ( - { - if (!useSecurityStore.getState().isLocked) { - startIdleTimer(); - } - }} - > - - - - - - )} - + + + {isLocked && isAuthenticated ? ( + + ) : ( + { + if (!useSecurityStore.getState().isLocked) { + startIdleTimer(); + } + }} + > + + + + + + )} + + ); } + +export default Sentry.wrap(RootLayout); \ No newline at end of file diff --git a/components/SentryErrorBoundary.tsx b/components/SentryErrorBoundary.tsx new file mode 100644 index 0000000..9239001 --- /dev/null +++ b/components/SentryErrorBoundary.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import * as Sentry from '@sentry/react-native'; + +interface FallbackProps { + resetError: () => void; +} + +function ErrorFallback({ resetError }: FallbackProps) { + return ( + + ⚠️ + Something went wrong + + An unexpected error occurred. Our team has been notified. + + + Reload App + + + ); +} + +interface Props { + children: React.ReactNode; +} + +export function SentryErrorBoundary({ children }: Props) { + return ( + } + showDialog={false} + > + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#080F1A', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + }, + emoji: { + fontSize: 48, + marginBottom: 16, + }, + title: { + fontSize: 22, + fontWeight: '700', + color: '#FFFFFF', + marginBottom: 8, + textAlign: 'center', + }, + subtitle: { + fontSize: 15, + color: '#94A3B8', + textAlign: 'center', + lineHeight: 22, + marginBottom: 32, + }, + button: { + backgroundColor: '#6366F1', + paddingHorizontal: 32, + paddingVertical: 14, + borderRadius: 12, + }, + buttonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/package-lock.json b/package-lock.json index 73fc7a3..90b47bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^11.4.1", "@react-navigation/native": "^7.1.8", + "@sentry/react-native": "~7.2.0", "@walletconnect/sign-client": "^2.23.9", "@walletconnect/types": "^2.23.9", "axios": "^1.16.0", @@ -3864,6 +3865,342 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.12.0.tgz", + "integrity": "sha512-dozbx389jhKynj0d657FsgbBVOar7pX3mb6GjqCxslXF0VKpZH2Xks0U32RgDY/nK27O+o095IWz7YvjVmPkDw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.12.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.12.0.tgz", + "integrity": "sha512-0+7ceO6yQPPqfxRc9ue/xoPHKcnB917ezPaehGQNfAFNQB9PNTG1y55+8mRu0Fw+ANbZeCt/HyoCmXuRdxmkpg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.12.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.12.0.tgz", + "integrity": "sha512-/1093gSNGN5KlOBsuyAl33JkzGiG38kCnxswQLZWpPpR6LBbR1Ddb18HjhDpoQNNEZybJBgJC3a5NKl43C2TSQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.12.0", + "@sentry/core": "10.12.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.12.0.tgz", + "integrity": "sha512-W/z1/+69i3INNfPjD1KuinSNaRQaApjzwb37IFmiyF440F93hxmEYgXHk3poOlYYaigl2JMYbysGPWOiXnqUXA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.12.0", + "@sentry/core": "10.12.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz", + "integrity": "sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.12.0.tgz", + "integrity": "sha512-lKyaB2NFmr7SxPjmMTLLhQ7xfxaY3kdkMhpzuRI5qwOngtKt4+FtvNYHRuz+PTtEFv4OaHhNNbRn6r91gWguQg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.12.0", + "@sentry-internal/feedback": "10.12.0", + "@sentry-internal/replay": "10.12.0", + "@sentry-internal/replay-canvas": "10.12.0", + "@sentry/core": "10.12.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.55.0.tgz", + "integrity": "sha512-cynvcIM2xL8ddwELyFRSpZQw4UtFZzoM2rId2l9vg7+wDREPDocMJB9lEQpBIo3eqhp9JswqUT037yjO6iJ5Sw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.55.0", + "@sentry/cli-linux-arm": "2.55.0", + "@sentry/cli-linux-arm64": "2.55.0", + "@sentry/cli-linux-i686": "2.55.0", + "@sentry/cli-linux-x64": "2.55.0", + "@sentry/cli-win32-arm64": "2.55.0", + "@sentry/cli-win32-i686": "2.55.0", + "@sentry/cli-win32-x64": "2.55.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.55.0.tgz", + "integrity": "sha512-jGHE7SHHzqXUmnsmRLgorVH6nmMmTjQQXdPZbSL5tRtH8d3OIYrVNr5D72DSgD26XAPBDMV0ibqOQ9NKoiSpfA==", + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.55.0.tgz", + "integrity": "sha512-ATjU0PsiWADSPLF/kZroLZ7FPKd5W9TDWHVkKNwIUNTei702LFgTjNeRwOIzTgSvG3yTmVEqtwFQfFN/7hnVXQ==", + "cpu": [ + "arm" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.55.0.tgz", + "integrity": "sha512-jNB/0/gFcOuDCaY/TqeuEpsy/k52dwyk1SOV3s1ku4DUsln6govTppeAGRewY3T1Rj9B2vgIWTrnB8KVh9+Rgg==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.55.0.tgz", + "integrity": "sha512-8LZjo6PncTM6bWdaggscNOi5r7F/fqRREsCwvd51dcjGj7Kp1plqo9feEzYQ+jq+KUzVCiWfHrUjddFmYyZJrg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.55.0.tgz", + "integrity": "sha512-5LUVvq74Yj2cZZy5g5o/54dcWEaX4rf3myTHy73AKhRj1PABtOkfexOLbF9xSrZy95WXWaXyeH+k5n5z/vtHfA==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.55.0.tgz", + "integrity": "sha512-cWIQdzm1pfLwPARsV6dUb8TVd6Y3V1A2VWxjTons3Ift6GvtVmiAe0OWL8t2Yt95i8v61kTD/6Tq21OAaogqzA==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.55.0.tgz", + "integrity": "sha512-ldepCn2t9r4I0wvgk7NRaA7coJyy4rTQAzM66u9j5nTEsUldf66xym6esd5ZZRAaJUjffqvHqUIr/lrieTIrVg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.55.0.tgz", + "integrity": "sha512-4hPc/I/9tXx+HLTdTGwlagtAfDSIa2AoTUP30tl32NAYQhx9a6niUbPAemK2qfxesiufJ7D2djX83rCw6WnJVA==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/cli/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/@sentry/core": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.12.0.tgz", + "integrity": "sha512-Jrf0Yo7DvmI/ZQcvBnA0xKNAFkJlVC/fMlvcin+5IrFNRcqOToZ2vtF+XqTgjRZymXQNE8s1QTD7IomPHk0TAw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.12.0.tgz", + "integrity": "sha512-TpqgdoYbkf5JynmmW2oQhHQ/h5w+XPYk0cEb/UrsGlvJvnBSR+5tgh0AqxCSi3gvtp82rAXI5w1TyRPBbhLDBw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.12.0", + "@sentry/core": "10.12.0", + "hoist-non-react-statics": "^3.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/react-native": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-7.2.0.tgz", + "integrity": "sha512-rjqYgEjntPz1sPysud78wi4B9ui7LBVPsG6qr8s/htLMYho9GPGFA5dF+eqsQWqMX8NDReAxNkLTC4+gCNklLQ==", + "license": "MIT", + "dependencies": { + "@sentry/babel-plugin-component-annotate": "4.3.0", + "@sentry/browser": "10.12.0", + "@sentry/cli": "2.55.0", + "@sentry/core": "10.12.0", + "@sentry/react": "10.12.0", + "@sentry/types": "10.12.0" + }, + "bin": { + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + }, + "peerDependencies": { + "expo": ">=49.0.0", + "react": ">=17.0.0", + "react-native": ">=0.65.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@sentry/types": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-10.12.0.tgz", + "integrity": "sha512-sKGj3l3V8ZKISh2Tu88bHfnm5ztkRtSLdmpZ6TmCeJdSM9pV+RRd6CMJ0RnSEXmYHselPNUod521t2NQFd4W1w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.12.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -9371,6 +9708,21 @@ "hermes-estree": "0.32.0" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", diff --git a/package.json b/package.json index 659534b..cc77450 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^11.4.1", "@react-navigation/native": "^7.1.8", + "@sentry/react-native": "~7.2.0", "@walletconnect/sign-client": "^2.23.9", "@walletconnect/types": "^2.23.9", "axios": "^1.16.0", diff --git a/services/api.ts b/services/api.ts index 45b6075..d44ff3e 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig } from 'axios'; +import axios, { AxiosError, AxiosRequestConfig } from 'axios'; import { router } from 'expo-router'; import { config } from '../constants/config'; import { useAuthStore } from '../stores/auth.store'; @@ -6,6 +6,7 @@ import { getFromCache, setToCache } from '../src/offline/cache'; import { useConnectivityStore } from '../src/offline/connectivity.store'; import { enqueueAction } from '../src/offline/offline-queue'; import type { QueueAction, QueueActionType } from '../src/offline/offline-queue'; +import { addBreadcrumb, captureServiceError } from './sentry'; const api = axios.create({ baseURL: config.API_BASE_URL, @@ -19,6 +20,12 @@ api.interceptors.request.use(async (req) => { (req.headers as Record).Authorization = `Bearer ${accessToken}`; } + // Breadcrumb for every outgoing request (URL only, no auth headers) + addBreadcrumb('http.request', `${req.method?.toUpperCase()} ${req.url}`, { + baseURL: req.baseURL ?? '', + timeout: req.timeout ?? 0, + }); + const method = req.method?.toLowerCase(); if (!method || !['post', 'put', 'patch', 'delete'].includes(method)) { return req; @@ -27,6 +34,7 @@ api.interceptors.request.use(async (req) => { const { isConnected } = useConnectivityStore.getState(); if (isConnected) return req; + // Offline mitigation queue const action = await enqueueAction({ type: getActionType(req.url ?? '', req.method ?? 'POST'), endpoint: req.url ?? '', @@ -76,7 +84,8 @@ api.interceptors.response.use( } return res; }, - async (error) => { + async (error: any) => { + // Intercept offline-queued mock items immediately if (error?.__offline_queued) { return { data: { queued: true, actionId: error.__action.id, unsignedXdr: '' }, @@ -90,6 +99,16 @@ api.interceptors.response.use( const original = error.config as RetriableRequest | undefined; const status = error.response?.status; + // Capture non-401 production exceptions to Sentry + if (status !== 401) { + captureServiceError('api', 'response', error as AxiosError); + addBreadcrumb('http.error', `HTTP ${status ?? 'network'} error`, { + url: original?.url ?? 'unknown', + status: status ?? 0, + }, 'error'); + } + + // Handle Token Expiration Refresh Sequence if (status === 401 && original && !original._retry) { original._retry = true; @@ -111,6 +130,7 @@ api.interceptors.response.use( return api.request(original); } + // Offline read strategy fallback for standard broken GET failures if (original?.method?.toLowerCase() === 'get' && original?.url) { const cached = await getFromCache(`GET:${original.url}`); if (cached !== null) { @@ -131,4 +151,4 @@ function getActionType(url: string, method: string): QueueActionType { return 'SUBMIT_SIGNED_XDR'; } -export default api; +export default api; \ No newline at end of file diff --git a/services/auth.service.ts b/services/auth.service.ts index 317a6ac..c249e5b 100644 --- a/services/auth.service.ts +++ b/services/auth.service.ts @@ -1,4 +1,5 @@ import api from './api'; +import { addBreadcrumb, captureServiceError } from './sentry'; export interface NonceResponse { nonce: string; @@ -13,17 +14,38 @@ export interface AuthTokens { export const authService = { async getNonce(wallet: string): Promise { - const res = await api.post('/auth/nonce', { wallet }); - return res.data; + addBreadcrumb('auth.service', 'Requesting nonce'); + try { + const res = await api.post('/auth/nonce', { wallet }); + addBreadcrumb('auth.service', 'Nonce received'); + return res.data; + } catch (error) { + captureServiceError('auth', 'getNonce', error); + throw error; + } }, async verify(wallet: string, nonce: string, signature: string): Promise { - const res = await api.post('/auth/verify', { wallet, nonce, signature }); - return res.data; + addBreadcrumb('auth.service', 'Verifying wallet signature'); + try { + const res = await api.post('/auth/verify', { wallet, nonce, signature }); + addBreadcrumb('auth.service', 'Wallet verified successfully'); + return res.data; + } catch (error) { + captureServiceError('auth', 'verify', error); + throw error; + } }, async refresh(refreshToken: string): Promise { - const res = await api.post('/auth/refresh', { refreshToken }); - return res.data; + addBreadcrumb('auth.service', 'Refreshing auth tokens'); + try { + const res = await api.post('/auth/refresh', { refreshToken }); + addBreadcrumb('auth.service', 'Tokens refreshed'); + return res.data; + } catch (error) { + captureServiceError('auth', 'refresh', error); + throw error; + } }, }; diff --git a/services/loans.service.ts b/services/loans.service.ts index fe54229..ca73d3e 100644 --- a/services/loans.service.ts +++ b/services/loans.service.ts @@ -1,4 +1,5 @@ import api from './api'; +import { addBreadcrumb, captureServiceError } from './sentry'; import type { Installment, Loan } from '../types/loan.types'; export interface AvailableCredit { @@ -20,23 +21,52 @@ export interface UnsignedXdrResponse { export const loansService = { async getMyLoans(): Promise { - const res = await api.get<{ data: Loan[] }>('/loans/my-loans'); - return res.data.data; + addBreadcrumb('loans.service', 'Fetching user loans'); + try { + const res = await api.get<{ data: Loan[] }>('/loans/my-loans'); + addBreadcrumb('loans.service', 'Loans fetched', { count: res.data.data.length }); + return res.data.data; + } catch (error) { + captureServiceError('loans', 'getMyLoans', error); + throw error; + } }, async getLoanById(id: string): Promise { - const res = await api.get(`/loans/${id}`); - return res.data; + addBreadcrumb('loans.service', 'Fetching loan by ID', { loanId: id }); + try { + const res = await api.get(`/loans/${id}`); + return res.data; + } catch (error) { + captureServiceError('loans', 'getLoanById', error); + throw error; + } }, async getAvailableCredit(): Promise { - const res = await api.get('/loans/available-credit'); - return res.data; + addBreadcrumb('loans.service', 'Fetching available credit'); + try { + const res = await api.get('/loans/available-credit'); + return res.data; + } catch (error) { + captureServiceError('loans', 'getAvailableCredit', error); + throw error; + } }, async createLoan(dto: CreateLoanDto): Promise { - const res = await api.post('/loans/create', dto); - return res.data; + addBreadcrumb('loans.service', 'Creating loan', { + vendorId: dto.vendorId, + totalAmount: dto.totalAmount, + }); + try { + const res = await api.post('/loans/create', dto); + addBreadcrumb('loans.service', 'Loan created'); + return res.data; + } catch (error) { + captureServiceError('loans', 'createLoan', error); + throw error; + } }, async repayInstallment( @@ -44,10 +74,21 @@ export const loansService = { installmentIndex: number, amount: number, ): Promise { - const res = await api.post(`/loans/${loanId}/repay-installment`, { + addBreadcrumb('loans.service', 'Repaying installment', { + loanId, installmentIndex, amount, }); - return res.data; + try { + const res = await api.post(`/loans/${loanId}/repay-installment`, { + installmentIndex, + amount, + }); + addBreadcrumb('loans.service', 'Installment repaid'); + return res.data; + } catch (error) { + captureServiceError('loans', 'repayInstallment', error); + throw error; + } }, }; diff --git a/services/reputation.service.ts b/services/reputation.service.ts index c8b8f46..9bb8acf 100644 --- a/services/reputation.service.ts +++ b/services/reputation.service.ts @@ -1,4 +1,5 @@ import api from './api'; +import { addBreadcrumb, captureServiceError } from './sentry'; export interface ReputationScore { score: number; @@ -9,7 +10,16 @@ export interface ReputationScore { export const reputationService = { async getScore(wallet: string): Promise { - const res = await api.get(`/reputation/${wallet}`); - return res.data; + addBreadcrumb('reputation.service', 'Fetching reputation score'); + try { + const res = await api.get(`/reputation/${wallet}`); + addBreadcrumb('reputation.service', 'Reputation score received', { + tier: res.data.tier, + }); + return res.data; + } catch (error) { + captureServiceError('reputation', 'getScore', error); + throw error; + } }, }; diff --git a/services/sentry.ts b/services/sentry.ts new file mode 100644 index 0000000..a7a1907 --- /dev/null +++ b/services/sentry.ts @@ -0,0 +1,161 @@ +import * as Sentry from '@sentry/react-native'; +import Constants from 'expo-constants'; + +// Sensitive field names that must never be sent to Sentry +const SENSITIVE_KEYS = new Set([ + 'accessToken', + 'refreshToken', + 'signature', + 'nonce', + 'walletAddress', + 'publicKey', + 'authorization', + 'Authorization', +]); + +// Helpers + +/** + * Recursively strip sensitive keys from a plain object. + * Returns a shallow‑cloned object — the original is never mutated. + */ +function scrub(obj: T): T { + if (obj === null || obj === undefined || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(scrub) as unknown as T; + } + + const cleaned: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (SENSITIVE_KEYS.has(key)) { + cleaned[key] = '[REDACTED]'; + } else { + cleaned[key] = scrub(value); + } + } + return cleaned as T; +} + +// Initialisation + +let _initialised = false; + +export function initSentry(): void { + if (_initialised) return; + + const dsn = process.env.EXPO_PUBLIC_SENTRY_DSN; + if (!dsn) { + if (__DEV__) { + // eslint-disable-next-line no-console + console.warn('[Sentry] EXPO_PUBLIC_SENTRY_DSN is not set — skipping init'); + } + return; + } + + const release = Constants.expoConfig?.version + ? `${Constants.expoConfig.slug ?? 'stepfi-app'}@${Constants.expoConfig.version}` + : undefined; + + Sentry.init({ + dsn, + release, + environment: __DEV__ ? 'development' : 'production', + + // Sessions + enableAutoSessionTracking: true, + + // Performance + tracesSampleRate: __DEV__ ? 1.0 : 0.2, + + // Data scrubbing — strip tokens / secrets before they leave the device + beforeSend(event) { + // Scrub extra data + if (event.extra) { + event.extra = scrub(event.extra); + } + + // Scrub breadcrumb data + if (event.breadcrumbs) { + event.breadcrumbs = event.breadcrumbs.map((bc) => ({ + ...bc, + data: bc.data ? scrub(bc.data) : bc.data, + })); + } + + // Scrub context values + if (event.contexts) { + event.contexts = scrub(event.contexts); + } + + return event; + }, + + // Only send events in production unless DSN is explicitly provided in dev + enabled: !__DEV__ || Boolean(dsn), + }); + + _initialised = true; +} + +// User context + +/** + * Set the Sentry user context when a wallet connects. + */ +export function setSentryUser(walletAddress: string): void { + Sentry.setUser({ id: walletAddress }); +} + +/** Clear user context on logout / wallet disconnect. */ +export function clearSentryUser(): void { + Sentry.setUser(null); +} + +// Breadcrumbs + +export function addBreadcrumb( + category: string, + message: string, + data?: Record, + level: Sentry.SeverityLevel = 'info', +): void { + Sentry.addBreadcrumb({ + category, + message, + data: data ? scrub(data) : undefined, + level, + }); +} + +// Error capture + +/** + * Capture a service‑layer error with useful tags so errors can be filtered + * by service name and operation in the Sentry dashboard. + */ +export function captureServiceError( + service: string, + operation: string, + error: unknown, +): void { + Sentry.withScope((scope) => { + scope.setTag('service', service); + scope.setTag('operation', operation); + + if (error instanceof Error) { + scope.setExtra('errorMessage', error.message); + Sentry.captureException(error); + } else { + Sentry.captureMessage(`[${service}.${operation}] Non-Error thrown`, { + level: 'error', + extra: { rawError: scrub(error as Record) }, + }); + } + }); +} + +// Re-export Sentry's wrap helper for the root component +export { Sentry };