Improve renderer crash report breadcrumbs
This commit is contained in:
parent
acda1b2b5b
commit
0d1b69eff2
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue