Improve renderer crash report breadcrumbs

This commit is contained in:
Neil 2026-05-20 10:38:52 -07:00
parent acda1b2b5b
commit 0d1b69eff2
11 changed files with 460 additions and 13 deletions

View File

@ -1,11 +1,13 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
clearCrashBreadcrumbsForTest,
getCrashBreadcrumbSnapshot,
recordCoalescedCrashBreadcrumb,
recordCrashBreadcrumb
} from './crash-breadcrumb-store'
afterEach(() => {
vi.useRealTimers()
clearCrashBreadcrumbsForTest()
})
@ -48,4 +50,37 @@ describe('crash breadcrumb store', () => {
expect(getCrashBreadcrumbSnapshot()).toHaveLength(1)
expect(getCrashBreadcrumbSnapshot()[0].data).toEqual({ packaged: false })
})
it('coalesces repeated breadcrumbs inside the interval', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z'))
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
data: { agentType: 'claude', state: 'working' },
coalesceKey: 'agent:claude:working',
minIntervalMs: 30_000
})
vi.advanceTimersByTime(1_000)
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
data: { agentType: 'claude', state: 'working' },
coalesceKey: 'agent:claude:working',
minIntervalMs: 30_000
})
vi.advanceTimersByTime(30_000)
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
data: { agentType: 'claude', state: 'working' },
coalesceKey: 'agent:claude:working',
minIntervalMs: 30_000
})
expect(getCrashBreadcrumbSnapshot().map((entry) => entry.data)).toEqual([
{ agentType: 'claude', state: 'working' },
{ agentType: 'claude', state: 'working', suppressedSinceLast: 1 }
])
vi.useRealTimers()
})
})

View File

