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
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
Expand Down Expand Up @@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({
title: "Client Storage",
},
}),
SettingsDiagnostics: createNativeStackScreen({
screen: SettingsDiagnosticsRouteScreen,
linking: "diagnostics",
options: {
title: "Diagnostics",
},
}),
SettingsUsageAccount: createNativeStackScreen({
screen: UsageLimitAccountScreen,
options: { title: "Account" },
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer";
import IconSettings from "@tabler/icons-react-native/IconSettings";
import IconSparkles from "@tabler/icons-react-native/IconSparkles";
import IconStack2 from "@tabler/icons-react-native/IconStack2";
import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
import IconSun from "@tabler/icons-react-native/IconSun";
import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
Expand Down Expand Up @@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,
safari: IconExternalLink,
"server.rack": IconServer,
stethoscope: IconStethoscope,
"sidebar.left": IconLayoutSidebar,
"sidebar.right": IconLayoutSidebarRight,
"slider.horizontal.3": IconAdjustmentsHorizontal,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import Constants from "expo-constants";
import * as Updates from "expo-updates";
import { useEffect, useState } from "react";
import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { SettingsSection } from "../settings/components/SettingsSection";
import {
formatStartupCrashReport,
parseStartupCrashRecords,
type StartupCrashRecord,
} from "./crash-log-model";

// expo-updates keeps its persistent log this long. Reading any further back
// returns nothing, so this is the whole available window.
const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;

type CrashLogState =
| { readonly status: "loading" }
| { readonly status: "unavailable" }
| { readonly status: "ready"; readonly records: ReadonlyArray<StartupCrashRecord> };

function appIdentity() {
return {
version: Constants.expoConfig?.version ?? "0.0.0",
build:
(Platform.OS === "ios"
? Constants.platform?.ios?.buildNumber
: Constants.platform?.android?.versionCode?.toString()) ?? "dev",
};
}

/**
* Startup crashes that TestFlight and the stores strip from their reports.
* expo-updates' ErrorRecovery writes the JS error and component stack to its
* own log before aborting the process, so the next launch can show it here.
*/
export function SettingsDiagnosticsRouteScreen() {
const insets = useSafeAreaInsets();
const [state, setState] = useState<CrashLogState>(() =>
Updates.isEnabled ? { status: "loading" } : { status: "unavailable" },
);
const [copied, setCopied] = useState(false);

useEffect(() => {
if (!Updates.isEnabled) return;
let cancelled = false;
Updates.readLogEntriesAsync(LOG_WINDOW_MS)
.then((entries) => {
if (cancelled) return;
setState({ status: "ready", records: parseStartupCrashRecords(entries) });
})
.catch((error: unknown) => {
console.warn("[diagnostics] could not read the expo-updates log", error);
if (!cancelled) setState({ status: "unavailable" });
});
return () => {
cancelled = true;
};
}, []);

const records = state.status === "ready" ? state.records : [];
const copyReport = async () => {
const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), {
target: "crash report",
});
if (ok) setCopied(true);
};

return (
<View collapsable={false} className="flex-1 bg-sheet">
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentInset={{ bottom: Math.max(insets.bottom, 18) }}
showsVerticalScrollIndicator={false}
className="flex-1"
contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]"
>
<SettingsSection title="Startup crashes">
{state.status === "loading" ? (
<View className="items-center gap-3 px-6 py-8">
<ActivityIndicator />
<Text className="text-center text-sm text-foreground-muted">Reading crash log…</Text>
</View>
) : state.status === "unavailable" ? (
<EmptyState
icon="exclamationmark.triangle"
title="Crash log unavailable"
detail="Startup crash records are only kept in store and TestFlight builds."
/>
) : records.length === 0 ? (
<EmptyState
icon="checkmark.circle"
title="No startup crashes"
detail="Nothing has taken the app down during launch in the last 7 days."
/>
) : (
records.map((record, index) => (
<CrashRow key={record.timestamp} record={record} first={index === 0} />
))
)}
</SettingsSection>

<View className="gap-3">
<SettingsSection title="Actions">
<Pressable
accessibilityRole="button"
disabled={state.status !== "ready"}
onPress={() => void copyReport()}
className="flex-row items-center gap-4 p-4 disabled:opacity-40"
>
<SymbolView
name={copied ? "checkmark" : "doc.on.doc"}
size={22}
tintColorClassName={"accent-icon"}
type="monochrome"
weight="regular"
/>
<Text className="flex-1 text-lg text-foreground">
{copied ? "Copied" : "Copy crash report"}
</Text>
</Pressable>
</SettingsSection>
<Text className="px-2 text-sm leading-normal text-foreground-muted">
Paste the report into a GitHub issue. It contains the app version, the JavaScript error
message, and the component stack. Error messages can quote values from the app, so read
it over before sharing.
</Text>
</View>
</ScrollView>
</View>
);
}

function EmptyState(props: {
readonly icon: "exclamationmark.triangle" | "checkmark.circle";
readonly title: string;
readonly detail: string;
}) {
return (
<View className="items-center gap-2 px-6 py-8">
<SymbolView
name={props.icon}
size={28}
tintColorClassName={"accent-icon"}
type="monochrome"
weight="regular"
/>
<Text className="text-center text-base text-foreground">{props.title}</Text>
<Text className="text-center text-sm text-foreground-muted">{props.detail}</Text>
</View>
);
}

