mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(mobile): address crash diagnostics sign-off notes
This commit is contained in:
@@ -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<DiagnosticFetchTimeout | null>(null)
|
||||
const [crashDiagnosticsCopied, setCrashDiagnosticsCopied] = useState(false)
|
||||
const [hasPreviousCrashSession, setHasPreviousCrashSession] = useState(false)
|
||||
const [previousCrashSession, setPreviousCrashSession] =
|
||||
useState<MobileCrashSessionSnapshot | null>(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 && <PreviousCrashSessionBanner style={styles.crashBanner} />}
|
||||
{previousCrashSession && (
|
||||
<PreviousCrashSessionBanner
|
||||
endedAbnormally={previousCrashSession.endedAbnormally}
|
||||
style={styles.crashBanner}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
}) {
|
||||
const copy = (
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>Previous session ended abnormally</Text>
|
||||
<Text style={styles.title}>{previousSessionTitle(endedAbnormally)}</Text>
|
||||
<Text style={styles.description}>
|
||||
Crash diagnostics are available to copy and share with support.
|
||||
</Text>
|
||||
|
||||
@@ -46,7 +46,11 @@ export function PreviousCrashSessionLaunchNotice() {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<PreviousCrashSessionBanner onDismiss={dismiss} onPress={openTroubleshooting} />
|
||||
<PreviousCrashSessionBanner
|
||||
endedAbnormally={previousCrash.endedAbnormally}
|
||||
onDismiss={dismiss}
|
||||
onPress={openTroubleshooting}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MobileCrashSessionSnapshot, 'endedAbnormally'> & {
|
||||
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<string, unknown>
|
||||
const snapshot = parseSnapshot(candidate)
|
||||
if (
|
||||
!snapshot ||
|
||||
typeof candidate.id !== 'string' ||
|
||||
(candidate.marker !== 'open' && candidate.marker !== 'closed')
|
||||
) {
|
||||
const candidate = value as Record<string, unknown> | 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<string, unknown> | 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<MobileCrashSessionSnapshot, 'endedAbnormally'> | 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]) ?? []
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void> = Promise.resolve()
|
||||
private startPromise: Promise<MobileCrashSessionSnapshot | null> | 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<MobileCrashSessionSnapshot | null> {
|
||||
@@ -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<MobileCrashSessionSnapshot, 'openedAt' | 'breadcrumbs'>
|
||||
): void {
|
||||
lines.push(`Opened: ${session.openedAt}`)
|
||||
lines.push(`Breadcrumbs (${session.breadcrumbs.length}, oldest first):`)
|
||||
for (const breadcrumb of session.breadcrumbs) {
|
||||
|
||||
@@ -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)}...`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user