Files
jrshikoku/lib/observability/appLifecycleCrashSentinel.ts

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();
}