@ -1,16 +1,15 @@
import {
sanitizeCrashReportBreadcrumbs,
type CrashReportBreadcrumbData,
type CrashReportBreadcrumb
} from '../../shared/crash-reporting'
const MAX_BREADCRUMBS = 30
let breadcrumbs: CrashReportBreadcrumb[] = []
let coalescedBreadcrumbs = new Map<string, { recordedAt: number; suppressed: number }>()
export function recordCrashBreadcrumb(
name: string,
data?: Record<string, string | number | boolean | null>
): void {
export function recordCrashBreadcrumb(name: string, data?: CrashReportBreadcrumbData): void {
const sanitized = sanitizeCrashReportBreadcrumbs([
{
createdAt: new Date().toISOString(),
@ -28,6 +27,31 @@ export function recordCrashBreadcrumb(
}
}
export function recordCoalescedCrashBreadcrumb({
name,
data,
coalesceKey,
minIntervalMs
}: {
name: string
data?: CrashReportBreadcrumbData
coalesceKey: string
minIntervalMs: number
}): void {
const now = Date.now()
const previous = coalescedBreadcrumbs.get(coalesceKey)
if (previous && now - previous.recordedAt < minIntervalMs) {
previous.suppressed += 1
return
}
coalescedBreadcrumbs.set(coalesceKey, { recordedAt: now, suppressed: 0 })
recordCrashBreadcrumb(
name,
previous?.suppressed ? { ...data, suppressedSinceLast: previous.suppressed } : data
)
}
export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] {
return breadcrumbs.map((breadcrumb) => ({
...breadcrumb,
@ -37,4 +61,5 @@ export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] {
export function clearCrashBreadcrumbsForTest(): void {
breadcrumbs = []
coalescedBreadcrumbs = new Map()
}

View File

@ -96,6 +96,7 @@ import { AutomationService } from './automations/service'
import { AgentAwakeService } from './agent-awake-service'
import {
getCrashBreadcrumbSnapshot,
recordCoalescedCrashBreadcrumb,
recordCrashBreadcrumb
} from './crash-reporting/crash-breadcrumb-store'
import { CrashReportStore } from './crash-reporting/crash-report-store'
@ -139,6 +140,7 @@ let watcherShutdownDone = false
let automations: AutomationService | null = null
let keybindings: KeybindingService | null = null
let expectedRendererReload: { webContentsId: number; until: number } | null = null
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
const isServeMode = process.argv.includes('--serve')
const appImageCliRedirect = maybeRedirectAppImageCliLaunch({
isPackaged: app.isPackaged,
@ -286,6 +288,18 @@ function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope
: 'none'
}
function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void {
// Why: hook pings can arrive many times per second while an agent works.
// Coalescing preserves crash-report room for renderer errors and memory
// samples instead of filling all 30 breadcrumbs with identical state pings.
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
data: { agentType, state },
coalesceKey: `agent:${agentType}:${state}`,
minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS
})
}
// Why: the lock must be acquired AFTER configureDevUserDataPath — Electron
// derives the lock identity from the `userData` path, so this placement lets
// dev (`orca-dev`) and packaged (`orca`) runs lock in separate namespaces
@ -599,10 +613,7 @@ function openMainWindow(): BrowserWindow {
stateStartedAt,
...(orchestration ? { orchestration } : {})
})
recordCrashBreadcrumb('agent_state_changed', {
agentType: payload.agentType ?? 'unknown',
state: payload.state
})
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
// Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn,
// and opencode's stays bare "OpenCode" — neither carries a working/idle
// signal the title heuristic can read. Synthesize an OSC title update

View File

@ -2,10 +2,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CrashReportRecord } from '../../shared/crash-reporting'
const { handlers, clipboardWriteTextMock, submitFeedbackMock } = vi.hoisted(() => ({
const {
handlers,
listeners,
clipboardWriteTextMock,
submitFeedbackMock,
recordCrashBreadcrumbMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
listeners: new Map<string, (_event: unknown, args?: unknown) => void>(),
clipboardWriteTextMock: vi.fn(),
submitFeedbackMock: vi.fn()
submitFeedbackMock: vi.fn(),
recordCrashBreadcrumbMock: vi.fn()
}))
vi.mock('electron', () => ({
@ -15,6 +23,10 @@ vi.mock('electron', () => ({
removeHandler: vi.fn((channel: string) => handlers.delete(channel)),
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
}),
removeAllListeners: vi.fn((channel: string) => listeners.delete(channel)),
on: vi.fn((channel: string, listener: (_event: unknown, args?: unknown) => void) => {
listeners.set(channel, listener)
})
}
}))
@ -23,6 +35,11 @@ vi.mock('./feedback', () => ({
submitFeedback: submitFeedbackMock
}))
vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({
getCrashBreadcrumbSnapshot: vi.fn(() => []),
recordCrashBreadcrumb: (...args: unknown[]) => recordCrashBreadcrumbMock(...args)
}))
import {
_getCrashReportingStateSizesForTests,
_resetRendererErrorReportDedupeForTests,
@ -54,8 +71,10 @@ function report(
describe('registerCrashReportingHandlers', () => {
beforeEach(() => {
handlers.clear()
listeners.clear()
clipboardWriteTextMock.mockReset()
submitFeedbackMock.mockReset()
recordCrashBreadcrumbMock.mockReset()
submitFeedbackMock.mockResolvedValue({ ok: true })
_resetRendererErrorReportDedupeForTests()
})
@ -379,4 +398,54 @@ describe('registerCrashReportingHandlers', () => {
expect(recordMock).toHaveBeenCalledTimes(261)
})
it('records sanitized renderer breadcrumbs', () => {
registerCrashReportingHandlers({
getLatestPending: vi.fn(),
getById: vi.fn(),
dismiss: vi.fn(),
markSent: vi.fn(),
listRecent: vi.fn(),
record: vi.fn(),
formatDiagnosticText: vi.fn()
} as never)
listeners.get('crashReports:recordBreadcrumb')?.(null, {
name: 'renderer_error',
data: {
message: 'boom',
count: 2,
ok: true,
empty: null,
badNumber: Number.POSITIVE_INFINITY,
object: { ignored: true }
}
})
expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_error', {
message: 'boom',
count: 2,
ok: true,
empty: null
})
})
it('ignores renderer breadcrumbs without a string name', () => {
registerCrashReportingHandlers({
getLatestPending: vi.fn(),
getById: vi.fn(),
dismiss: vi.fn(),
markSent: vi.fn(),
listRecent: vi.fn(),
record: vi.fn(),
formatDiagnosticText: vi.fn()
} as never)
listeners.get('crashReports:recordBreadcrumb')?.(null, {
name: 123,
data: { message: 'boom' }
})
expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled()
})
})

View File

@ -3,6 +3,7 @@
import os from 'node:os'
import { app, clipboard, ipcMain } from 'electron'
import {
type CrashReportBreadcrumbData,
formatCrashReportText,
type ReactErrorBoundaryReportArgs,
type ReactErrorBoundaryReportResult,
@ -11,7 +12,10 @@ import {
} from '../../shared/crash-reporting'
import { submitFeedback } from './feedback'
import type { CrashReportStore } from '../crash-reporting/crash-report-store'
import { getCrashBreadcrumbSnapshot } from '../crash-reporting/crash-breadcrumb-store'
import {
getCrashBreadcrumbSnapshot,
recordCrashBreadcrumb
} from '../crash-reporting/crash-breadcrumb-store'
const inFlightSubmissions = new Set<string>()
const submittedReportIds = new Set<string>()
@ -236,6 +240,21 @@ async function getLatestSendableReport(
)
}
function sanitizeRendererBreadcrumbData(value: unknown): CrashReportBreadcrumbData | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined
}
const sanitized: CrashReportBreadcrumbData = {}
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === 'string' || typeof entry === 'boolean' || entry === null) {
sanitized[key] = entry
} else if (typeof entry === 'number' && Number.isFinite(entry)) {
sanitized[key] = entry
}
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined
}
export function registerCrashReportingHandlers(store: CrashReportStore): void {
ipcMain.removeHandler('crashReports:getLatestPending')
ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store))
@ -255,6 +274,17 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
return store.dismiss(args.reportId)
})
ipcMain.removeAllListeners('crashReports:recordBreadcrumb')
ipcMain.on(
'crashReports:recordBreadcrumb',
(_event, args?: { name?: unknown; data?: unknown }) => {
if (!args || typeof args.name !== 'string') {
return
}
recordCrashBreadcrumb(args.name, sanitizeRendererBreadcrumbData(args.data))
}
)
ipcMain.removeHandler('crashReports:copyLatestDiagnostics')
ipcMain.handle(
'crashReports:copyLatestDiagnostics',

View File

@ -206,6 +206,7 @@ import type {
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult } from '../shared/skills'
import type {
CrashReportBreadcrumbData,
CrashReportRecord,
CrashReportSubmitArgs,
CrashReportSubmitResult,
@ -895,6 +896,7 @@ export type PreloadApi = {
recordRendererError: (
args: ReactErrorBoundaryReportArgs
) => Promise<ReactErrorBoundaryReportResult>
recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void
submit: (args: CrashReportSubmitArgs) => Promise<CrashReportSubmitResult>
copyLatestDiagnostics: (args?: {
reportId?: string

View File

@ -153,6 +153,7 @@ import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-su
import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions'
import type { HostedReviewForBranchArgs } from '../shared/hosted-review'
import type {
CrashReportBreadcrumbData,
CrashReportSubmitArgs,
CrashReportSubmitResult,
ReactErrorBoundaryReportArgs,
@ -797,6 +798,8 @@ const api = {
args: ReactErrorBoundaryReportArgs
): Promise<ReactErrorBoundaryReportResult> =>
ipcRenderer.invoke('crashReports:recordRendererError', args),
recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void =>
ipcRenderer.send('crashReports:recordBreadcrumb', args),
submit: (args: CrashReportSubmitArgs): Promise<CrashReportSubmitResult> =>
ipcRenderer.invoke('crashReports:submit', args),
copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) =>

View File

@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as CrashDiagnostics from './crash-diagnostics'
type DiagnosticsModule = typeof CrashDiagnostics
type Listener = (event: unknown) => void
describe('renderer crash diagnostics', () => {
let diagnostics: DiagnosticsModule
let listeners: Map<string, Listener[]>
let recordBreadcrumbMock: ReturnType<typeof vi.fn>
let setIntervalMock: ReturnType<typeof vi.fn>
beforeEach(async () => {
vi.resetModules()
listeners = new Map()
recordBreadcrumbMock = vi.fn()
setIntervalMock = vi.fn(() => 1)
vi.stubGlobal('window', {
api: {
crashReports: {
recordBreadcrumb: recordBreadcrumbMock
}
},
addEventListener: vi.fn((type: string, listener: Listener) => {
const current = listeners.get(type) ?? []
current.push(listener)
listeners.set(type, current)
}),
setInterval: setIntervalMock,
performance: {
memory: {
usedJSHeapSize: 32 * 1024 * 1024,
totalJSHeapSize: 64 * 1024 * 1024,
jsHeapSizeLimit: 512 * 1024 * 1024
}
}
})
diagnostics = (await import('./crash-diagnostics')) as DiagnosticsModule
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('records renderer breadcrumbs through preload', () => {
diagnostics.recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: true })
expect(recordBreadcrumbMock).toHaveBeenCalledWith({
name: 'renderer_bootstrap_started',
data: { dev: true }
})
})
it('installs startup, error, rejection, and memory breadcrumbs once', () => {
diagnostics.installRendererCrashDiagnostics()
diagnostics.installRendererCrashDiagnostics()
expect(window.addEventListener).toHaveBeenCalledTimes(2)
expect(setIntervalMock).toHaveBeenCalledTimes(1)
expect(recordBreadcrumbMock).toHaveBeenCalledWith({
name: 'renderer_memory',
data: {
reason: 'startup',
usedHeapMB: 32,
totalHeapMB: 64,
heapLimitMB: 512
}
})
listeners.get('error')?.[0]?.({
message: 'boom',
filename: '/Users/test/project/src/main.tsx',
lineno: 42,
colno: 7,
error: new TypeError('bad renderer state')
})
expect(recordBreadcrumbMock).toHaveBeenCalledWith({
name: 'renderer_error',
data: expect.objectContaining({
message: 'boom',
filename: '/Users/test/project/src/main.tsx',
lineno: 42,
colno: 7,
errorType: 'TypeError',
errorName: 'TypeError',
errorMessage: 'bad renderer state'
})
})
listeners.get('unhandledrejection')?.[0]?.({ reason: 'missing startup dependency' })
expect(recordBreadcrumbMock).toHaveBeenCalledWith({
name: 'renderer_unhandled_rejection',
data: {
reasonType: 'string',
reasonMessage: 'missing startup dependency'
}
})
})
it('does not throw when preload is unavailable', () => {
vi.stubGlobal('window', {})
expect(() =>
diagnostics.recordRendererCrashBreadcrumb('renderer_bootstrap_started')
).not.toThrow()
})
})

View File

@ -0,0 +1,150 @@
import type {
CrashReportBreadcrumbData,
CrashReportDetailValue
} from '../../../shared/crash-reporting'
const RENDERER_MEMORY_SAMPLE_INTERVAL_MS = 60_000
const BYTES_PER_MEGABYTE = 1024 * 1024
type BrowserPerformanceMemory = {
usedJSHeapSize?: number
totalJSHeapSize?: number
jsHeapSizeLimit?: number
}
let rendererCrashDiagnosticsInstalled = false
export function recordRendererCrashBreadcrumb(
name: string,
data?: CrashReportBreadcrumbData
): void {
if (typeof window === 'undefined') {
return
}
try {
// Why: crash diagnostics must never create or mask renderer startup failures.
const api = (window as Window & { api?: Window['api'] }).api
api?.crashReports.recordBreadcrumb({ name, ...(data ? { data } : {}) })
} catch {
// Best-effort crash evidence only.
}
}
export function installRendererCrashDiagnostics(): void {
if (rendererCrashDiagnosticsInstalled || typeof window === 'undefined') {
return
}
rendererCrashDiagnosticsInstalled = true
window.addEventListener('error', recordRendererError)
window.addEventListener('unhandledrejection', recordRendererUnhandledRejection)
if (getPerformanceMemory()) {
recordRendererMemory('startup')
window.setInterval(() => recordRendererMemory('interval'), RENDERER_MEMORY_SAMPLE_INTERVAL_MS)
}
}
function recordRendererError(event: ErrorEvent): void {
recordRendererCrashBreadcrumb(
'renderer_error',
compactBreadcrumbData({
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
...describeUnknownValue('error', event.error)
})
)
}
function recordRendererUnhandledRejection(event: PromiseRejectionEvent): void {
recordRendererCrashBreadcrumb(
'renderer_unhandled_rejection',
compactBreadcrumbData(describeUnknownValue('reason', event.reason))
)
}
function recordRendererMemory(reason: string): void {
const memory = getPerformanceMemory()
if (!memory) {
return
}
recordRendererCrashBreadcrumb(
'renderer_memory',
compactBreadcrumbData({
reason,
usedHeapMB: toMegabytes(memory.usedJSHeapSize),
totalHeapMB: toMegabytes(memory.totalJSHeapSize),
heapLimitMB: toMegabytes(memory.jsHeapSizeLimit)
})
)
}
function getPerformanceMemory(): BrowserPerformanceMemory | undefined {
if (typeof window === 'undefined') {
return undefined
}
return (window.performance as Performance & { memory?: BrowserPerformanceMemory }).memory
}
function describeUnknownValue(
prefix: string,
value: unknown
): Record<string, CrashReportDetailValue | undefined> {
if (value === null) {
return { [`${prefix}Type`]: 'null' }
}
if (value === undefined) {
return { [`${prefix}Type`]: 'undefined' }
}
if (typeof value === 'object' || typeof value === 'function') {
const candidate = value as {
name?: unknown
message?: unknown
stack?: unknown
constructor?: { name?: string }
}
return {
[`${prefix}Type`]: typeof value === 'function' ? 'function' : candidate.constructor?.name,
[`${prefix}Name`]: typeof candidate.name === 'string' ? candidate.name : undefined,
[`${prefix}Message`]: typeof candidate.message === 'string' ? candidate.message : undefined,
[`${prefix}Stack`]: typeof candidate.stack === 'string' ? candidate.stack : undefined
}
}
return {
[`${prefix}Type`]: typeof value,
[`${prefix}Message`]: stringifyUnknown(value)
}
}
function stringifyUnknown(value: unknown): string {
try {
return String(value)
} catch {
return '[unstringifiable]'
}
}
function compactBreadcrumbData(
data: Record<string, CrashReportDetailValue | undefined>
): CrashReportBreadcrumbData {
const compacted: CrashReportBreadcrumbData = {}
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'string' || typeof value === 'boolean' || value === null) {
compacted[key] = value
} else if (typeof value === 'number' && Number.isFinite(value)) {
compacted[key] = value
}
}
return compacted
}
function toMegabytes(value: number | undefined): number | undefined {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value / BYTES_PER_MEGABYTE)
: undefined
}

View File

@ -4,9 +4,16 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
import {
installRendererCrashDiagnostics,
recordRendererCrashBreadcrumb
} from './lib/crash-diagnostics'
import { applyDocumentTheme } from './lib/document-theme'
import { shouldEnableReactGrab } from './lib/react-grab-dev-gate'
recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV })
installRendererCrashDiagnostics()
if (
import.meta.env.DEV &&
shouldEnableReactGrab({
@ -20,7 +27,13 @@ if (
applyDocumentTheme('system', { disableTransitions: false })
createRoot(document.getElementById('root')!).render(
const rootElement = document.getElementById('root')
if (!rootElement) {
recordRendererCrashBreadcrumb('renderer_root_missing')
throw new Error('Renderer root element not found.')
}
createRoot(rootElement).render(
<StrictMode>
<RecoverableRenderErrorBoundary
boundaryId="app.root"
@ -32,3 +45,4 @@ createRoot(document.getElementById('root')!).render(
</RecoverableRenderErrorBoundary>
</StrictMode>
)
recordRendererCrashBreadcrumb('renderer_bootstrap_rendered')

View File

@ -432,6 +432,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
getLatestReport: () => Promise.resolve(null),
dismiss: () => Promise.resolve(null),
recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }),
recordBreadcrumb: () => {},
submit: () =>
Promise.resolve({
ok: false,