diff --git a/apps/native-component-list/src/screens/AppMetricsScreen.tsx b/apps/native-component-list/src/screens/AppMetricsScreen.tsx index 7cd94905dc28d6..b39061ad171998 100644 --- a/apps/native-component-list/src/screens/AppMetricsScreen.tsx +++ b/apps/native-component-list/src/screens/AppMetricsScreen.tsx @@ -1,19 +1,19 @@ import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from 'ThemeProvider'; -import AppMetrics, { type MainSession, type Metric } from 'expo-app-metrics'; +import AppMetrics, { type Metric } from 'expo-app-metrics'; import * as React from 'react'; import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; export default function AppMetricsScreen() { const { theme } = useTheme(); - const [session, setSession] = React.useState(null); + const [metrics, setMetrics] = React.useState([]); const [refreshing, setRefreshing] = React.useState(false); useFocusEffect( React.useCallback(() => { let cancelled = false; AppMetrics.getMainSession().then((s) => { - if (!cancelled) setSession(s); + if (!cancelled) setMetrics(s?.metrics ?? []); }); return () => { cancelled = true; @@ -24,13 +24,13 @@ export default function AppMetricsScreen() { const onRefresh = React.useCallback(async () => { setRefreshing(true); try { - setSession(await AppMetrics.getMainSession()); + setMetrics((await AppMetrics.getMainSession())?.metrics ?? []); } finally { setRefreshing(false); } }, []); - const navMetrics: Metric[] = (session?.metrics ?? []).filter((m) => m.category === 'navigation'); + const navMetrics: Metric[] = metrics.filter((m) => m.category === 'navigation'); return ( + ); diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/[id].tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/[id].tsx index a9abe9a66097b3..1c2d658121b5e7 100644 --- a/apps/observe-tester/app/(tabs)/(sessions)/sessions/[id].tsx +++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/[id].tsx @@ -1,157 +1,27 @@ import AppMetrics, { type Session } from 'expo-app-metrics'; -import { useObserve } from 'expo-observe'; -import { Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; -import { useCallback, useEffect, useState } from 'react'; -import { Platform, Pressable, ScrollView, StyleSheet, Text } from 'react-native'; +import { useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useCallback, useState } from 'react'; -import { CallStackTreeView } from '@/components/CallStackTreeView'; -import { Chevron } from '@/components/Chevron'; -import { CrashReportPanel } from '@/components/CrashReportPanel'; -import { Divider } from '@/components/Divider'; -import { JSONView } from '@/components/JSONView'; -import { LogsPanel } from '@/components/LogsPanel'; -import { MetricsFilter } from '@/components/MetricsFilter'; -import { MetricsPanel } from '@/components/MetricsPanel'; -import { SessionHeader } from '@/components/SessionHeader'; -import { useTheme } from '@/utils/theme'; +import { SessionDetail } from '@/components/SessionDetail'; -export default function SessionDetail() { +export default function InactiveSessionScreen() { const { id } = useLocalSearchParams<{ id: string }>(); - const theme = useTheme(); const [session, setSession] = useState(null); const [loaded, setLoaded] = useState(false); - const [showRawJson, setShowRawJson] = useState(false); - const [selectedMetricNames, setSelectedMetricNames] = useState>(() => new Set()); - - // Seed the selection to every distinct metric name once per session id. Keyed on `session?.id` - // so refocusing the tab (which reloads sessions and creates a new metrics array reference) - // doesn't wipe the user's filter. - useEffect(() => { - if (!session) return; - const names = new Set(); - for (const metric of session.metrics) names.add(metric.name); - setSelectedMetricNames(names); - }, [session?.id]); - - const { markInteractive } = useObserve(); - useEffect(() => { - setTimeout(() => { - markInteractive(); - }, 100); - }, []); useFocusEffect( useCallback(() => { - AppMetrics.getAllSessions().then((sessions) => { + let cancelled = false; + AppMetrics.getInactiveSessions().then((sessions) => { + if (cancelled) return; setSession(sessions.find((s) => s.id === id) ?? null); setLoaded(true); }); + return () => { + cancelled = true; + }; }, [id]) ); - return ( - - - {!loaded ? null : session ? ( - <> - - - {session.type === 'main' && session.crashReport ? ( - <> - Crash report - - {session.crashReport.callStackTree ? ( - <> - - - Call stacks - - - - ) : null} - - - ) : null} - Metrics - - - - Log events - - - setShowRawJson((v) => !v)} - style={({ pressed }) => [ - styles.rawJsonHeader, - showRawJson && styles.rawJsonHeaderExpanded, - pressed && { opacity: 0.6 }, - ]}> - - Raw JSON - - - - {showRawJson ? : null} - - ) : ( - Session not found - )} - - ); -} - -// The call stack tree balloons the raw JSON to the point where it can fail to render. It's -// already shown visually in the "Call stacks" section above, so we omit it from the raw JSON -// and replace it with a marker noting that. -function stripCallStackTree(session: Session): Session { - if (session.type !== 'main' || !session.crashReport?.callStackTree) { - return session; - } - return { - ...session, - crashReport: { - ...session.crashReport, - callStackTree: '' as never, - }, - }; + return ; } - -const styles = StyleSheet.create({ - container: { - padding: 20, - }, - contentContainer: { - paddingBottom: Platform.select({ ios: 30, android: 150 }), - }, - notFound: { - fontSize: 16, - fontWeight: 'bold', - textAlign: 'center', - }, - sectionTitle: { - fontSize: 18, - fontWeight: '700', - marginBottom: 12, - }, - divider: { - marginVertical: 16, - }, - rawJsonHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 12, - }, - rawJsonTitle: { - marginBottom: 0, - }, - rawJsonHeaderExpanded: { - marginBottom: 12, - }, -}); diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx index 9465c70e224d33..39578cbdfe4e24 100644 --- a/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx +++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx @@ -1,6 +1,6 @@ -import AppMetrics, { type Session } from 'expo-app-metrics'; +import AppMetrics, { type Session, type SessionType } from 'expo-app-metrics'; import { useObserve } from 'expo-observe'; -import { router, Stack, useFocusEffect } from 'expo-router'; +import { type Href, router, Stack, useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, @@ -15,14 +15,28 @@ import { import { useTheme } from '@/utils/theme'; +// A row's worth of session data, normalized from a `Session` record — the live +// main session or an inactive one. +type SessionRowData = { + id: string; + type: SessionType; + startDate: string; + endDate: string | null; + isActive: boolean; + metricCount: number; + crashed: boolean; + // Detail route: the live sessions have dedicated `main` screens; + // inactive ones are looked up by id via the `[id]` screen. + href: Href; +}; + +type Section = { title: string; data: SessionRowData[] }; + export default function SessionsList() { const theme = useTheme(); - const [sessions, setSessions] = useState([]); + const [sections, setSections] = useState([]); const [loaded, setLoaded] = useState(false); const [refreshing, setRefreshing] = useState(false); - const currentMainStart = sessions.find((s) => s.type === 'main')?.startDate; - const isActive = (s: Session) => - !s.endDate && currentMainStart != null && s.startDate >= currentMainStart; const { markInteractive } = useObserve(); useEffect(() => { @@ -32,9 +46,34 @@ export default function SessionsList() { }, []); const refresh = useCallback(async () => { - const result = await AppMetrics.getAllSessions(); - const sorted = [...result].sort((a, b) => (a.startDate < b.startDate ? 1 : -1)); - setSessions(sorted); + const mainSession = await AppMetrics.getMainSession(); + const active: SessionRowData[] = mainSession + ? [ + { + id: mainSession.id, + type: mainSession.type, + startDate: mainSession.startDate, + endDate: null, + isActive: true, + metricCount: mainSession?.metrics?.length ?? 0, + // A live session is always active, so it never has a crash report. + crashed: false, + // `main` is the live sessions' dedicated detail routes. + href: `/sessions/${mainSession.type}`, + }, + ] + : []; + + // Inactive sessions come back as plain eager records. + const records = await AppMetrics.getInactiveSessions(); + const inactive: SessionRowData[] = records + .map(inactiveSessionToRow) + .sort((a, b) => (a.startDate < b.startDate ? 1 : -1)); + + setSections([ + ...(active.length ? [{ title: 'Active', data: active }] : []), + ...(inactive.length ? [{ title: 'Inactive', data: inactive }] : []), + ]); setLoaded(true); }, []); @@ -53,7 +92,7 @@ export default function SessionsList() { }, [refresh]) ); - if (typeof AppMetrics.getAllSessions !== 'function') { + if (typeof AppMetrics.getInactiveSessions !== 'function') { return ( @@ -63,9 +102,8 @@ export default function SessionsList() { ); } - const sections = groupByDay(sessions); - - const title = sessions.length > 0 ? `Sessions (${sessions.length})` : 'Sessions'; + const total = sections.reduce((sum, section) => sum + section.data.length, 0); + const title = total > 0 ? `Sessions (${total})` : 'Sessions'; return ( <> @@ -110,46 +148,28 @@ export default function SessionsList() { {section.title} )} - renderItem={({ item }) => } + renderItem={({ item }) => } /> ); } -function groupByDay(sessions: Session[]): { title: string; data: Session[] }[] { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const yesterday = new Date(today); - yesterday.setDate(today.getDate() - 1); - - const sectionsByKey = new Map(); - for (const session of sessions) { - const date = new Date(session.startDate); - const day = new Date(date); - day.setHours(0, 0, 0, 0); - - let title: string; - if (day.getTime() === today.getTime()) { - title = 'Today'; - } else if (day.getTime() === yesterday.getTime()) { - title = 'Yesterday'; - } else { - title = sectionDateFormatter.format(date); - } - - const key = day.toISOString(); - const existing = sectionsByKey.get(key); - if (existing) { - existing.data.push(session); - } else { - sectionsByKey.set(key, { title, data: [session] }); - } - } - return Array.from(sectionsByKey.values()); +function inactiveSessionToRow(session: Session): SessionRowData { + return { + id: session.id, + type: session.type, + startDate: session.startDate, + endDate: session.endDate ?? null, + isActive: false, + metricCount: session.metrics.length, + crashed: 'crashReport' in session ? !!session.crashReport : false, + href: `/sessions/${session.id}`, + }; } -function SessionRow({ session, isActive }: { session: Session; isActive: boolean }) { +function SessionRow({ session }: { session: SessionRowData }) { const theme = useTheme(); + const { metricCount, isActive } = session; const startDate = new Date(session.startDate); const endDate = session.endDate ? new Date(session.endDate) : null; const duration = endDate ? formatDuration(endDate.getTime() - startDate.getTime()) : null; @@ -157,7 +177,7 @@ function SessionRow({ session, isActive }: { session: Session; isActive: boolean return ( router.push(`/sessions/${session.id}`)} + onPress={() => router.push(session.href)} style={({ pressed }) => [ styles.row, { @@ -178,7 +198,7 @@ function SessionRow({ session, isActive }: { session: Session; isActive: boolean {formatDate(startDate)} - {session.type === 'main' && session.crashReport ? ( + {session.crashed ? ( Crashed @@ -195,8 +215,8 @@ function SessionRow({ session, isActive }: { session: Session; isActive: boolean numberOfLines={1} ellipsizeMode="tail"> {shortId} - {duration ? ` · ${duration}` : isActive ? ' · active' : ''} · {session.metrics.length}{' '} - metric{session.metrics.length === 1 ? '' : 's'} + {duration ? ` · ${duration}` : isActive ? ' · active' : ''} · {metricCount} metric + {metricCount === 1 ? '' : 's'} ); @@ -211,13 +231,6 @@ const dateFormatter = new Intl.DateTimeFormat('en-GB', { second: '2-digit', }); -const sectionDateFormatter = new Intl.DateTimeFormat('en-GB', { - weekday: 'short', - day: '2-digit', - month: 'short', - year: 'numeric', -}); - function formatDate(date: Date) { return dateFormatter.format(date); } diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/main.tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/main.tsx new file mode 100644 index 00000000000000..7b05be5c63c696 --- /dev/null +++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/main.tsx @@ -0,0 +1,26 @@ +import AppMetrics, { type Session } from 'expo-app-metrics'; +import { useFocusEffect } from 'expo-router'; +import { useCallback, useState } from 'react'; + +import { SessionDetail, liveSessionToRecord } from '@/components/SessionDetail'; + +export default function MainSessionScreen() { + const [session, setSession] = useState(null); + const [loaded, setLoaded] = useState(false); + + useFocusEffect( + useCallback(() => { + let cancelled = false; + AppMetrics.getMainSession().then((mainSession) => { + if (cancelled) return; + setSession(mainSession ? liveSessionToRecord(mainSession) : null); + setLoaded(true); + }); + return () => { + cancelled = true; + }; + }, []) + ); + + return ; +} diff --git a/apps/observe-tester/app/(tabs)/debug/index.tsx b/apps/observe-tester/app/(tabs)/debug/index.tsx index bfa4946b55de1e..7225821f7fd1da 100644 --- a/apps/observe-tester/app/(tabs)/debug/index.tsx +++ b/apps/observe-tester/app/(tabs)/debug/index.tsx @@ -9,6 +9,7 @@ import { Divider } from '@/components/Divider'; import { GlobalAttributesSection } from '@/components/GlobalAttributesSection'; import { JSAnimation } from '@/components/JSAnimation'; import { LogEventsSection } from '@/components/LogEventsSection'; +import { NetworkRequestObserverSection } from '@/components/NetworkRequestObserverSection'; import { useTheme } from '@/utils/theme'; export default function Debug() { @@ -29,6 +30,8 @@ export default function Debug() { contentContainerStyle={styles.container}> + + {typeof AppMetrics.triggerCrash === 'function' ? : null} @@ -41,7 +44,15 @@ export default function Debug() { {showAnimation && }