feat: add mobile telemetry capture
This commit is contained in:
parent
c9aadfae8e
commit
856a683f18
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
8
mobile/lib/error-utils.ts
Normal file
8
mobile/lib/error-utils.ts
Normal file
@ -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;
|
||||
}
|
||||
20
mobile/lib/telemetry.ts
Normal file
20
mobile/lib/telemetry.ts
Normal file
@ -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<string, string>) {
|
||||
mobileTelemetry.trackEvent('error', module, eventName, {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
tags,
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
},
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user