function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) {
const { record } = props;
return (
<View className={props.first ? "gap-1.5 p-4" : "gap-1.5 border-t border-border-subtle p-4"}>
<Text className="text-xs text-foreground-muted">
{new Date(record.timestamp).toLocaleString()}
</Text>
<Text selectable className="text-base leading-snug text-danger-foreground">
{record.description}
</Text>
{record.frames.length > 0 ? (
<Text selectable className="font-mono text-xs leading-snug text-foreground-muted">
{record.frames.slice(0, 4).join("\n")}
</Text>
) : null}
</View>
);
}
74 changes: 74 additions & 0 deletions apps/mobile/src/features/diagnostics/crash-log-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vite-plus/test";

import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model";

// Verbatim shape of the entry expo-updates wrote for the build 56 launch crash.
const BUNDLE =
"/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle";
const FATAL = {
timestamp: 1789277752000,
level: "error",
code: "JSRuntimeError",
message: [
"ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930",
"Domain: RCTErrorDomain",
"Code: 0",
"Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null",
"",
"This error is located at:",
` at NewTaskFlowProvider (${BUNDLE}:590585:51)`,
` at NavigationProvider (${BUNDLE}:139067:3)`,
" at RNSSafeAreaView (<anonymous>)",
].join("\n"),
};

describe("parseStartupCrashRecords", () => {
it("extracts the JS error and a compact component stack from a fatal entry", () => {
const [record] = parseStartupCrashRecords([FATAL]);
expect(record?.description).toBe(
"TypeError: Cannot read property 'defaultModelSelection' of null",
);
expect(record?.frames).toEqual([
"NewTaskFlowProvider (main.jsbundle:590585:51)",
"NavigationProvider (main.jsbundle:139067:3)",
"RNSSafeAreaView",
]);
expect(record?.detail).toContain("Domain: RCTErrorDomain");
});

it("ignores update-check noise and orders crashes newest first", () => {
const records = parseStartupCrashRecords([
{ timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" },
{ ...FATAL, timestamp: 10 },
{ timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" },
{ ...FATAL, timestamp: 20 },
]);
expect(records.map((record) => record.timestamp)).toEqual([20, 10]);
});

it("skips a runtime error entry without a description line", () => {
expect(
parseStartupCrashRecords([
{ ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" },
]),
).toEqual([]);
});
});

describe("formatStartupCrashReport", () => {
it("writes the app version and each crash's full detail", () => {
const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), {
version: "1.1.1",
build: "56",
});
expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true);
expect(report).toContain("2026-09-13T05:35:52.000Z");
expect(report).toContain("at NewTaskFlowProvider");
});

it("says so when nothing was recorded", () => {
expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe(
"T3 Code 1.1.1 (56)\nNo startup crashes recorded.",
);
});
});
83 changes: 83 additions & 0 deletions apps/mobile/src/features/diagnostics/crash-log-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* The shape of an expo-updates log entry we care about. Mirrors
* `UpdatesLogEntry` structurally so the model needs no native module at test
* time.
*/
export interface UpdatesLogEntryLike {
readonly timestamp: number;
readonly message: string;
readonly code: string;
readonly level: string;
}

/** One fatal JavaScript error that took the app down at startup. */
export interface StartupCrashRecord {
readonly timestamp: number;
/** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */
readonly description: string;
/** Component stack (`at Name (bundle:line:col)`), one frame per entry. */
readonly frames: ReadonlyArray<string>;
/** Everything after the message header, verbatim, for copying. */
readonly detail: string;
}

const FATAL_PREFIX = "ErrorRecovery fatal exception: ";
const DESCRIPTION_PREFIX = "Description: ";
const UNHANDLED_PREFIX = "Unhandled JS Exception: ";

/**
* Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while
* aborting the process. Everything else in that log (update checks, asset
* loads) is noise for a "why did the app crash" question.
*/
export function parseStartupCrashRecords(
entries: ReadonlyArray<UpdatesLogEntryLike>,
): ReadonlyArray<StartupCrashRecord> {
const records: StartupCrashRecord[] = [];
for (const entry of entries) {
if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue;
const body = entry.message.slice(FATAL_PREFIX.length);
const lines = body.split("\n");
const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX));
if (descriptionLine === undefined) continue;
let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim();
if (description.startsWith(UNHANDLED_PREFIX)) {
description = description.slice(UNHANDLED_PREFIX.length);
}
const frames = lines
.map((line) => line.trim())
.filter((line) => line.startsWith("at "))
.map(compactFrame);
records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() });
}
// Newest first; the log appends in time order.
return records.sort((left, right) => right.timestamp - left.timestamp);
}

/**
* `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as
* `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is
* the same on every frame and says nothing.
*/
function compactFrame(line: string): string {
const match = /^at (.+?) \((.*)\)$/.exec(line);
if (!match) return line.slice("at ".length);
const [, name = "", location = ""] = match;
const file = location.slice(location.lastIndexOf("/") + 1);
return location === "<anonymous>" ? name : `${name} (${file})`;
}

/** The report a user pastes into an issue: every recorded crash, newest first. */
export function formatStartupCrashReport(
records: ReadonlyArray<StartupCrashRecord>,
app: { readonly version: string; readonly build: string },
): string {
const header = `T3 Code ${app.version} (${app.build})`;
if (records.length === 0) return `${header}\nNo startup crashes recorded.`;
return [
header,
...records.map(
(record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`,
),
].join("\n");
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@ function AppSettingsSection() {
return (
<SettingsSection title="App">
<SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
<SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" />
<SettingsRow
icon="doc.on.doc"
label="Open source licenses"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type SettingsSheetTarget =
| "SettingsAppearance"
| "SettingsProjectGrouping"
| "SettingsClientStorage"
| "SettingsDiagnostics"
| "SettingsOpenSourceLicenses"
| "SettingsUsage";

Expand Down
Loading
Loading