Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/observe-tester/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
}
}
],
["./plugins/withCrashOnLaunch", { "android": false, "ios": false }],
[
"expo-build-properties",
{
Expand Down
1 change: 1 addition & 0 deletions apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function SessionsLayout() {
<Stack.Screen name="sessions/main" options={{ title: 'Main session' }} />
<Stack.Screen name="sessions/foreground" options={{ title: 'Foreground session' }} />
<Stack.Screen name="sessions/[id]" options={{ title: 'Session' }} />
<Stack.Screen name="sessions/orphaned/[index]" options={{ title: 'Startup crash' }} />
</Stack>
);
}
97 changes: 93 additions & 4 deletions apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}, []);
Expand Down Expand Up @@ -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' ? (
Expand Down Expand Up @@ -137,14 +161,17 @@ export default function SessionsList() {
{section.title}
</Text>
)}
renderItem={({ item }) => <SessionRow session={item} />}
renderItem={({ item }) =>
item.kind === 'orphan' ? <OrphanRow row={item} /> : <SessionRow session={item} />
}
/>
</>
);
}

async function liveSessionToRow(session: Session): Promise<SessionRowData> {
return {
kind: 'session',
id: session.id,
type: session.type,
startDate: session.startDate,
Expand All @@ -158,6 +185,7 @@ async function liveSessionToRow(session: Session): Promise<SessionRowData> {

function inactiveSessionToRow(session: DebugSession): SessionRowData {
return {
kind: 'session',
id: session.id,
type: session.type,
startDate: session.startDate,
Expand All @@ -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;
Expand Down Expand Up @@ -224,6 +275,44 @@ function SessionRow({ session }: { session: SessionRowData }) {
);
}

function OrphanRow({ row }: { row: OrphanRowData }) {
const theme = useTheme();
return (
<Pressable
onPress={() => 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,
]}>
<View style={styles.rowHeader}>
<Text
style={[styles.rowTitle, { color: theme.text.default }]}
numberOfLines={1}
ellipsizeMode="tail">
{formatDate(new Date(row.timestamp))}
</Text>
<View style={styles.badges}>
<View style={[styles.badge, { backgroundColor: theme.background.danger }]}>
<Text style={[styles.badgeText, { color: theme.text.danger }]}>Crashed</Text>
</View>
</View>
</View>
<Text
style={[styles.rowMeta, { color: theme.text.secondary }]}
numberOfLines={2}
ellipsizeMode="tail">
{row.summary}
</Text>
</Pressable>
);
}

const dateFormatter = new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: '2-digit',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CrashReport | null>(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 (
<ScrollView
style={[styles.container, { backgroundColor: theme.background.screen }]}
contentContainerStyle={styles.contentContainer}>
<Stack.Screen options={{ title: 'Startup crash' }} />
{!loaded ? null : report ? (
<>
<Text style={[styles.sectionTitle, { color: theme.text.default }]}>Crash report</Text>
<CrashReportPanel report={report} />
{report.callStackTree ? (
<>
<Divider style={styles.divider} />
<Text style={[styles.sectionTitle, { color: theme.text.default }]}>Call stacks</Text>
<CallStackTreeView tree={report.callStackTree} />
</>
) : null}
</>
) : (
<Text style={[styles.notFound, { color: theme.text.default }]}>Crash report not found</Text>
)}
</ScrollView>
);
}

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',
},
});
18 changes: 17 additions & 1 deletion apps/observe-tester/components/CrashReportsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -25,8 +34,15 @@ export function CrashReportsSection() {
<>
<Text style={[styles.sectionTitle, { color: theme.text.default }]}>Crash reports</Text>
<Text style={[styles.sectionHint, { color: theme.text.secondary }]}>
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.
</Text>
<Button
title="Throw JS error"
description="Uncaught JS error captured by the global handler (RedBox in dev)"
onPress={triggerUncaughtError}
theme="secondary"
/>
{CRASH_TRIGGERS.map(({ kind, title, description }) => (
<Button
key={kind}
Expand Down
58 changes: 58 additions & 0 deletions apps/observe-tester/plugins/withCrashOnLaunch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Test-only: randomly crashes (native / runtime / not at all) at app startup to
// exercise crash reporting. Off by default — enable per platform via app.json props:
// ["./plugins/withCrashOnLaunch", { "android": true, "ios": true }]
const { withMainApplication, withAppDelegate, CodeGenerator } = require('expo/config-plugins');

// SIGSEGV isn't stored as a report; the JVM throw is. Throwing here is safe — onCreate
// isn't wrapped by expo-modules-core's exception decorator.
const ANDROID_CRASH_BLOCK = ` when ((0..5).random()) {
0 -> android.system.Os.kill(android.os.Process.myPid(), android.system.OsConstants.SIGSEGV)
1 -> throw RuntimeException("Intentional crash-on-launch test (JVM)")
else -> { /* no crash */ }
}`;

// MetricKit captures both outcomes and delivers them on the next launch.
const IOS_CRASH_BLOCK = ` switch Int.random(in: 0...5) {
case 0:
// EXC_BAD_ACCESS — bogus pointer deref.
_ = UnsafePointer<Int>(bitPattern: 0x1)!.pointee
case 1:
// Uncaught NSException — sets exceptionReason.
NSException(name: .genericException, reason: "Intentional crash-on-launch test (iOS)", userInfo: nil).raise()
default:
break // no crash
}`;

const withCrashOnLaunch = (config, { android = false, ios = false } = {}) => {
if (android) {
config = withMainApplication(config, (config) => {
config.modResults.contents = CodeGenerator.mergeContents({
src: config.modResults.contents,
newSrc: ANDROID_CRASH_BLOCK,
tag: 'crash-on-launch',
anchor: /ApplicationLifecycleDispatcher\.onApplicationCreate\(this\)/,
offset: 1,
comment: ' //',
}).contents;
return config;
});
}

if (ios) {
config = withAppDelegate(config, (config) => {
config.modResults.contents = CodeGenerator.mergeContents({
src: config.modResults.contents,
newSrc: IOS_CRASH_BLOCK,
tag: 'crash-on-launch',
anchor: /\)\s*->\s*Bool\s*\{/,
offset: 1,
comment: ' //',
}).contents;
return config;
});
}

return config;
};

module.exports = withCrashOnLaunch;
6 changes: 6 additions & 0 deletions apps/test-suite/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@ module.exports = {
extends: ['universe/native', 'universe/web'],
rules: {
'no-useless-escape': 0,
// TODO(@kitten): Disable in universe in general; redundant with TypeScript
'import/default': 'off',
'import/export': 'off',
'import/named': 'off',
'import/namespace': 'off',
'import/no-duplicates': 'off',
},
};
Loading
Loading