diff --git a/apps/observability-tester/app/(tabs)/(metrics)/_layout.tsx b/apps/observability-tester/app/(tabs)/(metrics)/_layout.tsx
deleted file mode 100644
index dd85d6fdc11773..00000000000000
--- a/apps/observability-tester/app/(tabs)/(metrics)/_layout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Stack } from 'expo-router';
-
-export default function MetricsLayout() {
- return (
-
-
-
- );
-}
diff --git a/apps/observability-tester/app/(tabs)/debug/_layout.tsx b/apps/observability-tester/app/(tabs)/debug/_layout.tsx
deleted file mode 100644
index 328223422f0709..00000000000000
--- a/apps/observability-tester/app/(tabs)/debug/_layout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Stack } from 'expo-router';
-
-export default function MetricsLayout() {
- return (
-
-
-
- );
-}
diff --git a/apps/observability-tester/components/Button.tsx b/apps/observability-tester/components/Button.tsx
deleted file mode 100644
index 9173de78b1bd7f..00000000000000
--- a/apps/observability-tester/components/Button.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import React from 'react';
-import { Pressable, PressableProps, StyleSheet, Text, useColorScheme } from 'react-native';
-
-type ButtonProps = {
- title: string;
- onPress?: () => void;
- theme?: 'primary' | 'secondary' | 'tertiary';
- disabled?: boolean;
-} & PressableProps;
-
-export function Button({ title, onPress, theme = 'primary', disabled, ...rest }: ButtonProps) {
- const colorScheme = useColorScheme();
- const isDark = colorScheme === 'dark';
-
- const buttonStyle = [
- styles.button,
- theme === 'primary' && styles.buttonPrimary,
- theme === 'secondary' &&
- (isDark ? { backgroundColor: '#1F1F1F', borderColor: '#404040' } : styles.buttonSecondary),
- theme === 'tertiary' && styles.buttonTertiary,
- disabled && styles.buttonDisabled,
- ];
-
- const textStyle = [
- styles.text,
- theme === 'primary' && styles.textPrimary,
- theme === 'secondary' && (isDark ? { color: '#FFFFFF' } : styles.textSecondary),
- theme === 'tertiary' && (isDark ? { color: '#E5E5E5' } : styles.textTertiary),
- ];
-
- return (
- [buttonStyle, pressed ? styles.buttonFocused : null]}
- disabled={disabled}
- {...rest}>
- {title}
-
- );
-}
-
-const styles = StyleSheet.create({
- button: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 6,
- paddingHorizontal: 20,
- paddingVertical: 12,
- marginBottom: 16,
- borderWidth: 1,
- },
- buttonFocused: {
- opacity: 0.5,
- },
- buttonPrimary: {
- backgroundColor: '#007AFF',
- borderColor: '#007AFF',
- },
- buttonSecondary: {
- backgroundColor: '#FFFFFF',
- borderColor: '#D1D5DB',
- },
- buttonTertiary: {
- backgroundColor: 'transparent',
- borderColor: 'transparent',
- },
- buttonDisabled: {
- opacity: 0.5,
- },
- text: {
- fontWeight: '600',
- fontSize: 18,
- letterSpacing: 0.5,
- },
- textPrimary: {
- color: '#FFFFFF',
- },
- textSecondary: {
- color: '#000000',
- },
- textTertiary: {
- color: '#1F2937',
- },
-});
diff --git a/apps/observability-tester/scripts/test-ios.sh b/apps/observability-tester/scripts/test-ios.sh
deleted file mode 100755
index 793c71decd79f9..00000000000000
--- a/apps/observability-tester/scripts/test-ios.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env bash
-# Run the iOS unit-test schemes for ExpoAppMetrics and ExpoObserve against
-# whichever iOS simulator is currently booted. Boot a simulator first
-# (e.g. via `xcrun simctl boot ` or by opening Simulator.app) and
-# then run `pnpm test:ios` from this app's directory.
-
-set -euo pipefail
-
-cd "$(dirname "$0")/.."
-
-UDID=$(xcrun simctl list devices available -j | \
- jq -r '.devices | to_entries | map(select(.key|test("iOS"))) | .[-1].value | map(select(.isAvailable)) | .[0].udid')
-
-echo "Running iOS unit tests against simulator: $UDID"
-
-xcodebuild test \
- -workspace ios/Observe.xcworkspace \
- -scheme ExpoAppMetrics-Unit-Tests \
- -destination "id=$UDID"
-
-xcodebuild test \
- -workspace ios/Observe.xcworkspace \
- -scheme ExpoObserve-Unit-Tests \
- -destination "id=$UDID"
diff --git a/apps/observe-tester/.gitignore b/apps/observe-tester/.gitignore
new file mode 100644
index 00000000000000..f25965cd078aa5
--- /dev/null
+++ b/apps/observe-tester/.gitignore
@@ -0,0 +1,42 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+#prebuild
+android/
+ios/
+
+# Native
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+app-example
diff --git a/apps/observability-tester/app.config.ts b/apps/observe-tester/app.config.ts
similarity index 90%
rename from apps/observability-tester/app.config.ts
rename to apps/observe-tester/app.config.ts
index a7312b34f82c6a..44ff6222c94b72 100644
--- a/apps/observability-tester/app.config.ts
+++ b/apps/observe-tester/app.config.ts
@@ -3,8 +3,6 @@ import 'tsx/cjs';
const config = ({ config }: ConfigContext): ExpoConfig => ({
...config,
- name: 'Observe',
- slug: 'observability',
extra: {
...config.extra,
eas: {
diff --git a/apps/observability-tester/app.json b/apps/observe-tester/app.json
similarity index 97%
rename from apps/observability-tester/app.json
rename to apps/observe-tester/app.json
index 3c1600af836677..e1b5a7c77e13e9 100644
--- a/apps/observability-tester/app.json
+++ b/apps/observe-tester/app.json
@@ -1,11 +1,11 @@
{
"expo": {
- "name": "observability",
+ "name": "Observe",
"slug": "observability",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
- "scheme": "observability",
+ "scheme": "observe",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
diff --git a/apps/observe-tester/app/(tabs)/(metrics)/_layout.tsx b/apps/observe-tester/app/(tabs)/(metrics)/_layout.tsx
new file mode 100644
index 00000000000000..e1fa42eec19b3d
--- /dev/null
+++ b/apps/observe-tester/app/(tabs)/(metrics)/_layout.tsx
@@ -0,0 +1,16 @@
+import { Stack } from 'expo-router';
+
+import { useTheme } from '@/utils/theme';
+
+export default function MetricsLayout() {
+ const theme = useTheme();
+ return (
+
+
+
+ );
+}
diff --git a/apps/observability-tester/app/(tabs)/(metrics)/index.tsx b/apps/observe-tester/app/(tabs)/(metrics)/index.tsx
similarity index 69%
rename from apps/observability-tester/app/(tabs)/(metrics)/index.tsx
rename to apps/observe-tester/app/(tabs)/(metrics)/index.tsx
index 50046da1cfcb6b..4c50d8f4cf9b91 100644
--- a/apps/observability-tester/app/(tabs)/(metrics)/index.tsx
+++ b/apps/observe-tester/app/(tabs)/(metrics)/index.tsx
@@ -1,17 +1,17 @@
-import { Code } from '@expo/html-elements';
-import { useCallback, useEffect, useState } from 'react';
-import { Platform, ScrollView, StyleSheet, Text, View, useColorScheme } from 'react-native';
-
-import { useRouterMetricsHelpers } from '@/router-metrics-integration';
import AppMetrics, { type Metric } from 'expo-app-metrics';
import ExpoObserve from 'expo-observe';
-import { Button } from '../../../components/Button';
-
import { checkForUpdateAsync, fetchUpdateAsync, reloadAsync, useUpdates } from 'expo-updates';
+import { useCallback, useEffect, useState } from 'react';
+import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
+
+import { Button } from '@/components/Button';
+import { Divider } from '@/components/Divider';
+import { JSONView } from '@/components/JSONView';
+import { useRouterMetricsHelpers } from '@/router-metrics-integration';
+import { useTheme } from '@/utils/theme';
export default function Index() {
- const colorScheme = useColorScheme();
- const isDark = colorScheme === 'dark';
+ const theme = useTheme();
const [metrics, setMetrics] = useState([]);
const [showEntries, setShowEntries] = useState(false);
const { isUpdateAvailable, isUpdatePending, availableUpdate, currentlyRunning } = useUpdates();
@@ -61,7 +61,7 @@ export default function Index() {
return (
@@ -82,15 +82,18 @@ export default function Index() {
theme="secondary"
/>
) : null}
- {`Currently running ${currentlyRunning.updateId}`}
- {`${currentlyRunning.isEmbeddedLaunch ? 'Embedded bundle' : 'OTA bundle'}`}
-
+
+ {`Currently running ${currentlyRunning.updateId}`}
+
+
+ {`${currentlyRunning.isEmbeddedLaunch ? 'Embedded bundle' : 'OTA bundle'}`}
+
+
+
{metrics.length === 0 ? (
-
- No stored entries
-
+ No stored entries
) : (
);
}
-function JSONView({ value, isDark }: { value: any; isDark: boolean }) {
- return (
-
- {JSON.stringify(value, deterministicJSONReplacer, 2)}
-
- );
-}
-
-// A replacer function for JSON.stringify that guarantees the same keys order
-function deterministicJSONReplacer(_: any, value: any) {
- return typeof value !== 'object' || value === null || Array.isArray(value)
- ? value
- : Object.fromEntries(
- Object.entries(value).sort(([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0))
- );
-}
-
const styles = StyleSheet.create({
container: {
padding: 20,
@@ -136,14 +122,7 @@ const styles = StyleSheet.create({
marginBottom: 10,
textAlign: 'center',
},
- code: {
- fontSize: 12,
- },
contentContainer: {
paddingBottom: Platform.select({ ios: 30, android: 150 }),
},
- divider: {
- height: 1,
- marginVertical: 20,
- },
});
diff --git a/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx b/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx
new file mode 100644
index 00000000000000..feba87885a0a5b
--- /dev/null
+++ b/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx
@@ -0,0 +1,17 @@
+import { Stack } from 'expo-router';
+
+import { useTheme } from '@/utils/theme';
+
+export default function SessionsLayout() {
+ const theme = useTheme();
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/[id].tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/[id].tsx
new file mode 100644
index 00000000000000..e638c3da6f74dc
--- /dev/null
+++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/[id].tsx
@@ -0,0 +1,132 @@
+import AppMetrics, { type Session } from 'expo-app-metrics';
+import { Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
+import { useCallback, useState } from 'react';
+import { Platform, Pressable, ScrollView, StyleSheet, Text } from 'react-native';
+
+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 { MetricsPanel } from '@/components/MetricsPanel';
+import { SessionHeader } from '@/components/SessionHeader';
+import { useTheme } from '@/utils/theme';
+
+export default function SessionDetail() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const theme = useTheme();
+ const [session, setSession] = useState(null);
+ const [loaded, setLoaded] = useState(false);
+ const [showRawJson, setShowRawJson] = useState(false);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (Platform.OS !== 'ios') {
+ setLoaded(true);
+ return;
+ }
+ AppMetrics.getAllSessions().then((sessions) => {
+ setSession(sessions.find((s) => s.id === id) ?? null);
+ setLoaded(true);
+ });
+ }, [id])
+ );
+
+ return (
+
+
+ {!loaded ? null : session ? (
+ <>
+
+
+ {session.type === 'main' && session.crashReport ? (
+ <>
+ Crash report
+
+ {session.crashReport.callStackTree ? (
+ <>
+
+
+ Call stacks
+
+
+ >
+ ) : null}
+
+ >
+ ) : null}
+ Metrics
+
+
+ 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,
+ },
+ };
+}
+
+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
new file mode 100644
index 00000000000000..b78b508e5368c9
--- /dev/null
+++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx
@@ -0,0 +1,323 @@
+import AppMetrics, { type Session } from 'expo-app-metrics';
+import { router, Stack, useFocusEffect } from 'expo-router';
+import { useCallback, useState } from 'react';
+import {
+ ActivityIndicator,
+ Platform,
+ Pressable,
+ RefreshControl,
+ SectionList,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+
+import { useTheme } from '@/utils/theme';
+
+export default function SessionsList() {
+ const theme = useTheme();
+ const [sessions, setSessions] = 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 refresh = useCallback(async () => {
+ const result = await AppMetrics.getAllSessions();
+ const sorted = [...result].sort((a, b) => (a.startDate < b.startDate ? 1 : -1));
+ setSessions(sorted);
+ setLoaded(true);
+ }, []);
+
+ const onRefresh = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ await refresh();
+ } finally {
+ setRefreshing(false);
+ }
+ }, [refresh]);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (Platform.OS === 'ios') {
+ refresh();
+ } else {
+ setLoaded(true);
+ }
+ }, [refresh])
+ );
+
+ if (typeof AppMetrics.getAllSessions !== 'function') {
+ return (
+
+
+ Sessions are not implemented on this platform yet
+
+
+ );
+ }
+
+ const sections = groupByDay(sessions);
+
+ const title = sessions.length > 0 ? `Sessions (${sessions.length})` : 'Sessions';
+
+ return (
+ <>
+
+ session.id}
+ stickySectionHeadersEnabled={false}
+ refreshControl={
+ Platform.OS === 'ios' ? (
+
+ ) : undefined
+ }
+ ListEmptyComponent={
+ loaded ? (
+
+ No sessions yet
+
+ Sessions are recorded as you use the app. Crashes from past launches show up here
+ once MetricKit delivers them.
+
+
+ ) : (
+
+
+
+ )
+ }
+ renderSectionHeader={({ section }) => (
+
+ {section.title}
+
+ )}
+ 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 SessionRow({ session, isActive }: { session: Session; isActive: boolean }) {
+ const theme = useTheme();
+ const startDate = new Date(session.startDate);
+ const endDate = session.endDate ? new Date(session.endDate) : null;
+ const duration = endDate ? formatDuration(endDate.getTime() - startDate.getTime()) : null;
+ const shortId = session.id.slice(0, 8);
+
+ return (
+ router.push(`/sessions/${session.id}`)}
+ style={({ pressed }) => [
+ styles.row,
+ {
+ backgroundColor: theme.background.element,
+ borderColor: theme.border.default,
+ },
+ isActive && {
+ borderLeftWidth: 3,
+ borderLeftColor: theme.icon.success,
+ },
+ pressed && styles.rowPressed,
+ ]}>
+
+
+ {formatDate(startDate)}
+
+
+ {session.type === 'main' && session.crashReport ? (
+
+ Crashed
+
+ ) : null}
+
+
+ {capitalize(session.type)}
+
+
+
+
+
+ {shortId}
+ {duration ? ` · ${duration}` : isActive ? ' · active' : ''} · {session.metrics.length}{' '}
+ metric{session.metrics.length === 1 ? '' : 's'}
+
+
+ );
+}
+
+const dateFormatter = new Intl.DateTimeFormat('en-GB', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ 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);
+}
+
+function capitalize(value: string) {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+// Renders a duration with the two largest non-zero units (e.g. "2h 5m", "45s", "3d").
+function formatDuration(ms: number) {
+ const totalSeconds = Math.round(ms / 1000);
+ const units = [
+ { value: Math.floor(totalSeconds / 86400), suffix: 'd' },
+ { value: Math.floor((totalSeconds % 86400) / 3600), suffix: 'h' },
+ { value: Math.floor((totalSeconds % 3600) / 60), suffix: 'm' },
+ { value: totalSeconds % 60, suffix: 's' },
+ ];
+ const firstNonZero = units.findIndex((u) => u.value > 0);
+ if (firstNonZero === -1) {
+ return '0s';
+ }
+ const parts = units
+ .slice(firstNonZero, firstNonZero + 2)
+ .filter((u) => u.value > 0)
+ .map((u) => `${u.value}${u.suffix}`);
+ return parts.join(' ');
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ center: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ contentContainer: {
+ padding: 20,
+ paddingTop: 8,
+ paddingBottom: Platform.select({ ios: 30, android: 150 }),
+ },
+ emptyContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 40,
+ gap: 8,
+ },
+ emptyText: {
+ fontSize: 16,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ },
+ emptyHint: {
+ fontSize: 13,
+ textAlign: 'center',
+ marginHorizontal: 12,
+ },
+ sectionHeader: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginBottom: 10,
+ },
+ sectionHeaderSpaced: {
+ marginTop: 16,
+ },
+ row: {
+ borderWidth: 1,
+ borderRadius: 10,
+ paddingVertical: 16,
+ paddingHorizontal: 16,
+ marginBottom: 8,
+ gap: 6,
+ },
+ rowPressed: {
+ opacity: 0.6,
+ },
+ rowHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 8,
+ },
+ rowTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ lineHeight: 20,
+ flexShrink: 1,
+ },
+ rowMeta: {
+ fontSize: 13,
+ },
+ badges: {
+ flexDirection: 'row',
+ gap: 6,
+ },
+ badge: {
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 5,
+ },
+ badgeText: {
+ fontSize: 12,
+ fontWeight: '700',
+ lineHeight: 16,
+ },
+});
diff --git a/apps/observability-tester/app/(tabs)/_layout.tsx b/apps/observe-tester/app/(tabs)/_layout.tsx
similarity index 73%
rename from apps/observability-tester/app/(tabs)/_layout.tsx
rename to apps/observe-tester/app/(tabs)/_layout.tsx
index a930806f19fe03..a7c80d5fa15581 100644
--- a/apps/observability-tester/app/(tabs)/_layout.tsx
+++ b/apps/observe-tester/app/(tabs)/_layout.tsx
@@ -7,6 +7,10 @@ export default function TabsLayout() {
Metrics
+
+ Sessions
+
+
Debug
diff --git a/apps/observe-tester/app/(tabs)/debug/_layout.tsx b/apps/observe-tester/app/(tabs)/debug/_layout.tsx
new file mode 100644
index 00000000000000..7e59192c48a8c5
--- /dev/null
+++ b/apps/observe-tester/app/(tabs)/debug/_layout.tsx
@@ -0,0 +1,16 @@
+import { Stack } from 'expo-router';
+
+import { useTheme } from '@/utils/theme';
+
+export default function DebugLayout() {
+ const theme = useTheme();
+ return (
+
+
+
+ );
+}
diff --git a/apps/observability-tester/app/(tabs)/debug/index.tsx b/apps/observe-tester/app/(tabs)/debug/index.tsx
similarity index 50%
rename from apps/observability-tester/app/(tabs)/debug/index.tsx
rename to apps/observe-tester/app/(tabs)/debug/index.tsx
index a78536bd2bb8ee..65d7df0ba2bf4b 100644
--- a/apps/observability-tester/app/(tabs)/debug/index.tsx
+++ b/apps/observe-tester/app/(tabs)/debug/index.tsx
@@ -1,11 +1,16 @@
+import AppMetrics from 'expo-app-metrics';
import { useEffect, useState } from 'react';
-import { StyleSheet, View } from 'react-native';
+import { ScrollView, StyleSheet } from 'react-native';
+import { Button } from '@/components/Button';
+import { CrashReportsSection } from '@/components/CrashReportsSection';
+import { Divider } from '@/components/Divider';
+import { JSAnimation } from '@/components/JSAnimation';
import { useRouterMetricsHelpers } from '@/router-metrics-integration';
-import { Button } from '../../../components/Button';
-import { JSAnimation } from '../../../components/JSAnimation';
+import { useTheme } from '@/utils/theme';
export default function Debug() {
+ const theme = useTheme();
const [showAnimation, setShowAnimation] = useState(false);
const { markPageInteractive } = useRouterMetricsHelpers();
@@ -17,13 +22,18 @@ export default function Debug() {
}, [markPageInteractive]);
return (
-
+
+
+ {typeof AppMetrics.triggerCrash === 'function' ? : null}
+
);
}
diff --git a/apps/observability-tester/app/_layout.tsx b/apps/observe-tester/app/_layout.tsx
similarity index 99%
rename from apps/observability-tester/app/_layout.tsx
rename to apps/observe-tester/app/_layout.tsx
index 0d1485b0ed0a15..a1e0e041bc4c9f 100644
--- a/apps/observability-tester/app/_layout.tsx
+++ b/apps/observe-tester/app/_layout.tsx
@@ -1,9 +1,10 @@
-import { startLoggingRouterMetrics } from '@/router-metrics-integration';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import ExpoObserve, { AppMetricsRoot } from 'expo-observe';
import { Stack } from 'expo-router';
import { useColorScheme } from 'react-native';
+import { startLoggingRouterMetrics } from '@/router-metrics-integration';
+
// Toggle to enable per screen router metrics logging
const IS_ROUTER_INTEGRATION_ENABLED = false;
diff --git a/apps/observability-tester/app/redirect.tsx b/apps/observe-tester/app/redirect.tsx
similarity index 100%
rename from apps/observability-tester/app/redirect.tsx
rename to apps/observe-tester/app/redirect.tsx
diff --git a/apps/observability-tester/assets/images/android-icon-dark.png b/apps/observe-tester/assets/images/android-icon-dark.png
similarity index 100%
rename from apps/observability-tester/assets/images/android-icon-dark.png
rename to apps/observe-tester/assets/images/android-icon-dark.png
diff --git a/apps/observability-tester/assets/images/android-icon-foreground.png b/apps/observe-tester/assets/images/android-icon-foreground.png
similarity index 100%
rename from apps/observability-tester/assets/images/android-icon-foreground.png
rename to apps/observe-tester/assets/images/android-icon-foreground.png
diff --git a/apps/observability-tester/assets/images/android-icon.png b/apps/observe-tester/assets/images/android-icon.png
similarity index 100%
rename from apps/observability-tester/assets/images/android-icon.png
rename to apps/observe-tester/assets/images/android-icon.png
diff --git a/apps/observability-tester/assets/images/favicon.png b/apps/observe-tester/assets/images/favicon.png
similarity index 100%
rename from apps/observability-tester/assets/images/favicon.png
rename to apps/observe-tester/assets/images/favicon.png
diff --git a/apps/observability-tester/assets/images/icon.png b/apps/observe-tester/assets/images/icon.png
similarity index 100%
rename from apps/observability-tester/assets/images/icon.png
rename to apps/observe-tester/assets/images/icon.png
diff --git a/apps/observability-tester/assets/images/splash-icon-dark.png b/apps/observe-tester/assets/images/splash-icon-dark.png
similarity index 100%
rename from apps/observability-tester/assets/images/splash-icon-dark.png
rename to apps/observe-tester/assets/images/splash-icon-dark.png
diff --git a/apps/observability-tester/assets/images/splash-icon.png b/apps/observe-tester/assets/images/splash-icon.png
similarity index 100%
rename from apps/observability-tester/assets/images/splash-icon.png
rename to apps/observe-tester/assets/images/splash-icon.png
diff --git a/apps/observability-tester/assets/observe.icon/Assets/pulse.svg b/apps/observe-tester/assets/observe.icon/Assets/pulse.svg
similarity index 100%
rename from apps/observability-tester/assets/observe.icon/Assets/pulse.svg
rename to apps/observe-tester/assets/observe.icon/Assets/pulse.svg
diff --git a/apps/observability-tester/assets/observe.icon/icon.json b/apps/observe-tester/assets/observe.icon/icon.json
similarity index 100%
rename from apps/observability-tester/assets/observe.icon/icon.json
rename to apps/observe-tester/assets/observe.icon/icon.json
diff --git a/apps/observe-tester/components/Button.tsx b/apps/observe-tester/components/Button.tsx
new file mode 100644
index 00000000000000..486203d959e6cd
--- /dev/null
+++ b/apps/observe-tester/components/Button.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { Pressable, PressableProps, StyleSheet, Text, View } from 'react-native';
+
+import { useTheme } from '@/utils/theme';
+
+type ButtonTheme = 'primary' | 'secondary' | 'tertiary';
+
+type ButtonProps = {
+ title: string;
+ description?: string;
+ onPress?: () => void;
+ theme?: ButtonTheme;
+ disabled?: boolean;
+} & PressableProps;
+
+export function Button({
+ title,
+ description,
+ onPress,
+ theme = 'primary',
+ disabled,
+ ...rest
+}: ButtonProps) {
+ const styleguide = useTheme();
+ const tokens = styleguide.button[theme];
+ const disabledTokens = tokens.disabled;
+
+ const backgroundColor = disabled ? disabledTokens.background : tokens.background;
+ const borderColor = disabled ? disabledTokens.border : tokens.border;
+ const textColor = disabled ? disabledTokens.text : tokens.text;
+
+ return (
+ [
+ styles.button,
+ { backgroundColor, borderColor },
+ pressed ? styles.buttonFocused : null,
+ ]}
+ disabled={disabled}
+ {...rest}>
+
+ {title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: 6,
+ paddingHorizontal: 20,
+ paddingVertical: 12,
+ marginBottom: 16,
+ borderWidth: 1,
+ },
+ buttonFocused: {
+ opacity: 0.5,
+ },
+ labelContainer: {
+ alignItems: 'center',
+ },
+ text: {
+ fontWeight: '600',
+ fontSize: 18,
+ letterSpacing: 0.5,
+ },
+ description: {
+ fontSize: 13,
+ fontWeight: '500',
+ marginTop: 2,
+ letterSpacing: 0.3,
+ },
+});
diff --git a/apps/observe-tester/components/CallStackTreeView.tsx b/apps/observe-tester/components/CallStackTreeView.tsx
new file mode 100644
index 00000000000000..ff1cf95b75f09b
--- /dev/null
+++ b/apps/observe-tester/components/CallStackTreeView.tsx
@@ -0,0 +1,189 @@
+import type { CallStackFrame, CallStackTree } from 'expo-app-metrics';
+import { useEffect, useState } from 'react';
+import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+
+import { Chevron } from '@/components/Chevron';
+import { useTheme } from '@/utils/theme';
+
+export function CallStackTreeView({ tree }: { tree: CallStackTree }) {
+ const theme = useTheme();
+ const stacks = tree.callStacks ?? [];
+
+ if (stacks.length === 0) {
+ return (
+ No call stacks captured
+ );
+ }
+
+ const sortedStacks = stacks
+ .map((stack, originalIndex) => ({ stack, originalIndex }))
+ .filter(({ stack }) => (stack.callStackRootFrames?.length ?? 0) > 0)
+ .sort(
+ (a, b) =>
+ Number(b.stack.threadAttributed ?? false) - Number(a.stack.threadAttributed ?? false)
+ );
+
+ return (
+
+ {sortedStacks.map(({ stack, originalIndex }) => (
+
+ ))}
+
+ );
+}
+
+function ThreadView({
+ stack,
+ originalIndex,
+}: {
+ stack: NonNullable[number];
+ originalIndex: number;
+}) {
+ const theme = useTheme();
+ const isAttributed = !!stack.threadAttributed;
+ const [expanded, setExpanded] = useState(isAttributed);
+ const [framesMounted, setFramesMounted] = useState(isAttributed);
+ const frames = flattenFrames(stack.callStackRootFrames ?? []);
+
+ useEffect(() => {
+ if (expanded && !framesMounted) {
+ const handle = requestAnimationFrame(() => setFramesMounted(true));
+ return () => cancelAnimationFrame(handle);
+ }
+ }, [expanded, framesMounted]);
+
+ return (
+
+ setExpanded((v) => !v)}
+ style={({ pressed }) => [
+ styles.threadHeader,
+ expanded && styles.threadHeaderExpanded,
+ pressed && { opacity: 0.6 },
+ ]}>
+
+ Thread {originalIndex + 1}
+ {isAttributed ? (
+
+ {' · '}
+ attributed
+
+ ) : null}
+ {!expanded ? (
+
+ {' '}
+ · {frames.length} frame{frames.length === 1 ? '' : 's'}
+
+ ) : null}
+
+
+
+ {framesMounted ? (
+
+
+ {frames.map((frame, frameIndex) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function flattenFrames(rootFrames: CallStackFrame[]): CallStackFrame[] {
+ const result: CallStackFrame[] = [];
+ const stack: CallStackFrame[] = [...rootFrames];
+ while (stack.length > 0) {
+ const next = stack.pop()!;
+ result.push(next);
+ const subFrames = next.subFrames ?? [];
+ for (let i = subFrames.length - 1; i >= 0; i--) {
+ stack.push(subFrames[i]);
+ }
+ }
+ return result;
+}
+
+function FrameRow({ frame }: { frame: CallStackFrame }) {
+ const theme = useTheme();
+ return (
+
+ {formatAddress(frame.address)}
+ {frame.binaryName ?? '(unknown)'}
+ {frame.symbol ? (
+ {frame.symbol}
+ ) : (
+
+ {formatOffset(frame.offsetIntoBinaryTextSegment)}
+
+ )}
+
+ );
+}
+
+function formatAddress(address: number | null | undefined) {
+ if (address == null) return '0x0000000000000000';
+ return '0x' + address.toString(16).padStart(16, '0');
+}
+
+function formatOffset(offset: number | null | undefined) {
+ if (offset == null) return '';
+ return ' +0x' + offset.toString(16);
+}
+
+const styles = StyleSheet.create({
+ thread: {
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingVertical: 12,
+ paddingHorizontal: 8,
+ marginBottom: 12,
+ },
+ threadHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 12,
+ paddingHorizontal: 8,
+ },
+ threadHeaderExpanded: {
+ marginBottom: 8,
+ },
+ threadTitle: {
+ fontSize: 14,
+ fontWeight: '700',
+ flexShrink: 1,
+ },
+ attributed: {
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ frameCount: {
+ fontSize: 12,
+ fontWeight: '500',
+ },
+ frame: {
+ fontFamily: 'Menlo',
+ fontSize: 11,
+ paddingVertical: 2,
+ },
+ empty: {
+ fontSize: 13,
+ textAlign: 'center',
+ marginVertical: 12,
+ },
+ hidden: {
+ display: 'none',
+ },
+});
diff --git a/apps/observe-tester/components/Chevron.tsx b/apps/observe-tester/components/Chevron.tsx
new file mode 100644
index 00000000000000..72664ac52307cd
--- /dev/null
+++ b/apps/observe-tester/components/Chevron.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useRef } from 'react';
+import { Animated, StyleSheet, type StyleProp, type TextStyle } from 'react-native';
+
+import { useTheme } from '@/utils/theme';
+
+type ChevronProps = {
+ expanded: boolean;
+ size?: number;
+ color?: string;
+ animated?: boolean;
+ style?: StyleProp;
+};
+
+export function Chevron({ expanded, size = 18, color, animated = true, style }: ChevronProps) {
+ const theme = useTheme();
+ const rotation = useRef(new Animated.Value(expanded ? 1 : 0)).current;
+
+ useEffect(() => {
+ if (!animated) {
+ rotation.setValue(expanded ? 1 : 0);
+ return;
+ }
+ Animated.timing(rotation, {
+ toValue: expanded ? 1 : 0,
+ duration: 180,
+ useNativeDriver: true,
+ }).start();
+ }, [expanded, animated, rotation]);
+
+ const rotate = rotation.interpolate({
+ inputRange: [0, 1],
+ outputRange: ['0deg', '90deg'],
+ });
+
+ return (
+
+ ❯
+
+ );
+}
+
+const styles = StyleSheet.create({
+ chevron: {
+ fontWeight: '600',
+ },
+});
diff --git a/apps/observe-tester/components/CrashReportPanel.tsx b/apps/observe-tester/components/CrashReportPanel.tsx
new file mode 100644
index 00000000000000..50612549b7bee8
--- /dev/null
+++ b/apps/observe-tester/components/CrashReportPanel.tsx
@@ -0,0 +1,172 @@
+import type { CrashReport } from 'expo-app-metrics';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { useTheme } from '@/utils/theme';
+
+export function CrashReportPanel({ report }: { report: CrashReport }) {
+ const theme = useTheme();
+ const exceptionLabel = formatExceptionLabel(report.exceptionType, report.signal);
+
+ return (
+
+ {exceptionLabel ? (
+ {exceptionLabel}
+ ) : null}
+ {report.terminationReason ? (
+
+ ) : null}
+ {report.virtualMemoryRegionInfo ? (
+
+ ) : null}
+ {report.exceptionReason ? (
+
+
+ {report.exceptionReason.composedMessage}
+
+
+ {report.exceptionReason.className} · {report.exceptionReason.exceptionType}
+
+
+ ) : null}
+
+
+ Window: {formatDate(report.timestampBegin)} → {formatDate(report.timestampEnd)}
+
+
+ Ingested: {formatDate(report.ingestedAt)}
+
+
+
+ );
+}
+
+function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ const theme = useTheme();
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+function formatExceptionLabel(type: number | null | undefined, signal: number | null | undefined) {
+ const parts: string[] = [];
+ if (type != null) parts.push(exceptionName(type));
+ if (signal != null) parts.push(signalName(signal));
+ return parts.join(' · ');
+}
+
+function exceptionName(type: number) {
+ switch (type) {
+ case 1:
+ return 'EXC_BAD_ACCESS';
+ case 2:
+ return 'EXC_BAD_INSTRUCTION';
+ case 3:
+ return 'EXC_ARITHMETIC';
+ case 4:
+ return 'EXC_EMULATION';
+ case 5:
+ return 'EXC_SOFTWARE';
+ case 6:
+ return 'EXC_BREAKPOINT';
+ case 10:
+ return 'EXC_CRASH';
+ case 11:
+ return 'EXC_RESOURCE';
+ case 12:
+ return 'EXC_GUARD';
+ default:
+ return `EXC_${type}`;
+ }
+}
+
+function signalName(signal: number) {
+ switch (signal) {
+ case 4:
+ return 'SIGILL';
+ case 5:
+ return 'SIGTRAP';
+ case 6:
+ return 'SIGABRT';
+ case 8:
+ return 'SIGFPE';
+ case 9:
+ return 'SIGKILL';
+ case 10:
+ return 'SIGBUS';
+ case 11:
+ return 'SIGSEGV';
+ default:
+ return `SIG${signal}`;
+ }
+}
+
+const dateFormatter = new Intl.DateTimeFormat('en-GB', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+});
+
+function formatDate(iso: string) {
+ return dateFormatter.format(new Date(iso));
+}
+
+const styles = StyleSheet.create({
+ container: {
+ borderWidth: 1,
+ borderRadius: 8,
+ padding: 12,
+ gap: 10,
+ },
+ headline: {
+ fontSize: 16,
+ fontWeight: '700',
+ },
+ field: {
+ gap: 2,
+ },
+ fieldLabel: {
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ fieldValue: {
+ fontSize: 13,
+ fontWeight: '500',
+ },
+ fieldValueMono: {
+ fontFamily: 'Menlo',
+ fontSize: 12,
+ },
+ exceptionReason: {
+ gap: 4,
+ },
+ exceptionMessage: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ exceptionMeta: {
+ fontSize: 12,
+ fontWeight: '500',
+ },
+ timing: {
+ gap: 2,
+ },
+ timingText: {
+ fontSize: 11,
+ },
+});
diff --git a/apps/observe-tester/components/CrashReportsSection.tsx b/apps/observe-tester/components/CrashReportsSection.tsx
new file mode 100644
index 00000000000000..94739e20037ad3
--- /dev/null
+++ b/apps/observe-tester/components/CrashReportsSection.tsx
@@ -0,0 +1,60 @@
+import AppMetrics, { type CrashKind } from 'expo-app-metrics';
+import { StyleSheet, Text } from 'react-native';
+
+import { Button } from '@/components/Button';
+import { useTheme } from '@/utils/theme';
+
+const CRASH_TRIGGERS: { kind: CrashKind; title: string; description: string }[] = [
+ { kind: 'badAccess', title: 'Bad access', description: 'EXC_BAD_ACCESS / SIGSEGV' },
+ { kind: 'fatalError', title: 'Fatal error', description: 'EXC_CRASH / SIGABRT' },
+ { kind: 'divideByZero', title: 'Divide by zero', description: 'EXC_ARITHMETIC / SIGFPE' },
+ { kind: 'forceUnwrapNil', title: 'Force-unwrap nil', description: 'EXC_BAD_INSTRUCTION' },
+ { kind: 'arrayOutOfBounds', title: 'Array out of bounds', description: 'EXC_BAD_INSTRUCTION' },
+ { kind: 'objcException', title: 'NSException', description: 'Uncaught Objective-C exception' },
+ { kind: 'stackOverflow', title: 'Stack overflow', description: 'Unbounded recursion' },
+];
+
+export function CrashReportsSection() {
+ const theme = useTheme();
+
+ if (typeof AppMetrics.triggerCrash !== 'function') {
+ return null;
+ }
+
+ return (
+ <>
+ Crash reports
+
+ Trigger real crashes to produce MetricKit diagnostics, or simulate a crash report attached
+ to the current session.
+
+