diff --git a/src/main/crash-reporting/crash-breadcrumb-store.test.ts b/src/main/crash-reporting/crash-breadcrumb-store.test.ts index c674b8b4c..2965007c9 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.test.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.test.ts @@ -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() + }) }) diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index 61f630f5f..55210366c 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -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() -export function recordCrashBreadcrumb( - name: string, - data?: Record -): 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() } diff --git a/src/main/index.ts b/src/main/index.ts index 00ac22887..2e8023ef5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 5049f64c2..bea144731 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -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 unknown>(), + listeners: new Map 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() + }) }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 6e289e645..92cea2653 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -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() const submittedReportIds = new Set() @@ -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', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 08ef1ea6a..d5802406b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void submit: (args: CrashReportSubmitArgs) => Promise copyLatestDiagnostics: (args?: { reportId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 87223f84b..101d91f91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('crashReports:recordRendererError', args), + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => + ipcRenderer.send('crashReports:recordBreadcrumb', args), submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) => diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts new file mode 100644 index 000000000..d72649d3e --- /dev/null +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -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 + let recordBreadcrumbMock: ReturnType + let setIntervalMock: ReturnType + + 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() + }) +}) diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts new file mode 100644 index 000000000..8cf76a869 --- /dev/null +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -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 { + 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 +): 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 +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6d5d884bd..22b084f60 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( ) +recordRendererCrashBreadcrumb('renderer_bootstrap_rendered') diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c29cffb7c..4560a98d7 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -432,6 +432,7 @@ function createWebPreloadApi(): Partial { getLatestReport: () => Promise.resolve(null), dismiss: () => Promise.resolve(null), recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }), + recordBreadcrumb: () => {}, submit: () => Promise.resolve({ ok: false,