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:
Dennis V
2026-05-23 09:00:26 +00:00
parent 678750d16f
commit 24667ee4f4
11 changed files with 676 additions and 21 deletions

View 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" },
})