diff --git a/apps/observe-tester/app.json b/apps/observe-tester/app.json index 7950813025d830..c6f28ef08ff988 100644 --- a/apps/observe-tester/app.json +++ b/apps/observe-tester/app.json @@ -45,6 +45,7 @@ } } ], + ["./plugins/withCrashOnLaunch", { "android": false, "ios": false }], [ "expo-build-properties", { diff --git a/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx b/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx index 57c1a4c4ab88b9..7ed4d3b2f082cd 100644 --- a/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx +++ b/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx @@ -14,6 +14,7 @@ export default function SessionsLayout() { + ); } diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx index 5abc481f785348..61a36d12a9d21e 100644 --- a/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx +++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx @@ -1,4 +1,9 @@ -import AppMetrics, { type DebugSession, type Session, type SessionType } from 'expo-app-metrics'; +import AppMetrics, { + type CrashReport, + type DebugSession, + type Session, + type SessionType, +} from 'expo-app-metrics'; import { useObserve } from 'expo-observe'; import { type Href, router, Stack, useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; @@ -18,6 +23,7 @@ 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 = { + kind: 'session'; id: string; type: SessionType; startDate: string; @@ -30,7 +36,20 @@ type SessionRowData = { href: Href; }; -type Section = { title: string; data: SessionRowData[] }; +// A startup crash not attributed to any session (from `getOrphanedCrashReports`). +// Keyed by position since crash reports have no id; the detail screen re-fetches +// the same ordered list and looks the report up by index. +type OrphanRowData = { + kind: 'orphan'; + index: number; + summary: string; + timestamp: string; + href: Href; +}; + +type RowData = SessionRowData | OrphanRowData; + +type Section = { title: string; data: RowData[] }; export default function SessionsList() { const theme = useTheme(); @@ -59,9 +78,14 @@ export default function SessionsList() { .map(inactiveSessionToRow) .sort((a, b) => (a.startDate < b.startDate ? 1 : -1)); + // Startup crashes that predate any session — Android only, hence the optional call. + const orphanReports = (await AppMetrics.getOrphanedCrashReports?.()) ?? []; + const orphans: OrphanRowData[] = orphanReports.map(orphanCrashToRow); + setSections([ ...(active.length ? [{ title: 'Active', data: active }] : []), ...(inactive.length ? [{ title: 'Inactive', data: inactive }] : []), + ...(orphans.length ? [{ title: 'Startup crashes', data: orphans }] : []), ]); setLoaded(true); }, []); @@ -101,7 +125,7 @@ export default function SessionsList() { style={[styles.container, { backgroundColor: theme.background.screen }]} contentContainerStyle={styles.contentContainer} sections={sections} - keyExtractor={(session) => session.id} + keyExtractor={(item) => (item.kind === 'orphan' ? `orphan-${item.index}` : item.id)} stickySectionHeadersEnabled={false} refreshControl={ Platform.OS === 'ios' ? ( @@ -137,7 +161,9 @@ export default function SessionsList() { {section.title} )} - renderItem={({ item }) => } + renderItem={({ item }) => + item.kind === 'orphan' ? : + } /> ); @@ -145,6 +171,7 @@ export default function SessionsList() { async function liveSessionToRow(session: Session): Promise { return { + kind: 'session', id: session.id, type: session.type, startDate: session.startDate, @@ -158,6 +185,7 @@ async function liveSessionToRow(session: Session): Promise { function inactiveSessionToRow(session: DebugSession): SessionRowData { return { + kind: 'session', id: session.id, type: session.type, startDate: session.startDate, @@ -169,6 +197,29 @@ function inactiveSessionToRow(session: DebugSession): SessionRowData { }; } +function orphanCrashToRow(report: CrashReport, index: number): OrphanRowData { + return { + kind: 'orphan', + index, + summary: crashSummary(report), + timestamp: report.timestampBegin, + href: `/sessions/orphaned/${index}`, + }; +} + +// A short one-line description of a crash. Android JVM crashes carry the throwable +// message as a plain `exceptionReason` string; native crashes fall back to the signal. +function crashSummary(report: CrashReport): string { + const reason = report.exceptionReason; + if (typeof reason === 'string' && reason.trim()) { + return reason.split('\n')[0]; + } + if (reason && typeof reason === 'object') { + return reason.composedMessage || reason.exceptionName; + } + return report.terminationReason ?? 'Native crash'; +} + function SessionRow({ session }: { session: SessionRowData }) { const theme = useTheme(); const { metricCount, isActive } = session; @@ -224,6 +275,44 @@ function SessionRow({ session }: { session: SessionRowData }) { ); } +function OrphanRow({ row }: { row: OrphanRowData }) { + const theme = useTheme(); + return ( + router.push(row.href)} + style={({ pressed }) => [ + styles.row, + { + backgroundColor: theme.background.element, + borderColor: theme.border.default, + borderLeftWidth: 3, + borderLeftColor: theme.icon.danger, + }, + pressed && styles.rowPressed, + ]}> + + + {formatDate(new Date(row.timestamp))} + + + + Crashed + + + + + {row.summary} + + + ); +} + const dateFormatter = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: '2-digit', diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/orphaned/[index].tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/orphaned/[index].tsx new file mode 100644 index 00000000000000..2bb920050dd5bb --- /dev/null +++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/orphaned/[index].tsx @@ -0,0 +1,88 @@ +import AppMetrics, { type CrashReport } from 'expo-app-metrics'; +import { useObserve } from 'expo-observe'; +import { Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useCallback, useEffect, useState } from 'react'; +import { Platform, ScrollView, StyleSheet, Text } from 'react-native'; + +import { CallStackTreeView } from '@/components/CallStackTreeView'; +import { CrashReportPanel } from '@/components/CrashReportPanel'; +import { Divider } from '@/components/Divider'; +import { useTheme } from '@/utils/theme'; + +// Detail for a startup crash that isn't attributed to any session. The list passes +// the report's position in `getOrphanedCrashReports`; we re-fetch that ordered list +// and look it up by index, since crash reports have no stable id. The index is only +// stable within a launch, but orphaned crashes are only produced at startup — never +// while the app is foregrounded — so the list can't shift under an open detail screen. +export default function OrphanedCrashScreen() { + const theme = useTheme(); + const { index } = useLocalSearchParams<{ index: string }>(); + const [report, setReport] = useState(null); + const [loaded, setLoaded] = useState(false); + + useFocusEffect( + useCallback(() => { + let cancelled = false; + AppMetrics.getOrphanedCrashReports?.().then((reports) => { + if (cancelled) return; + setReport(reports[Number(index)] ?? null); + setLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [index]) + ); + + const { markInteractive } = useObserve(); + useEffect(() => { + setTimeout(() => { + markInteractive(); + }, 100); + }, []); + + return ( + + + {!loaded ? null : report ? ( + <> + Crash report + + {report.callStackTree ? ( + <> + + Call stacks + + + ) : null} + + ) : ( + Crash report not found + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + contentContainer: { + paddingBottom: Platform.select({ ios: 30, android: 150 }), + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + marginBottom: 12, + }, + divider: { + marginVertical: 16, + }, + notFound: { + fontSize: 16, + fontWeight: 'bold', + textAlign: 'center', + }, +}); diff --git a/apps/observe-tester/components/CrashReportsSection.tsx b/apps/observe-tester/components/CrashReportsSection.tsx index 70f5dcdd7bfb15..4e51ace71f58d1 100644 --- a/apps/observe-tester/components/CrashReportsSection.tsx +++ b/apps/observe-tester/components/CrashReportsSection.tsx @@ -14,6 +14,15 @@ const CRASH_TRIGGERS: { kind: CrashKind; title: string; description: string }[] { kind: 'stackOverflow', title: 'Stack overflow', description: 'Unbounded recursion' }, ]; +// Throws from a timer callback so the error is genuinely uncaught: it unwinds to React Native's +// global error handler (where expo-app-metrics' handler is chained) instead of being swallowed by +// the press handler or a React error boundary. +function triggerUncaughtError() { + setTimeout(() => { + throw new Error('Intentional uncaught JS error from observe-tester'); + }, 0); +} + export function CrashReportsSection() { const theme = useTheme(); @@ -25,8 +34,15 @@ export function CrashReportsSection() { <> Crash reports - Trigger real crashes to produce crash diagnostics. + Trigger real crashes to produce crash diagnostics, or throw an uncaught JS error to exercise + the JavaScript error handler. +