From 751747b293ca05e5da349362bf0b136e1f90721d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 22:45:32 -0700 Subject: [PATCH] [eric] telemetry: crash recoveries, wedge recoveries, and rage clicks reach analytics (silent-failure family A) --- electron/main.js | 14 +++++++++ electron/preload.js | 6 ++++ frontend/src/app/Main.tsx | 14 ++++++++- frontend/src/shared/uxSignals.ts | 49 ++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 frontend/src/shared/uxSignals.ts diff --git a/electron/main.js b/electron/main.js index 69905241..adc1f7b9 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1475,6 +1475,20 @@ function createWindow() { mainWindow.webContents.on('preload-error', (_event, preloadPath, err) => { console.error('[diag][main:preload-error]', preloadPath, err && err.stack || err); }); + // Frozen-but-not-crashed is the silent class no crash log sees; Chromium's own unresponsive + // signal costs nothing and the report fires from the renderer AFTER it recovers. + let wedgeStartedAt = 0; + mainWindow.webContents.on('unresponsive', () => { + wedgeStartedAt = Date.now(); + console.error('[diag][main] renderer unresponsive'); + }); + mainWindow.webContents.on('responsive', () => { + if (!wedgeStartedAt) return; + const ms = Date.now() - wedgeStartedAt; + wedgeStartedAt = 0; + console.error('[diag][main] renderer responsive again after', ms, 'ms'); + try { mainWindow.webContents.send('diag:wedge', { ms }); } catch (_) { /* window mid-teardown */ } + }); mainWindow.webContents.on('render-process-gone', (_event, details) => { const reason = details && details.reason; if (reason === 'clean-exit') return; diff --git a/electron/preload.js b/electron/preload.js index 978d18bb..b16e668b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -152,6 +152,12 @@ contextBridge.exposeInMainWorld('openswarm', { openApplication: (name) => ipcRenderer.invoke('open-application', name), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'), + // Fires after the renderer RECOVERS from a Chromium-detected freeze, with how long it was wedged. + onWedge: (cb) => { + const listener = (_e, info) => cb(info); + ipcRenderer.on('diag:wedge', listener); + return () => ipcRenderer.removeListener('diag:wedge', listener); + }, checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), downloadUpdate: () => ipcRenderer.invoke('download-update'), installUpdate: () => ipcRenderer.invoke('install-update'), diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index c7abb24c..7b9ece9f 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -66,6 +66,7 @@ if (typeof window !== 'undefined') { else window.setTimeout(prefetchAll, 500); } import { report, reportAppOpened, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; +import { installUxSignals } from '@/shared/uxSignals'; import { useRouteTracker } from '@/shared/hooks/useRouteTracker'; import { useDeepLink } from '@/shared/hooks/useDeepLink'; import { useWindowFocus } from '@/shared/hooks/useWindowFocus'; @@ -277,6 +278,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = if (!loaded) return; (window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates); }, [loaded, allowExperimentalUpdates]); + useEffect(() => installUxSignals(), []); // Hold paint until the settings fetch SETTLES so the user's theme renders first; Electron's ready-to-show relies on this. Settling, not succeeding: a backend that never answers used to leave a blank window forever. if (!settled) return null; return <>{children}; @@ -416,7 +418,17 @@ const CrashRecoveryChip: React.FC = () => { const api = (window as any).openswarm as OpenSwarmAPI | undefined; if (!api?.getCrashRecoveryInfo) return; api.getCrashRecoveryInfo().then((info) => { - if (info) { setMounted(true); setShow(true); } + if (info) { + setMounted(true); setShow(true); + // The crash was captured locally but analytics never heard about it; a silent GPU/renderer + // death is exactly the failure class telemetry must count (flight-recorder family A). + const i = info as { kind?: string; details?: { reason?: string; exitCode?: number } }; + report('process', 'crash_recovered', { + crash_kind: i.kind ?? 'unknown', + reason: i.details?.reason ?? null, + exit_code: i.details?.exitCode ?? null, + }); + } }).catch(() => {}); }, []); React.useEffect(() => { diff --git a/frontend/src/shared/uxSignals.ts b/frontend/src/shared/uxSignals.ts new file mode 100644 index 00000000..fe8b9f29 --- /dev/null +++ b/frontend/src/shared/uxSignals.ts @@ -0,0 +1,49 @@ +// Silent-failure UX sensors: rage clicks (the user telling us a button did nothing) and renderer +// wedge recoveries. Threshold-emission only; nothing here runs per-frame or per-render. +import { report } from '@/shared/serviceClient'; + +const RAGE_COUNT = 3; +const RAGE_WINDOW_MS = 2000; +const RAGE_THROTTLE_MS = 60_000; + +let p_lastTarget: EventTarget | null = null; +let p_clickTimes: number[] = []; +let p_lastRageReport = 0; + +function describeTarget(el: Element | null): string { + if (!el) return 'unknown'; + const sel = el.closest('[data-select-type]'); + if (sel) return sel.getAttribute('data-select-type') || 'card'; + const btn = el.closest('button, [role="button"]'); + if (btn) return (btn.getAttribute('aria-label') || btn.textContent || 'button').trim().slice(0, 40); + return el.tagName.toLowerCase(); +} + +export function installUxSignals(): () => void { + const onClick = (e: MouseEvent): void => { + const now = Date.now(); + if (e.target !== p_lastTarget) { + p_lastTarget = e.target; + p_clickTimes = [now]; + return; + } + p_clickTimes = [...p_clickTimes.filter((t) => now - t < RAGE_WINDOW_MS), now]; + if (p_clickTimes.length >= RAGE_COUNT && now - p_lastRageReport > RAGE_THROTTLE_MS) { + p_lastRageReport = now; + report('ux', 'rage_click', { + target: describeTarget(e.target as Element | null), + clicks: p_clickTimes.length, + }); + p_clickTimes = []; + } + }; + window.addEventListener('click', onClick, true); + const bridge = window as unknown as { openswarm?: { onWedge?: (cb: (info: { ms: number }) => void) => () => void } }; + const offWedge = bridge.openswarm?.onWedge?.((info) => { + report('process', 'wedge_recovered', { wedge_ms: info?.ms ?? -1 }); + }); + return () => { + window.removeEventListener('click', onClick, true); + offWedge?.(); + }; +}