fix(mobile): address crash diagnostics sign-off notes

This commit is contained in:
Brennan Benson
2026-08-27 19:55:08 -07:00
parent 6cd351420a
commit 91de183434
14 changed files with 147 additions and 98 deletions
+10 -3
View File
@@ -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) {
+13 -9
View File
@@ -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)}...`)
})
})
+17 -7
View File
@@ -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
)
}
: {})
}
}
+4 -4
View File
@@ -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
+1
View File
@@ -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