diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..8add3abd386 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,3 +1,7 @@ +// Installed via a side-effect import listed first so the fatal-error handler +// is in place before the rest of the app module graph evaluates. +import "./src/lib/installCrashLog"; + import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { featureFlags } from "react-native-screens"; diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts new file mode 100644 index 00000000000..755ba1f723c --- /dev/null +++ b/apps/mobile/src/lib/crashLog.ts @@ -0,0 +1,91 @@ +import { Directory, File, Paths } from "expo-file-system"; +import type { ErrorUtils as ErrorUtilsInterface } from "react-native"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const MAX_PERSISTED_CRASH_LOGS = 20; +const REPORTED_ON_LAUNCH_COUNT = 3; + +let crashSequence = 0; + +function safeErrorMessage(error: unknown): string { + try { + return String(error); + } catch { + return "[Uncoercible error]"; + } +} + +export function installCrashLogger(): void { + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; + if (errorUtils === undefined) { + return; + } + const previousHandler = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { + if (isFatal === true) { + try { + persistCrashRecord(error); + } catch { + // Never let the logger mask the original error. + } + } + previousHandler(error, isFatal); + }); + // Deferred so install (which runs before the app module graph) does no + // file IO on the startup path. + setTimeout(() => { + reportAndPrunePreviousCrashes(); + }, 0); +} + +function persistCrashRecord(error: unknown): void { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const cause = error instanceof Error ? error : null; + const record = { + capturedAt: new Date().toISOString(), + isFatal: true, + message: cause?.message ?? safeErrorMessage(error), + name: cause?.name ?? null, + stack: cause?.stack ?? null, + }; + crashSequence += 1; + // Keep lexical directory ordering aligned with capture order for records + // written within the same millisecond. + const sequence = String(crashSequence).padStart(6, "0"); + const file = new File(directory, `crash-${Date.now()}-${sequence}.json`); + file.create({ intermediates: true, overwrite: true }); + file.write(JSON.stringify(record, null, 2)); +} + +function reportAndPrunePreviousCrashes(): void { + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + if (!directory.exists) { + return; + } + const files = directory + .list() + .filter( + (entry): entry is File => + entry instanceof File && entry.name.startsWith("crash-") && entry.name.endsWith(".json"), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const file of files.slice(-REPORTED_ON_LAUNCH_COUNT)) { + try { + console.warn(`[crash-log] previous fatal JS error (${file.name}):`, file.textSync()); + } catch { + // Skip unreadable records. + } + } + for (const file of files.slice(0, Math.max(0, files.length - MAX_PERSISTED_CRASH_LOGS))) { + try { + file.delete(); + } catch { + // Leave undeletable records for the next prune. + } + } + } catch { + // Reporting past crashes must never affect startup. + } +} diff --git a/apps/mobile/src/lib/installCrashLog.ts b/apps/mobile/src/lib/installCrashLog.ts new file mode 100644 index 00000000000..ac3d1b76558 --- /dev/null +++ b/apps/mobile/src/lib/installCrashLog.ts @@ -0,0 +1,3 @@ +import { installCrashLogger } from "./crashLog"; + +installCrashLogger();