Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/mobile/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
91 changes: 91 additions & 0 deletions apps/mobile/src/lib/crashLog.ts
Original file line number Diff line number Diff line change
@@ -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));
Comment thread
juliusmarminge marked this conversation as resolved.
}

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))) {
Comment thread
juliusmarminge marked this conversation as resolved.
try {
file.delete();
} catch {
// Leave undeletable records for the next prune.
}
}
} catch {
// Reporting past crashes must never affect startup.
}
}
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/installCrashLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { installCrashLogger } from "./crashLog";

installCrashLogger();
Loading