diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 67b6dfe..08c3ceb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,7 +29,7 @@ It assumes: - [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts - [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates - [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution -- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, and live backend polling plus websocket-backed updates +- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, and startup/error telemetry capture - [-] DRY cleanup completed for runtime/config/bootstrap concerns and shared Supabase bootstrap, but not yet for all auth/session internals - [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary @@ -306,7 +306,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell. - [x] Implement auth flow and session restore - [-] Define secure storage and session invalidation behavior - [x] Implement launch-time kill-switch and maintenance handling -- [ ] Add telemetry startup and error capture +- [x] Add telemetry startup and error capture - [x] Define initial mobile scope - [x] Connect to backend and websocket/status contracts - [ ] Add push-notification-ready architecture diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 4578262..e9bd2d5 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import { AppState } from 'react-native'; import { useFrameworkReady } from '@/hooks/useFrameworkReady'; import { useFonts } from 'expo-font'; import { @@ -19,7 +20,9 @@ import { } from '@expo-google-fonts/jetbrains-mono'; import * as SplashScreen from 'expo-splash-screen'; import { ProductAvailabilityGate } from '@/components/ProductAvailabilityGate'; +import { getGlobalErrorUtils } from '@/lib/error-utils'; import { createMobilePlatformSdk, mobileRuntime } from '@/lib/runtime'; +import { mobileTelemetry, trackMobileError } from '@/lib/telemetry'; import { AuthGate } from '@/components/auth/AuthGate'; import { MobileAuthProvider } from '@/providers/MobileAuthProvider'; import { TradingDataProvider } from '@/providers/TradingDataProvider'; @@ -56,6 +59,47 @@ export default function RootLayout() { } }, [fontsLoaded, fontError]); + useEffect(() => { + mobileTelemetry.init(); + mobileTelemetry.trackEvent('info', 'app_shell', 'trading_mobile_bootstrap', { + feature: 'bootstrap', + tags: { surface: 'mobile' }, + }); + + const appStateSubscription = AppState.addEventListener('change', (nextState) => { + mobileTelemetry.trackEvent('info', 'app_lifecycle', 'app_state_changed', { + message: nextState, + }); + + if (nextState !== 'active') { + mobileTelemetry.flush(); + } + }); + + const errorUtils = getGlobalErrorUtils(); + const previousGlobalHandler = errorUtils?.getGlobalHandler?.(); + + errorUtils?.setGlobalHandler?.((error, isFatal) => { + trackMobileError('app_shell', isFatal ? 'unhandled_fatal_error' : 'unhandled_error', error, { + fatal: String(Boolean(isFatal)), + }); + mobileTelemetry.flush(); + previousGlobalHandler?.(error, isFatal); + }); + + return () => { + appStateSubscription.remove(); + errorUtils?.setGlobalHandler?.(previousGlobalHandler ?? ((error) => console.error(error))); + mobileTelemetry.shutdown(); + }; + }, []); + + useEffect(() => { + if (fontError) { + trackMobileError('app_shell', 'font_load_failed', fontError); + } + }, [fontError]); + if (!fontsLoaded && !fontError) { return null; } diff --git a/mobile/lib/error-utils.ts b/mobile/lib/error-utils.ts new file mode 100644 index 0000000..e2baae2 --- /dev/null +++ b/mobile/lib/error-utils.ts @@ -0,0 +1,8 @@ +export interface GlobalErrorUtils { + getGlobalHandler?: () => (error: unknown, isFatal?: boolean) => void; + setGlobalHandler?: (handler: (error: unknown, isFatal?: boolean) => void) => void; +} + +export function getGlobalErrorUtils(): GlobalErrorUtils | undefined { + return (globalThis as typeof globalThis & { ErrorUtils?: GlobalErrorUtils }).ErrorUtils; +} diff --git a/mobile/lib/telemetry.ts b/mobile/lib/telemetry.ts new file mode 100644 index 0000000..78360e2 --- /dev/null +++ b/mobile/lib/telemetry.ts @@ -0,0 +1,20 @@ +import { createTelemetryClient } from '@bytelyst/telemetry-client'; +import { mobileRuntime } from '@/lib/runtime'; +import { productConfig } from '../../shared/product.js'; + +export const mobileTelemetry = createTelemetryClient({ + productId: mobileRuntime.productId, + baseUrl: mobileRuntime.platformApiUrl, + platform: 'mobile', + channel: 'invttrdg_mobile', + transport: 'fetch', + appVersion: productConfig.version, + releaseChannel: process.env.NODE_ENV === 'production' ? 'prod' : 'dev', +}); + +export function trackMobileError(module: string, eventName: string, error: unknown, tags?: Record) { + mobileTelemetry.trackEvent('error', module, eventName, { + message: error instanceof Error ? error.message : String(error), + tags, + }); +} diff --git a/mobile/package.json b/mobile/package.json index 46053d0..15aed1c 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,6 +12,7 @@ "dependencies": { "@bytelyst/kill-switch-client": "link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client", "@bytelyst/react-native-platform-sdk": "link:../../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk", + "@bytelyst/telemetry-client": "link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/vector-icons": "^15.0.2", diff --git a/mobile/providers/MobileAuthProvider.tsx b/mobile/providers/MobileAuthProvider.tsx index 35e0b12..c301a8d 100644 --- a/mobile/providers/MobileAuthProvider.tsx +++ b/mobile/providers/MobileAuthProvider.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import type { Session, User } from '@supabase/supabase-js'; import { mobileSupabase } from '@/lib/supabase'; import { tableNameUsers } from '@/lib/tables'; +import { mobileTelemetry, trackMobileError } from '@/lib/telemetry'; export interface MobileUserProfile { user_id: string; @@ -38,15 +39,24 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { let active = true; async function bootstrap() { - const { data } = await mobileSupabase.auth.getSession(); - if (!active) { - return; - } - setSession(data.session ?? null); - setUser(data.session?.user ?? null); - if (data.session?.user) { - await fetchProfile(data.session.user.id); - } else { + try { + const { data } = await mobileSupabase.auth.getSession(); + if (!active) { + return; + } + setSession(data.session ?? null); + setUser(data.session?.user ?? null); + if (data.session?.user) { + mobileTelemetry.trackEvent('info', 'auth', 'session_restored', { + userId: data.session.user.id, + }); + await fetchProfile(data.session.user.id); + } else { + setLoading(false); + } + } catch (bootstrapError) { + trackMobileError('auth', 'session_restore_failed', bootstrapError); + setError(bootstrapError instanceof Error ? bootstrapError.message : 'Failed to restore session'); setLoading(false); } } @@ -57,6 +67,9 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { setSession(nextSession); setUser(nextSession?.user ?? null); if (nextSession?.user) { + mobileTelemetry.trackEvent('info', 'auth', 'session_changed', { + userId: nextSession.user.id, + }); void fetchProfile(nextSession.user.id); } else { setProfile(null); @@ -80,12 +93,14 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { if (profileError) { setError(profileError.message); + trackMobileError('auth', 'profile_load_failed', profileError, { userId }); } else { setProfile((data || null) as MobileUserProfile | null); setError(null); } } catch (fetchError) { setError(fetchError instanceof Error ? fetchError.message : 'Failed to load profile'); + trackMobileError('auth', 'profile_load_failed', fetchError, { userId }); } finally { setLoading(false); } @@ -97,15 +112,22 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) { const { error: authError } = await mobileSupabase.auth.signInWithPassword({ email, password }); if (authError) { setError(authError.message); + trackMobileError('auth', 'sign_in_failed', authError, { email }); setLoading(false); return { error: authError.message }; } + mobileTelemetry.trackEvent('info', 'auth', 'sign_in_succeeded', { + message: email, + }); return {}; } async function signOut() { setLoading(true); await mobileSupabase.auth.signOut(); + mobileTelemetry.trackEvent('info', 'auth', 'sign_out_succeeded', { + userId: user?.id, + }); setProfile(null); setSession(null); setUser(null); diff --git a/mobile/providers/TradingDataProvider.tsx b/mobile/providers/TradingDataProvider.tsx index e2561fd..148217f 100644 --- a/mobile/providers/TradingDataProvider.tsx +++ b/mobile/providers/TradingDataProvider.tsx @@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS import type { ReactNode } from 'react'; import { io, type Socket } from 'socket.io-client'; import { mobileRuntime } from '@/lib/runtime'; +import { mobileTelemetry, trackMobileError } from '@/lib/telemetry'; import { useMobileAuth } from '@/providers/MobileAuthProvider'; type HealthSnapshot = { @@ -169,6 +170,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { } catch (fetchError) { setConnected(false); setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state'); + trackMobileError('trading_data', 'state_fetch_failed', fetchError); } finally { setLoading(false); } @@ -208,14 +210,17 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { socket.on('connect', () => { setConnected(true); setError(null); + mobileTelemetry.trackEvent('info', 'realtime', 'socket_connected'); }); socket.on('disconnect', () => { setConnected(false); + mobileTelemetry.trackEvent('warn', 'realtime', 'socket_disconnected'); }); socket.on('connect_error', (socketError) => { setError(socketError.message); + trackMobileError('realtime', 'socket_connect_failed', socketError); }); socket.on('state', (nextState: BotState) => { @@ -308,11 +313,19 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { const body = await response.json().catch(() => ({} as { error?: string })); if (!response.ok) { + mobileTelemetry.trackEvent('warn', 'controls', 'trading_action_failed', { + message: body.error || `Request failed (${response.status})`, + tags: { path }, + }); return { error: body.error || `Request failed (${response.status})` }; } await fetchState(); + mobileTelemetry.trackEvent('info', 'controls', 'trading_action_succeeded', { + tags: { path }, + }); return {}; } catch (actionError) { + trackMobileError('controls', 'trading_action_failed', actionError, { path }); return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' }; } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaf3079..a959adf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@bytelyst/react-native-platform-sdk': specifier: link:../../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk version: link:../../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk + '@bytelyst/telemetry-client': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client + version: link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client '@expo-google-fonts/inter': specifier: ^0.4.2 version: 0.4.2