235 lines
7.6 KiB
TypeScript
235 lines
7.6 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { AppState, AppStateStatus, Platform } from "react-native";
|
|
import * as Sentry from "@sentry/react-native";
|
|
import * as Updates from "expo-updates";
|
|
import { lastObservedRootRouteRef } from "@/lib/rootNavigation";
|
|
|
|
const STORAGE_KEY = "@jrshikoku/app_crash_sentinel_v1";
|
|
const HEARTBEAT_INTERVAL_MS = 7000;
|
|
const RECENT_HEARTBEAT_MS = 120000;
|
|
const MIN_SESSION_AGE_MS = 5000;
|
|
|
|
type RootNavigationSnapshot = {
|
|
rootRoute: string | null;
|
|
nestedRoute?: string | null;
|
|
rootIndex?: number;
|
|
nestedIndex?: number;
|
|
source?: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
type CrashSentinelState = {
|
|
app_session_active: boolean;
|
|
normal_background: boolean;
|
|
sessionStartedAt: string;
|
|
lastHeartbeatAt: string;
|
|
lastKnownAppState: AppStateStatus | "unknown";
|
|
lastRootTab: string | null;
|
|
previousRootNavigation: RootNavigationSnapshot | null;
|
|
activeWebViews: string[];
|
|
nativeScreensMode: string;
|
|
reportedUnexpectedExitForSessionStartedAt?: string;
|
|
};
|
|
|
|
type StartOptions = {
|
|
nativeScreensMode: string;
|
|
};
|
|
|
|
let currentState: CrashSentinelState | null = null;
|
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
let appStateSubscription: { remove: () => void } | null = null;
|
|
let latestNavigationSnapshot: RootNavigationSnapshot | null = null;
|
|
const activeWebViews = new Set<string>();
|
|
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
const safeJsonParse = <T>(value: string | null): T | null => {
|
|
if (!value) return null;
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getExpoUpdateContext = () => ({
|
|
update_id: (Updates as any).updateId ?? null,
|
|
updateId: (Updates as any).updateId ?? null,
|
|
channel: (Updates as any).channel ?? null,
|
|
runtimeVersion: (Updates as any).runtimeVersion ?? null,
|
|
createdAt: (Updates as any).createdAt ?? null,
|
|
isEmbeddedLaunch: (Updates as any).isEmbeddedLaunch ?? null,
|
|
});
|
|
|
|
const getMemoryInfo = () => {
|
|
const performanceMemory = (globalThis as any)?.performance?.memory;
|
|
if (!performanceMemory) return null;
|
|
return {
|
|
jsHeapSizeLimit: performanceMemory.jsHeapSizeLimit ?? null,
|
|
totalJSHeapSize: performanceMemory.totalJSHeapSize ?? null,
|
|
usedJSHeapSize: performanceMemory.usedJSHeapSize ?? null,
|
|
};
|
|
};
|
|
|
|
const getRootTab = () => lastObservedRootRouteRef.current ?? currentState?.lastRootTab ?? "unknown";
|
|
|
|
const persistCurrentState = async (patch?: Partial<CrashSentinelState>) => {
|
|
if (!currentState) return;
|
|
currentState = {
|
|
...currentState,
|
|
...patch,
|
|
lastRootTab: getRootTab(),
|
|
previousRootNavigation: latestNavigationSnapshot ?? currentState.previousRootNavigation,
|
|
activeWebViews: Array.from(activeWebViews).sort(),
|
|
};
|
|
try {
|
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(currentState));
|
|
} catch (error) {
|
|
Sentry.addBreadcrumb({
|
|
category: "app.lifecycle",
|
|
level: "warning",
|
|
message: "crash sentinel persist failed",
|
|
data: {
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const shouldReportUnexpectedExit = (previous: CrashSentinelState, nowMs: number) => {
|
|
if (!previous.app_session_active) return false;
|
|
if (previous.normal_background) return false;
|
|
if (previous.reportedUnexpectedExitForSessionStartedAt === previous.sessionStartedAt) return false;
|
|
|
|
const heartbeatMs = Date.parse(previous.lastHeartbeatAt);
|
|
const startedMs = Date.parse(previous.sessionStartedAt);
|
|
if (!Number.isFinite(heartbeatMs) || !Number.isFinite(startedMs)) return false;
|
|
|
|
const heartbeatAgeMs = nowMs - heartbeatMs;
|
|
const sessionAgeMs = heartbeatMs - startedMs;
|
|
return heartbeatAgeMs >= 0 && heartbeatAgeMs <= RECENT_HEARTBEAT_MS && sessionAgeMs >= MIN_SESSION_AGE_MS;
|
|
};
|
|
|
|
const reportUnexpectedExit = async (previous: CrashSentinelState, nativeScreensMode: string) => {
|
|
const rootTab = getRootTab();
|
|
Sentry.captureMessage("app.previous_session_unexpected_exit", {
|
|
level: "warning",
|
|
tags: {
|
|
area: "app_lifecycle",
|
|
result: "unexpected_exit",
|
|
platform: Platform.OS,
|
|
root_tab: rootTab,
|
|
last_root_tab: previous.lastRootTab ?? "unknown",
|
|
native_screens_mode: nativeScreensMode,
|
|
},
|
|
contexts: {
|
|
app_lifecycle: {
|
|
lastHeartbeatAt: previous.lastHeartbeatAt,
|
|
sessionStartedAt: previous.sessionStartedAt,
|
|
previousRootNavigation: previous.previousRootNavigation,
|
|
lastKnownAppState: previous.lastKnownAppState,
|
|
activeWebViews: previous.activeWebViews,
|
|
memory: getMemoryInfo(),
|
|
expoUpdate: getExpoUpdateContext(),
|
|
normalBackground: previous.normal_background,
|
|
heartbeatAgeMs: Date.now() - Date.parse(previous.lastHeartbeatAt),
|
|
},
|
|
},
|
|
fingerprint: ["app_lifecycle", "unexpected_exit", Platform.OS],
|
|
});
|
|
|
|
await AsyncStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify({
|
|
...previous,
|
|
reportedUnexpectedExitForSessionStartedAt: previous.sessionStartedAt,
|
|
})
|
|
);
|
|
};
|
|
|
|
export async function startAppLifecycleCrashSentinel(options: StartOptions) {
|
|
const startedAt = nowIso();
|
|
const previous = safeJsonParse<CrashSentinelState>(await AsyncStorage.getItem(STORAGE_KEY));
|
|
|
|
if (previous && shouldReportUnexpectedExit(previous, Date.now())) {
|
|
await reportUnexpectedExit(previous, options.nativeScreensMode);
|
|
}
|
|
|
|
currentState = {
|
|
app_session_active: true,
|
|
normal_background: false,
|
|
sessionStartedAt: startedAt,
|
|
lastHeartbeatAt: startedAt,
|
|
lastKnownAppState: AppState.currentState ?? "unknown",
|
|
lastRootTab: getRootTab(),
|
|
previousRootNavigation: latestNavigationSnapshot,
|
|
activeWebViews: Array.from(activeWebViews).sort(),
|
|
nativeScreensMode: options.nativeScreensMode,
|
|
};
|
|
|
|
Sentry.setContext("app_lifecycle_sentinel", {
|
|
sessionStartedAt: currentState.sessionStartedAt,
|
|
lastHeartbeatAt: currentState.lastHeartbeatAt,
|
|
lastKnownAppState: currentState.lastKnownAppState,
|
|
activeWebViews: currentState.activeWebViews,
|
|
expoUpdate: getExpoUpdateContext(),
|
|
});
|
|
|
|
await persistCurrentState();
|
|
|
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
heartbeatTimer = setInterval(() => {
|
|
void persistCurrentState({
|
|
lastHeartbeatAt: nowIso(),
|
|
lastKnownAppState: AppState.currentState ?? "unknown",
|
|
normal_background: AppState.currentState === "background" ? true : currentState?.normal_background ?? false,
|
|
});
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
|
|
appStateSubscription?.remove();
|
|
appStateSubscription = AppState.addEventListener("change", (nextState) => {
|
|
const patch: Partial<CrashSentinelState> = {
|
|
lastKnownAppState: nextState,
|
|
lastHeartbeatAt: nowIso(),
|
|
};
|
|
if (nextState === "background") {
|
|
patch.normal_background = true;
|
|
} else if (nextState === "active") {
|
|
patch.normal_background = false;
|
|
}
|
|
void persistCurrentState(patch);
|
|
});
|
|
}
|
|
|
|
export function stopAppLifecycleCrashSentinel() {
|
|
if (heartbeatTimer) {
|
|
clearInterval(heartbeatTimer);
|
|
heartbeatTimer = null;
|
|
}
|
|
appStateSubscription?.remove();
|
|
appStateSubscription = null;
|
|
}
|
|
|
|
export function recordAppLifecycleRootNavigation(snapshot: Omit<RootNavigationSnapshot, "updatedAt">) {
|
|
latestNavigationSnapshot = {
|
|
...snapshot,
|
|
updatedAt: nowIso(),
|
|
};
|
|
if (snapshot.rootRoute) {
|
|
lastObservedRootRouteRef.current = snapshot.rootRoute;
|
|
}
|
|
void persistCurrentState();
|
|
}
|
|
|
|
export function setAppLifecycleWebViewActive(name: string, active: boolean) {
|
|
if (active) {
|
|
activeWebViews.add(name);
|
|
} else {
|
|
activeWebViews.delete(name);
|
|
}
|
|
Sentry.setContext("active_webviews", {
|
|
names: Array.from(activeWebViews).sort(),
|
|
});
|
|
void persistCurrentState();
|
|
}
|