From 91de183434b19088c19c6c082f9e86a395065d09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:55:08 -0700 Subject: [PATCH] fix(mobile): address crash diagnostics sign-off notes --- mobile/app/troubleshoot.tsx | 13 +++- .../components/PreviousCrashSessionBanner.tsx | 5 +- .../PreviousCrashSessionLaunchNotice.tsx | 6 +- .../mobile-root-error-boundary.test.tsx | 7 ++ ...vious-crash-session-launch-notice.test.tsx | 19 ++++- .../components/troubleshoot-screen.test.tsx | 3 +- .../mobile-crash-session-storage.test.ts | 9 ++- .../mobile-crash-session-storage.ts | 40 ++++++---- .../diagnostics/mobile-crash-session.test.ts | 74 ++++++------------- .../src/diagnostics/mobile-crash-session.ts | 22 +++--- src/shared/crash-error-description.test.ts | 14 ++++ src/shared/crash-error-description.ts | 24 ++++-- src/shared/crash-report-redaction.ts | 8 +- src/shared/crash-reporting.ts | 1 + 14 files changed, 147 insertions(+), 98 deletions(-) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index d185d577769..573ab80c36c 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -38,6 +38,7 @@ import { buildMobileCrashDiagnosticsReport, getPreviousMobileCrashSession } from '../src/diagnostics/mobile-crash-diagnostics' +import type { MobileCrashSessionSnapshot } from '../src/diagnostics/mobile-crash-session' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -68,7 +69,8 @@ export default function TroubleshootScreen() { const diagnosticRunRef = useRef(0) const activeInternetCheckRef = useRef(null) const [crashDiagnosticsCopied, setCrashDiagnosticsCopied] = useState(false) - const [hasPreviousCrashSession, setHasPreviousCrashSession] = useState(false) + const [previousCrashSession, setPreviousCrashSession] = + useState(null) const crashSessionLoadedRef = useRef(false) const setTroubleshootRootRef = useCallback((node: View | null): void => { @@ -77,7 +79,7 @@ export default function TroubleshootScreen() { crashSessionLoadedRef.current = true void getPreviousMobileCrashSession().then((session) => { if (!abortRef.current) { - setHasPreviousCrashSession(session !== null) + setPreviousCrashSession(session) } }) } @@ -213,7 +215,12 @@ export default function TroubleshootScreen() { contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} > - {hasPreviousCrashSession && } + {previousCrashSession && ( + + )} [ diff --git a/mobile/src/components/PreviousCrashSessionBanner.tsx b/mobile/src/components/PreviousCrashSessionBanner.tsx index 5f842027ac7..8f6dfd06444 100644 --- a/mobile/src/components/PreviousCrashSessionBanner.tsx +++ b/mobile/src/components/PreviousCrashSessionBanner.tsx @@ -1,19 +1,22 @@ import { AlertTriangle, ChevronRight, X } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native' +import { previousSessionTitle } from '../diagnostics/mobile-crash-session' import { colors, radii, spacing, typography } from '../theme/mobile-theme' export function PreviousCrashSessionBanner({ + endedAbnormally, onDismiss, onPress, style }: { + endedAbnormally: boolean onDismiss?: () => void onPress?: () => void style?: StyleProp }) { const copy = ( - Previous session ended abnormally + {previousSessionTitle(endedAbnormally)} Crash diagnostics are available to copy and share with support. diff --git a/mobile/src/components/PreviousCrashSessionLaunchNotice.tsx b/mobile/src/components/PreviousCrashSessionLaunchNotice.tsx index c0feea8d392..6cc852e06d3 100644 --- a/mobile/src/components/PreviousCrashSessionLaunchNotice.tsx +++ b/mobile/src/components/PreviousCrashSessionLaunchNotice.tsx @@ -46,7 +46,11 @@ export function PreviousCrashSessionLaunchNotice() { return ( - + ) } diff --git a/mobile/src/components/mobile-root-error-boundary.test.tsx b/mobile/src/components/mobile-root-error-boundary.test.tsx index 58c91433eef..ff346ef420d 100644 --- a/mobile/src/components/mobile-root-error-boundary.test.tsx +++ b/mobile/src/components/mobile-root-error-boundary.test.tsx @@ -3,6 +3,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ + dismissCrash: vi.fn().mockResolvedValue(undefined), + getUndismissedCrash: vi.fn().mockResolvedValue(null), hideSplash: vi.fn().mockResolvedValue(undefined), preventAutoHide: vi.fn().mockResolvedValue(undefined), recordAppState: vi.fn().mockResolvedValue(undefined), @@ -22,6 +24,9 @@ vi.mock('react-native', () => ({ Text: 'Text', View: 'View' })) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0 }) +})) vi.mock('lucide-react-native', () => ({ AlertTriangle: 'AlertTriangle', House: 'House', @@ -75,6 +80,8 @@ vi.mock('../transport/mobile-relay-pairing-recovery', () => ({ recoverMobileRelayPairing: vi.fn().mockResolvedValue(undefined) })) vi.mock('../diagnostics/mobile-crash-diagnostics', () => ({ + dismissPreviousMobileCrashSession: mocks.dismissCrash, + getUndismissedPreviousMobileCrashSession: mocks.getUndismissedCrash, recordMobileAppState: mocks.recordAppState, recordMobileRenderError: mocks.recordRenderError, recordMobileRouteBreadcrumb: mocks.recordRoute, diff --git a/mobile/src/components/previous-crash-session-launch-notice.test.tsx b/mobile/src/components/previous-crash-session-launch-notice.test.tsx index 4cbdcb85e5d..c7f637b94bb 100644 --- a/mobile/src/components/previous-crash-session-launch-notice.test.tsx +++ b/mobile/src/components/previous-crash-session-launch-notice.test.tsx @@ -32,7 +32,8 @@ import { PreviousCrashSessionLaunchNotice } from './PreviousCrashSessionLaunchNo const previousCrash = { openedAt: '2026-08-24T18:00:00.000Z', - breadcrumbs: [] + breadcrumbs: [], + endedAbnormally: true } describe('PreviousCrashSessionLaunchNotice', () => { @@ -70,4 +71,20 @@ describe('PreviousCrashSessionLaunchNotice', () => { 0 ) }) + + it('describes a contained-then-clean session without calling it abnormal', async () => { + mocks.getUndismissed.mockResolvedValue({ ...previousCrash, endedAbnormally: false }) + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create(createElement(PreviousCrashSessionLaunchNotice)) + await Promise.resolve() + }) + + expect( + renderer.root.findByProps({ children: 'Previous session recovered from a render error' }) + ).toBeDefined() + expect( + renderer.root.findAllByProps({ children: 'Previous session ended abnormally' }) + ).toHaveLength(0) + }) }) diff --git a/mobile/src/components/troubleshoot-screen.test.tsx b/mobile/src/components/troubleshoot-screen.test.tsx index 0f5d7aeee33..755053fe66c 100644 --- a/mobile/src/components/troubleshoot-screen.test.tsx +++ b/mobile/src/components/troubleshoot-screen.test.tsx @@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getPreviousCrashSession: vi.fn().mockResolvedValue({ openedAt: '2026-08-24T18:00:00.000Z', - breadcrumbs: [] + breadcrumbs: [], + endedAbnormally: true }) })) diff --git a/mobile/src/diagnostics/mobile-crash-session-storage.test.ts b/mobile/src/diagnostics/mobile-crash-session-storage.test.ts index 1bafc6e46a6..814e61afac3 100644 --- a/mobile/src/diagnostics/mobile-crash-session-storage.test.ts +++ b/mobile/src/diagnostics/mobile-crash-session-storage.test.ts @@ -28,13 +28,18 @@ function makeJournal( return { version: 1, activeSession: { - id: 'active-session', openedAt: OPENED_AT, marker: 'open', breadcrumbs: activeBreadcrumbs }, ...(previousBreadcrumbs - ? { latestAbnormalSession: { openedAt: OPENED_AT, breadcrumbs: previousBreadcrumbs } } + ? { + latestAbnormalSession: { + openedAt: OPENED_AT, + breadcrumbs: previousBreadcrumbs, + endedAbnormally: true + } + } : {}) } } diff --git a/mobile/src/diagnostics/mobile-crash-session-storage.ts b/mobile/src/diagnostics/mobile-crash-session-storage.ts index 75a159d6a31..ad094b7aec8 100644 --- a/mobile/src/diagnostics/mobile-crash-session-storage.ts +++ b/mobile/src/diagnostics/mobile-crash-session-storage.ts @@ -12,10 +12,10 @@ export type MobileCrashStorage = { export type MobileCrashSessionSnapshot = { openedAt: string breadcrumbs: CrashReportBreadcrumb[] + endedAbnormally: boolean } -export type PersistedMobileCrashSession = MobileCrashSessionSnapshot & { - id: string +export type PersistedMobileCrashSession = Omit & { marker: 'open' | 'closed' } @@ -28,13 +28,14 @@ export type PersistedMobileCrashJournal = { export const MOBILE_CRASH_SESSION_STORAGE_KEY = 'orca.mobile-crash-session.v1' export const MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS = 24_000 -export const MAX_MOBILE_CRASH_BREADCRUMBS = 30 +export const MAX_STORED_MOBILE_CRASH_BREADCRUMBS = 30 export function snapshotMobileCrashSession( session: PersistedMobileCrashSession ): MobileCrashSessionSnapshot { return { openedAt: session.openedAt, + endedAbnormally: session.marker === 'open', breadcrumbs: session.breadcrumbs.map((breadcrumb) => ({ ...breadcrumb, ...(breadcrumb.data ? { data: { ...breadcrumb.data } } : {}) @@ -104,26 +105,33 @@ export function serializeMobileCrashJournal(journal: PersistedMobileCrashJournal } function parseSession(value: unknown): PersistedMobileCrashSession | null { - if (!value || typeof value !== 'object') { - return null - } - const candidate = value as Record - const snapshot = parseSnapshot(candidate) - if ( - !snapshot || - typeof candidate.id !== 'string' || - (candidate.marker !== 'open' && candidate.marker !== 'closed') - ) { + const candidate = value as Record | null + const session = parseSessionData(value) + if (!candidate || !session || (candidate.marker !== 'open' && candidate.marker !== 'closed')) { return null } return { - ...snapshot, - id: sanitizeCrashReportString(candidate.id, 80), + ...session, marker: candidate.marker } } function parseSnapshot(value: unknown): MobileCrashSessionSnapshot | null { + const candidate = value as Record | null + const session = parseSessionData(value) + if (!candidate || !session) { + return null + } + const endedAbnormally = + typeof candidate.endedAbnormally === 'boolean' + ? candidate.endedAbnormally + : !session.breadcrumbs.some((breadcrumb) => breadcrumb.name === 'render_error_contained') + return { ...session, endedAbnormally } +} + +function parseSessionData( + value: unknown +): Omit | null { if (!value || typeof value !== 'object') { return null } @@ -132,7 +140,7 @@ function parseSnapshot(value: unknown): MobileCrashSessionSnapshot | null { return null } const recentBreadcrumbs = (candidate.breadcrumbs as CrashReportBreadcrumb[]).slice( - -MAX_MOBILE_CRASH_BREADCRUMBS + -MAX_STORED_MOBILE_CRASH_BREADCRUMBS ) const breadcrumbs = recentBreadcrumbs.flatMap( (breadcrumb) => sanitizeCrashReportBreadcrumbs([breadcrumb]) ?? [] diff --git a/mobile/src/diagnostics/mobile-crash-session.test.ts b/mobile/src/diagnostics/mobile-crash-session.test.ts index 5ec31a95666..3cd9073c681 100644 --- a/mobile/src/diagnostics/mobile-crash-session.test.ts +++ b/mobile/src/diagnostics/mobile-crash-session.test.ts @@ -24,10 +24,7 @@ const SECOND_SESSION_AT = FIRST_SESSION_AT + 30_000 describe('mobile crash session journal', () => { it('surfaces durable breadcrumbs after a simulated abnormal termination', async () => { const storage = new MemoryStorage() - const first = new MobileCrashSessionJournal(storage, { - now: () => FIRST_SESSION_AT, - createSessionId: () => 'session-one' - }) + const first = new MobileCrashSessionJournal(storage, { now: () => FIRST_SESSION_AT }) await first.start() await first.recordRoute(['h', 'private-host-id', 'session', 'private-worktree-id']) @@ -37,10 +34,7 @@ describe('mobile crash session journal', () => { ) // No background transition: the first process disappears with its marker open. - const second = new MobileCrashSessionJournal(storage, { - now: () => SECOND_SESSION_AT, - createSessionId: () => 'session-two' - }) + const second = new MobileCrashSessionJournal(storage, { now: () => SECOND_SESSION_AT }) const previous = await second.start() const report = await second.buildReport({ version: '0.0.29', platform: 'ios 26.5' }) const persisted = storage.values.get(MOBILE_CRASH_SESSION_STORAGE_KEY) ?? '' @@ -50,6 +44,7 @@ describe('mobile crash session journal', () => { 'route_changed', 'render_error_contained' ]) + expect(previous?.endedAbnormally).toBe(true) expect(report).toContain('Previous session ended abnormally') expect(report).toContain('h > [dynamic] > session > [dynamic]') expect(report).toContain('errorFingerprint') @@ -64,14 +59,12 @@ describe('mobile crash session journal', () => { expect(persisted).not.toContain('do-not-store') expect(persisted).not.toContain('/Users/example/private-repo') expect(persisted.length).toBeLessThanOrEqual(MAX_MOBILE_CRASH_DIAGNOSTICS_CHARS) + expect(JSON.parse(persisted).activeSession).not.toHaveProperty('id') }) it('sanitizes every line of a multi-line error message before persistence and reporting', async () => { const storage = new MemoryStorage() - const journal = new MobileCrashSessionJournal(storage, { - now: () => FIRST_SESSION_AT, - createSessionId: () => 'session-one' - }) + const journal = new MobileCrashSessionJournal(storage, { now: () => FIRST_SESSION_AT }) const error = new Error( [ 'Failed to render prompt: SECRET_TOKEN=first-secret', @@ -110,43 +103,35 @@ describe('mobile crash session journal', () => { it('retains a contained render failure after a normal background handoff', async () => { const storage = new MemoryStorage() - const first = new MobileCrashSessionJournal(storage, { - now: () => FIRST_SESSION_AT, - createSessionId: () => 'session-one' - }) + const first = new MobileCrashSessionJournal(storage, { now: () => FIRST_SESSION_AT }) await first.start() await first.recordRenderError(new Error('contained render failure')) await first.recordAppState('background') - const second = new MobileCrashSessionJournal(storage, { - now: () => SECOND_SESSION_AT, - createSessionId: () => 'session-two' - }) + const second = new MobileCrashSessionJournal(storage, { now: () => SECOND_SESSION_AT }) await expect(second.start()).resolves.toMatchObject({ + endedAbnormally: false, breadcrumbs: expect.arrayContaining([ expect.objectContaining({ name: 'render_error_contained' }) ]) }) await expect( second.buildReport({ version: '0.0.29', platform: 'android 15' }) - ).resolves.toContain('render_error_contained') + ).resolves.toContain('Previous session recovered from a render error') + await expect( + second.buildReport({ version: '0.0.29', platform: 'android 15' }) + ).resolves.not.toContain('Previous session ended abnormally') }) it('does not classify a session backgrounded cleanly as a crash', async () => { const storage = new MemoryStorage() - const first = new MobileCrashSessionJournal(storage, { - now: () => FIRST_SESSION_AT, - createSessionId: () => 'session-one' - }) + const first = new MobileCrashSessionJournal(storage, { now: () => FIRST_SESSION_AT }) await first.start() await first.recordRoute(['settings']) await first.recordAppState('background') - const second = new MobileCrashSessionJournal(storage, { - now: () => SECOND_SESSION_AT, - createSessionId: () => 'session-two' - }) + const second = new MobileCrashSessionJournal(storage, { now: () => SECOND_SESSION_AT }) await expect(second.start()).resolves.toBeNull() await expect(second.getUndismissedLatestAbnormalSession()).resolves.toBeNull() @@ -157,16 +142,10 @@ describe('mobile crash session journal', () => { it('keeps a report dismissed across a clean relaunch without hiding the next crash', async () => { const storage = new MemoryStorage() - const first = new MobileCrashSessionJournal(storage, { - now: () => FIRST_SESSION_AT, - createSessionId: () => 'session-one' - }) + const first = new MobileCrashSessionJournal(storage, { now: () => FIRST_SESSION_AT }) await first.start() - const second = new MobileCrashSessionJournal(storage, { - now: () => SECOND_SESSION_AT, - createSessionId: () => 'session-two' - }) + const second = new MobileCrashSessionJournal(storage, { now: () => SECOND_SESSION_AT }) const previous = await second.start() expect(previous).not.toBeNull() await expect(second.getUndismissedLatestAbnormalSession()).resolves.toEqual(previous) @@ -175,8 +154,7 @@ describe('mobile crash session journal', () => { await second.recordAppState('background') const third = new MobileCrashSessionJournal(storage, { - now: () => SECOND_SESSION_AT + 30_000, - createSessionId: () => 'session-three' + now: () => SECOND_SESSION_AT + 30_000 }) await third.start() await expect(third.getUndismissedLatestAbnormalSession()).resolves.toBeNull() @@ -184,8 +162,7 @@ describe('mobile crash session journal', () => { await third.recordRenderError(new Error('a different crash')) await third.recordAppState('background') const fourth = new MobileCrashSessionJournal(storage, { - now: () => SECOND_SESSION_AT + 60_000, - createSessionId: () => 'session-four' + now: () => SECOND_SESSION_AT + 60_000 }) await fourth.start() await expect(fourth.getUndismissedLatestAbnormalSession()).resolves.toMatchObject({ @@ -195,19 +172,13 @@ describe('mobile crash session journal', () => { it('reopens the marker when a backgrounded process becomes active again', async () => { const storage = new MemoryStorage() - const first = new MobileCrashSessionJournal(storage, { - now: () => FIRST_SESSION_AT, - createSessionId: () => 'session-one' - }) + const first = new MobileCrashSessionJournal(storage, { now: () => FIRST_SESSION_AT }) await first.start() await first.recordAppState('inactive') await first.recordAppState('background') await first.recordAppState('active') - const second = new MobileCrashSessionJournal(storage, { - now: () => SECOND_SESSION_AT, - createSessionId: () => 'session-two' - }) + const second = new MobileCrashSessionJournal(storage, { now: () => SECOND_SESSION_AT }) await expect(second.start()).resolves.toMatchObject({ breadcrumbs: [ @@ -221,10 +192,7 @@ describe('mobile crash session journal', () => { it('caps the persisted breadcrumb ring and payload', async () => { const storage = new MemoryStorage() - const journal = new MobileCrashSessionJournal(storage, { - now: () => FIRST_SESSION_AT, - createSessionId: () => 'session-one' - }) + const journal = new MobileCrashSessionJournal(storage, { now: () => FIRST_SESSION_AT }) await journal.start() for (let index = 0; index < 80; index += 1) { diff --git a/mobile/src/diagnostics/mobile-crash-session.ts b/mobile/src/diagnostics/mobile-crash-session.ts index 801d38f11c0..561bb426062 100644 --- a/mobile/src/diagnostics/mobile-crash-session.ts +++ b/mobile/src/diagnostics/mobile-crash-session.ts @@ -6,7 +6,7 @@ import { } from '../../../src/shared/crash-reporting' import { describeCrashError } from '../../../src/shared/crash-error-description' import { - MAX_MOBILE_CRASH_BREADCRUMBS, + MAX_STORED_MOBILE_CRASH_BREADCRUMBS, MOBILE_CRASH_SESSION_STORAGE_KEY, parseMobileCrashJournal, serializeMobileCrashJournal, @@ -26,7 +26,6 @@ export { type JournalOptions = { now?: () => number - createSessionId?: () => string } const SAFE_ROUTE_SEGMENTS = new Set([ @@ -64,7 +63,6 @@ const SAFE_ROUTE_SEGMENTS = new Set([ export class MobileCrashSessionJournal { private readonly storage: MobileCrashStorage private readonly now: () => number - private readonly createSessionId: () => string private queue: Promise = Promise.resolve() private startPromise: Promise | null = null private journal: PersistedMobileCrashJournal | null = null @@ -72,8 +70,6 @@ export class MobileCrashSessionJournal { constructor(storage: MobileCrashStorage, options: JournalOptions = {}) { this.storage = storage this.now = options.now ?? Date.now - this.createSessionId = - options.createSessionId ?? (() => `${this.now()}-${Math.random().toString(36).slice(2, 10)}`) } start(): Promise { @@ -92,7 +88,6 @@ export class MobileCrashSessionJournal { : null const openedAt = new Date(this.now()).toISOString() const activeSession: PersistedMobileCrashSession = { - id: this.createSessionId(), openedAt, marker: 'open', breadcrumbs: [createBreadcrumb(openedAt, 'session_started')] @@ -180,7 +175,7 @@ export class MobileCrashSessionJournal { ] const previous = this.journal?.latestAbnormalSession if (previous) { - lines.push('', 'Previous session ended abnormally') + lines.push('', previousSessionTitle(previous.endedAbnormally)) appendSessionLines(lines, previous) } else { lines.push('', 'No previous abnormal session recorded.') @@ -201,7 +196,7 @@ export class MobileCrashSessionJournal { createBreadcrumb(new Date(this.now()).toISOString(), name, data) ) this.journal.activeSession.breadcrumbs = this.journal.activeSession.breadcrumbs.slice( - -MAX_MOBILE_CRASH_BREADCRUMBS + -MAX_STORED_MOBILE_CRASH_BREADCRUMBS ) } @@ -238,6 +233,12 @@ export class MobileCrashSessionJournal { } } +export function previousSessionTitle(endedAbnormally: boolean): string { + return endedAbnormally + ? 'Previous session ended abnormally' + : 'Previous session recovered from a render error' +} + function createBreadcrumb( createdAt: string, name: string, @@ -266,7 +267,10 @@ function normalizeAppState(state: string): string { : 'unknown' } -function appendSessionLines(lines: string[], session: MobileCrashSessionSnapshot): void { +function appendSessionLines( + lines: string[], + session: Pick +): void { lines.push(`Opened: ${session.openedAt}`) lines.push(`Breadcrumbs (${session.breadcrumbs.length}, oldest first):`) for (const breadcrumb of session.breadcrumbs) { diff --git a/src/shared/crash-error-description.test.ts b/src/shared/crash-error-description.test.ts index 81d1ec9542e..69e8b8dbe0d 100644 --- a/src/shared/crash-error-description.test.ts +++ b/src/shared/crash-error-description.test.ts @@ -83,4 +83,18 @@ describe('describeCrashError', () => { expect(description.errorStack).toContain('at PrivateScreen ([redacted-path]') expect(description.componentStack).toContain('at PrivateScreen ([redacted-path]') }) + + it('fingerprints the sanitized message and retains the desktop stack budget', () => { + const first = new Error('secret=first-private-value') + const second = new Error('secret=second-private-value') + first.stack = 'x'.repeat(4_500) + + const firstDescription = describeCrashError(first) + const secondDescription = describeCrashError(second) + + expect(firstDescription.errorMessage).toBe('secret=[redacted]') + expect(secondDescription.errorMessage).toBe(firstDescription.errorMessage) + expect(secondDescription.errorFingerprint).toBe(firstDescription.errorFingerprint) + expect(firstDescription.errorStack).toBe(`${'x'.repeat(4_000)}...`) + }) }) diff --git a/src/shared/crash-error-description.ts b/src/shared/crash-error-description.ts index 4b4ae147cc7..f3644c07365 100644 --- a/src/shared/crash-error-description.ts +++ b/src/shared/crash-error-description.ts @@ -1,9 +1,11 @@ -import { sanitizeCrashReportString, type CrashReportBreadcrumbData } from './crash-reporting' +import { + MAX_CRASH_REPORT_STACK_CHARS, + sanitizeCrashReportString, + type CrashReportBreadcrumbData +} from './crash-reporting' export type CrashErrorDescription = CrashReportBreadcrumbData -const MAX_STACK_CHARS = 2_000 - export function describeCrashError( error: unknown, componentStack?: string | null @@ -23,14 +25,22 @@ export function describeCrashError( const name = rawName == null ? 'NonErrorThrown' : safeString(rawName, 'NonErrorThrown') const rawStack = readErrorProperty(candidate, 'stack') const stack = rawStack == null ? '' : safeString(rawStack, '') + const sanitizedMessage = sanitizeCrashReportString(message) return { errorName: sanitizeCrashReportString(name || 'NonErrorThrown', 80), - errorMessage: sanitizeCrashReportString(message), - errorFingerprint: fingerprint(message), - ...(stack ? { errorStack: sanitizeCrashReportString(stack, MAX_STACK_CHARS) } : {}), + errorMessage: sanitizedMessage, + errorFingerprint: fingerprint(sanitizedMessage), + ...(stack + ? { errorStack: sanitizeCrashReportString(stack, MAX_CRASH_REPORT_STACK_CHARS) } + : {}), ...(typeof componentStack === 'string' && componentStack.trim() - ? { componentStack: sanitizeCrashReportString(componentStack.trim(), MAX_STACK_CHARS) } + ? { + componentStack: sanitizeCrashReportString( + componentStack.trim(), + MAX_CRASH_REPORT_STACK_CHARS + ) + } : {}) } } diff --git a/src/shared/crash-report-redaction.ts b/src/shared/crash-report-redaction.ts index 00592da571c..265ee54caaf 100644 --- a/src/shared/crash-report-redaction.ts +++ b/src/shared/crash-report-redaction.ts @@ -5,9 +5,9 @@ import type { } from './crash-reporting' const MAX_STRING_DETAIL_LENGTH = 240 -const MAX_STACK_DETAIL_LENGTH = 4_000 +export const MAX_CRASH_REPORT_STACK_CHARS = 4_000 const MAX_BREADCRUMB_NAME_LENGTH = 80 -const MAX_BREADCRUMBS = 30 +const MAX_CRASH_REPORT_PAYLOAD_BREADCRUMBS = 30 const SECRET_PATTERNS = [ /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, @@ -66,7 +66,7 @@ export function sanitizeCrashReportDetails( /(?:^|_)(?:stack|component_stack|error_stack|minidump_check_message)$/i.test( normalizedKey ) - ? MAX_STACK_DETAIL_LENGTH + ? MAX_CRASH_REPORT_STACK_CHARS : MAX_STRING_DETAIL_LENGTH sanitized[key] = sanitizeCrashReportString(value, maxLength) } @@ -87,7 +87,7 @@ export function sanitizeCrashReportBreadcrumbs( } const sanitized = breadcrumbs - .slice(-MAX_BREADCRUMBS) + .slice(-MAX_CRASH_REPORT_PAYLOAD_BREADCRUMBS) .map((breadcrumb): CrashReportBreadcrumb | null => { if (!breadcrumb.name.trim() || !breadcrumb.createdAt.trim()) { return null diff --git a/src/shared/crash-reporting.ts b/src/shared/crash-reporting.ts index 194760afa81..a18b60e0abd 100644 --- a/src/shared/crash-reporting.ts +++ b/src/shared/crash-reporting.ts @@ -9,6 +9,7 @@ import type { CrashReportAttribution } from './react-update-depth-attribution' import { sanitizeCrashReportString } from './crash-report-redaction' export { + MAX_CRASH_REPORT_STACK_CHARS, sanitizeCrashReportBreadcrumbs, sanitizeCrashReportDetails, sanitizeCrashReportString