diff --git a/app.config.js b/app.config.js index 941f2e7..18beb0b 100644 --- a/app.config.js +++ b/app.config.js @@ -85,6 +85,8 @@ export default { FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET, FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID, FIREBASE_APP_ID: process.env.FIREBASE_APP_ID, + URL_MONITORING: process.env.URL_MONITORING, + KEY_API_MONITORING: process.env.KEY_API_MONITORING, } } }; diff --git a/app/_layout.tsx b/app/_layout.tsx index 4960e59..bd5de89 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,11 +2,13 @@ import AuthProvider from '@/providers/AuthProvider'; import ThemeProvider, { useTheme } from '@/providers/ThemeProvider'; import QueryProvider from '@/providers/QueryProvider'; import ErrorBoundary from '@/components/ErrorBoundary'; +import { flushErrorQueue } from '@/lib/errorLogger'; import { useFonts } from 'expo-font'; import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; +import { AppState } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { NotifierWrapper } from 'react-native-notifier'; import 'react-native-reanimated'; @@ -38,9 +40,17 @@ export default function RootLayout() { useEffect(() => { if (loaded) { SplashScreen.hideAsync(); + flushErrorQueue(); } }, [loaded]); + useEffect(() => { + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active') flushErrorQueue(); + }); + return () => sub.remove(); + }, []); + if (!loaded) { return null; } diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx index 841a956..1b4c29f 100644 --- a/components/ErrorBoundary.tsx +++ b/components/ErrorBoundary.tsx @@ -18,6 +18,11 @@ export default class ErrorBoundary extends Component { return { hasError: true, error }; } + componentDidCatch(error: Error): void { + const { logError } = require('@/lib/errorLogger'); + logError(error.message, error); + } + handleReset = () => { this.setState({ hasError: false, error: null }); }; diff --git a/constants/ConstEnv.ts b/constants/ConstEnv.ts index dc45423..1714cf3 100644 --- a/constants/ConstEnv.ts +++ b/constants/ConstEnv.ts @@ -3,6 +3,8 @@ import Constants from 'expo-constants'; export const ConstEnv = { url_storage: Constants?.expoConfig?.extra?.URL_STORAGE, pass_encrypt: Constants?.expoConfig?.extra?.PASS_ENC, + url_monitoring: Constants?.expoConfig?.extra?.URL_MONITORING, + key_api_monitoring: Constants?.expoConfig?.extra?.KEY_API_MONITORING, firebase: { apiKey: Constants?.expoConfig?.extra?.FIREBASE_API_KEY, authDomain: Constants?.expoConfig?.extra?.FIREBASE_AUTH_DOMAIN, diff --git a/lib/api.ts b/lib/api.ts index 5d7f772..ffb7da1 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,10 +1,22 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import Constants from 'expo-constants'; +import { logError } from '@/lib/errorLogger'; const api = axios.create({ baseURL: Constants?.expoConfig?.extra?.URL_API }); +api.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + const status = error.response?.status; + const url = error.config?.url ?? 'unknown endpoint'; + const description = `API error ${status ?? 'network'} on ${url}`; + logError(description, error); + return Promise.reject(error); + } +); + export const apiCheckPhoneLogin = async (body: { phone: string }) => { const response = await api.post('/auth/login', body) return response.data; diff --git a/lib/errorLogger.ts b/lib/errorLogger.ts new file mode 100644 index 0000000..bc2046b --- /dev/null +++ b/lib/errorLogger.ts @@ -0,0 +1,100 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import NetInfo from '@react-native-community/netinfo'; +import Constants from 'expo-constants'; +import * as Device from 'expo-device'; +import { Platform } from 'react-native'; +import { ConstEnv } from '@/constants/ConstEnv'; + +const QUEUE_KEY = 'error_log_queue'; +const APP_NAME = 'desa-plus'; + +type ErrorPayload = { + affectedVersion: string; + device: string; + os: string; + description: string; + app: string; + source: 'SYSTEM'; + stackTrace: string; +}; + +function buildPayload(description: string, stackTrace?: string): ErrorPayload { + const platformName = Platform.OS === 'ios' ? 'iOS' : 'Android'; + return { + affectedVersion: Constants.expoConfig?.version ?? 'unknown', + device: Device.modelName ?? 'unknown', + os: `${platformName} ${Device.osVersion ?? ''}`.trim(), + description, + app: APP_NAME, + source: 'SYSTEM', + stackTrace: stackTrace ?? '', + }; +} + +async function sendToMonitoring(payload: ErrorPayload): Promise { + try { + const res = await fetch(`${ConstEnv.url_monitoring}/api/bugs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': ConstEnv.key_api_monitoring ?? '', + }, + body: JSON.stringify(payload), + }); + return res.ok; + } catch (error) { + console.log(error); + return false; + } +} + +async function enqueue(payload: ErrorPayload): Promise { + try { + const raw = await AsyncStorage.getItem(QUEUE_KEY); + const queue: ErrorPayload[] = raw ? JSON.parse(raw) : []; + queue.push(payload); + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue)); + } catch {} +} + +export async function flushErrorQueue(): Promise { + try { + const state = await NetInfo.fetch(); + if (!state.isConnected) return; + + const raw = await AsyncStorage.getItem(QUEUE_KEY); + if (!raw) return; + + const queue: ErrorPayload[] = JSON.parse(raw); + if (queue.length === 0) return; + + const failed: ErrorPayload[] = []; + for (const payload of queue) { + const ok = await sendToMonitoring(payload); + if (!ok) failed.push(payload); + } + + if (failed.length > 0) { + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(failed)); + } else { + await AsyncStorage.removeItem(QUEUE_KEY); + } + } catch {} +} + +export async function logError(description: string, error?: Error | unknown): Promise { + const stackTrace = error instanceof Error ? (error.stack ?? error.message) : String(error ?? ''); + const payload = buildPayload(description, stackTrace); + + try { + const state = await NetInfo.fetch(); + if (state.isConnected) { + const ok = await sendToMonitoring(payload); + if (!ok) await enqueue(payload); + } else { + await enqueue(payload); + } + } catch { + await enqueue(payload); + } +}