feat(crash): comprehensive crash + error reporting for v0.2.3
Adds full-stack crash capture so any unexpected failure — React render,
uncaught JS exception, unhandled promise rejection, or native — is
reported to Sentry with rich, scrubbed context. Expected operational
errors (timeouts, biometric cancel, etc.) stay local to preserve signal.
Changes:
- src/lib/sentry.ts: explicit native crash handlers, release/dist tags
from app.json, beforeSend/beforeBreadcrumb URL+secret scrubbing,
addBreadcrumb/captureException helpers, ErrorUtils + onunhandledrejection
wrappers that always feed the in-memory log buffer (so offline Share
Report includes the crash too).
- src/components/ErrorBoundary.tsx: new app-wide React boundary with a
dark recovery screen — error message, top stack/component frames,
Share Report (clipboard + native share sheet) and Try Again.
- src/lib/diagnostics.ts: buildCrashReport() reuses the existing
DiagnosticReport pipeline so crashes and connect failures share one
UI and one transport.
- _layout.tsx: wraps app in ErrorBoundary; emits app.lifecycle
breadcrumb at startup.
- stores/{connections,events,sessions}.ts: high-signal breadcrumbs at
connect, SSE connect/disconnect/reconnect, and session select.
- (tabs)/settings.tsx: fix unhandled promise on notificationsGranted().
- app.json: bump expo.version to 0.2.3.
- docs/prd.md, docs/tdd.md: new product + technical design docs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
122
src/components/ErrorBoundary.tsx
Normal file
122
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// App-level React error boundary. Catches render-time exceptions anywhere in
|
||||
// the component tree, reports them to Sentry with the React component stack,
|
||||
// and presents a recovery UI that lets the user share a diagnostic report
|
||||
// (logs + device info + stack) before retrying. The retry path remounts the
|
||||
// children, which is enough recovery for the vast majority of render bugs;
|
||||
// truly fatal cases will just re-throw and the user can share again.
|
||||
|
||||
import React from "react"
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"
|
||||
import { captureException } from "../lib/sentry"
|
||||
import { buildCrashReport, shareReport } from "../lib/diagnostics"
|
||||
import { log } from "../lib/logbuffer"
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
componentStack: string | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null, componentStack: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error, componentStack: null }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
log.error("boundary", "react render crash", error.message)
|
||||
captureException(error, {
|
||||
level: "fatal",
|
||||
tags: { "crash.source": "react-boundary" },
|
||||
extra: { componentStack: info.componentStack ?? "" },
|
||||
})
|
||||
this.setState({ componentStack: info.componentStack ?? null })
|
||||
}
|
||||
|
||||
handleShare = () => {
|
||||
const { error } = this.state
|
||||
if (!error) return
|
||||
const report = buildCrashReport(error, "react-boundary")
|
||||
shareReport(report).catch((e) => log.warn("boundary", "share failed", String(e)))
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ error: null, componentStack: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, componentStack } = this.state
|
||||
if (!error) return this.props.children
|
||||
|
||||
const message = error.message || "Unknown error"
|
||||
const stack = (error.stack ?? "").split("\n").slice(0, 6).join("\n")
|
||||
const compStack = (componentStack ?? "").split("\n").slice(0, 6).join("\n")
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Something went wrong</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
The app hit an unexpected error. It has been reported automatically. You can share a detailed report to help us
|
||||
fix it faster.
|
||||
</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardLabel}>Error</Text>
|
||||
<Text style={styles.cardBody} selectable>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{stack ? (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardLabel}>Stack</Text>
|
||||
<Text style={styles.code} selectable>
|
||||
{stack}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{compStack ? (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardLabel}>Component</Text>
|
||||
<Text style={styles.code} selectable>
|
||||
{compStack}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={[styles.button, styles.buttonPrimary]} onPress={this.handleShare}>
|
||||
<Text style={styles.buttonPrimaryText}>Share Report</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.button, styles.buttonSecondary]} onPress={this.handleRetry}>
|
||||
<Text style={styles.buttonSecondaryText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: "#0a0a0a" },
|
||||
content: { padding: 24, paddingTop: 80 },
|
||||
title: { color: "#ffffff", fontSize: 24, fontWeight: "700", marginBottom: 8 },
|
||||
subtitle: { color: "#a0a0a0", fontSize: 15, lineHeight: 21, marginBottom: 24 },
|
||||
card: { backgroundColor: "#1a1a1a", borderRadius: 12, padding: 16, marginBottom: 12 },
|
||||
cardLabel: { color: "#888", fontSize: 12, fontWeight: "600", textTransform: "uppercase", marginBottom: 6 },
|
||||
cardBody: { color: "#fff", fontSize: 15 },
|
||||
code: { color: "#cdd3da", fontSize: 12, fontFamily: "Courier" },
|
||||
actions: { marginTop: 16, flexDirection: "row", gap: 12 },
|
||||
button: { flex: 1, paddingVertical: 14, borderRadius: 10, alignItems: "center" },
|
||||
buttonPrimary: { backgroundColor: "#3b82f6" },
|
||||
buttonSecondary: { backgroundColor: "#2a2a2a" },
|
||||
buttonPrimaryText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
buttonSecondaryText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
})
|
||||
Reference in New Issue
Block a user