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.
+
{CRASH_TRIGGERS.map(({ kind, title, description }) => (