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

View File

@@ -205,6 +205,37 @@ export function formatReport(report: DiagnosticReport): string {
return lines.join("\n")
}
// Build a synthetic DiagnosticReport from an unexpected runtime error (e.g.
// a React render crash or an unhandled promise rejection). Reuses the same
// formatting / share pipeline as connection diagnostics so users only ever
// see one kind of "Share report" UI.
export function buildCrashReport(error: unknown, source: "react-boundary" | "global" = "global"): DiagnosticReport {
const err = error instanceof Error ? error : new Error(typeof error === "string" ? error : JSON.stringify(error))
const stackHead = (err.stack ?? "").split("\n").slice(0, 3).join(" | ")
const attempt: ProbeAttempt = {
name: source === "react-boundary" ? "react-render" : "runtime",
target: "app",
ok: false,
durationMs: 0,
error: err.message,
errorCause: stackHead || undefined,
}
return {
classification: "unknown",
summary: `App crashed (${source}): ${err.message}`,
url: "",
isHostname: false,
attempts: [attempt],
device: {
platform: Platform.OS,
osVersion: String(Platform.Version),
model: Device.modelName || "unknown",
appVersion: (appJson as { expo?: { version?: string } }).expo?.version || "unknown",
},
timestamp: new Date().toISOString(),
}
}
// Copy the report to the clipboard and open the native share sheet.
// Works fully offline (unlike the Sentry auto-upload).
export async function shareReport(report: DiagnosticReport): Promise<void> {

View File

@@ -1,37 +1,207 @@
// Thin Sentry wrapper. No-ops cleanly when no DSN is configured so dev/CI
// builds work without secrets. DSN comes from EXPO_PUBLIC_SENTRY_DSN
// (Expo inlines EXPO_PUBLIC_* at build time).
// Centralised Sentry wrapper. The goals here:
// 1. Capture every *unexpected* error: React render crashes, uncaught JS
// exceptions from the RN bridge, unhandled promise rejections, native
// crashes (handled by the Sentry RN SDK automatically).
// 2. Stay a strict no-op when no DSN is configured so dev/CI builds need no
// secrets and offline behaviour is unchanged.
// 3. Scrub URLs (basic-auth + query string) from every outgoing event so
// server addresses or tokens never leak to Sentry.
// 4. Provide small `addBreadcrumb` / `captureException` helpers so call sites
// get rich context without importing the Sentry SDK directly.
import * as Sentry from "@sentry/react-native"
import appJson from "../../app.json"
import { log } from "./logbuffer"
import type { DiagnosticReport } from "./diagnostics"
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown"
let enabled = false
export function initSentry() {
if (!DSN) {
log.info("sentry", "no DSN configured — telemetry disabled")
installGlobalHandlers(false)
return
}
try {
Sentry.init({
dsn: DSN,
// Capture breadcrumbs but keep performance tracing off by default.
release: `opencode-mobile@${APP_VERSION}`,
dist: APP_VERSION,
// Performance tracing off by default; only error + crash capture.
tracesSampleRate: 0,
enableAutoSessionTracking: true,
// Don't send PII; connection URLs are attached explicitly + scrubbed below.
// Don't ship default PII (IP, cookies). We attach what we want explicitly.
sendDefaultPii: false,
// Auto-capture uncaught JS exceptions AND unhandled promise rejections.
// The SDK enables these by default but we keep them on explicitly so a
// future config refactor can't silently drop coverage.
enableNative: true,
enableNativeCrashHandling: true,
enableAutoPerformanceTracing: false,
attachStacktrace: true,
maxBreadcrumbs: 100,
// Final pre-send scrub: strip URLs everywhere they could appear.
beforeSend(event) {
return scrubEvent(event)
},
beforeBreadcrumb(crumb) {
if (crumb.data && typeof crumb.data === "object") {
crumb.data = scrubObject(crumb.data as Record<string, unknown>)
}
if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message)
return crumb
},
})
enabled = true
log.info("sentry", "initialized")
Sentry.setTag("app.version", APP_VERSION)
log.info("sentry", "initialized", `release=opencode-mobile@${APP_VERSION}`)
} catch (e) {
log.warn("sentry", "init failed", String(e))
}
installGlobalHandlers(enabled)
}
// Strip basic-auth credentials from a URL before it leaves the device.
function scrubUrl(url: string): string {
return url.replace(/\/\/[^@/]+@/, "//<redacted>@")
// Install belt-and-braces global handlers. The Sentry RN SDK already wires
// these via its ReactNativeErrorHandlers integration, but we layer our own on
// top so:
// * Errors still land in the in-memory log buffer (and therefore in any
// shared diagnostic report) even when Sentry is disabled.
// * Telemetry-disabled builds still leave a breadcrumb that something blew
// up, which is invaluable when triaging a user-shared report offline.
function installGlobalHandlers(sentryEnabled: boolean) {
type GlobalErrorUtils = {
getGlobalHandler?: () => (err: unknown, isFatal?: boolean) => void
setGlobalHandler?: (handler: (err: unknown, isFatal?: boolean) => void) => void
}
const errorUtils = (globalThis as unknown as { ErrorUtils?: GlobalErrorUtils }).ErrorUtils
if (errorUtils?.setGlobalHandler && errorUtils?.getGlobalHandler) {
const previous = errorUtils.getGlobalHandler()
errorUtils.setGlobalHandler((err: unknown, isFatal?: boolean) => {
const error = toError(err)
log.error("crash", isFatal ? "FATAL" : "non-fatal", error.message, error.stack ?? "")
if (sentryEnabled) {
Sentry.captureException(error, (scope) => {
scope.setLevel(isFatal ? "fatal" : "error")
scope.setTag("crash.source", "js-global")
scope.setTag("crash.fatal", String(Boolean(isFatal)))
return scope
})
}
previous?.(err, isFatal)
})
}
// Hermes/RN expose `onunhandledrejection` on the global object.
type GlobalRejection = {
onunhandledrejection?: (event: { reason?: unknown; promise?: unknown }) => void
}
const g = globalThis as unknown as GlobalRejection
const prevRej = g.onunhandledrejection
g.onunhandledrejection = (event) => {
const error = toError(event?.reason)
log.error("crash", "unhandled-rejection", error.message, error.stack ?? "")
if (sentryEnabled) {
Sentry.captureException(error, (scope) => {
scope.setLevel("error")
scope.setTag("crash.source", "promise-rejection")
return scope
})
}
prevRej?.(event)
}
}
function toError(value: unknown): Error {
if (value instanceof Error) return value
if (typeof value === "string") return new Error(value)
try {
return new Error(JSON.stringify(value))
} catch {
return new Error(String(value))
}
}
// --- Scrubbing -----------------------------------------------------------
// Strip basic-auth credentials and any `?token=` style query secrets so URLs
// can be safely sent or logged.
export function scrubUrl(url: string): string {
return url
.replace(/\/\/[^@/]+@/, "//<redacted>@")
.replace(/([?&](?:token|access_token|api_key|key|password|pwd|auth)=)[^&#]*/gi, "$1<redacted>")
}
function scrubString(s: string): string {
// Catch any embedded URL inside a free-text string (error messages often
// contain them, e.g. "fetch failed: https://user:pw@host/...").
return s.replace(/https?:\/\/\S+/g, (m) => scrubUrl(m))
}
function scrubObject(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(obj)) {
if (typeof v === "string") out[k] = scrubString(v)
else if (v && typeof v === "object" && !Array.isArray(v)) out[k] = scrubObject(v as Record<string, unknown>)
else out[k] = v
}
return out
}
function scrubEvent<T extends Sentry.Event>(event: T): T {
if (event.request?.url) event.request.url = scrubUrl(event.request.url)
if (event.message) event.message = scrubString(event.message)
if (event.exception?.values) {
for (const ex of event.exception.values) {
if (ex.value) ex.value = scrubString(ex.value)
}
}
if (event.breadcrumbs) {
for (const crumb of event.breadcrumbs) {
if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message)
if (crumb.data && typeof crumb.data === "object") {
crumb.data = scrubObject(crumb.data as Record<string, unknown>)
}
}
}
return event
}
// --- Helpers exposed to the rest of the app ------------------------------
export type Breadcrumb = {
category: string
message: string
level?: "debug" | "info" | "warning" | "error"
data?: Record<string, unknown>
}
export function addBreadcrumb(crumb: Breadcrumb) {
if (!enabled) return
Sentry.addBreadcrumb({
category: crumb.category,
message: crumb.message,
level: crumb.level ?? "info",
data: crumb.data,
timestamp: Date.now() / 1000,
})
}
export function captureException(
err: unknown,
context?: { tags?: Record<string, string>; extra?: Record<string, unknown>; level?: Sentry.SeverityLevel },
) {
const error = toError(err)
log.error("sentry", "captureException", error.message)
if (!enabled) return
Sentry.withScope((scope) => {
if (context?.level) scope.setLevel(context.level)
if (context?.tags) for (const [k, v] of Object.entries(context.tags)) scope.setTag(k, v)
if (context?.extra) for (const [k, v] of Object.entries(context.extra)) scope.setExtra(k, v)
Sentry.captureException(error)
})
}
export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown) {
@@ -63,4 +233,9 @@ export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown)
})
}
// React error boundaries are implemented as our own class component
// (see src/components/ErrorBoundary.tsx) so we can render a useful
// "Share diagnostic" fallback. We still expose Sentry.wrap as `wrap`
// for callers that just want auto-capture without a custom fallback.
export const wrap = Sentry.wrap
export const sentryEnabled = () => enabled

View File

@@ -2,6 +2,7 @@ import { create } from "zustand"
import * as SecureStore from "expo-secure-store"
import type { ServerConnection, ConnectionType } from "../lib/types"
import { createClient, type Client, type Project } from "../lib/sdk"
import { addBreadcrumb } from "../lib/sentry"
const CONNECTIONS_KEY = "opencode_connections"
const PASSWORDS_PREFIX = "opencode_password_"
@@ -216,6 +217,11 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
}
set({ connections, activeConnection: active, client, clientBase: base, currentProject: project, serverHome: home })
addBreadcrumb({
category: "connection",
message: active ? `active connection set: ${active.type}` : "active connection cleared",
data: { id: active?.id, type: active?.type, hasProject: Boolean(project) },
})
},
testConnection: async (connection, password) => {

View File

@@ -2,6 +2,7 @@ import { create } from "zustand"
import { useConnections } from "./connections"
import { useSessions } from "./sessions"
import { send as notify } from "../lib/notifications"
import { addBreadcrumb } from "../lib/sentry"
import type { Client, Part, Session, Message } from "../lib/sdk"
// Session status from the server
@@ -116,6 +117,7 @@ export const useEvents = create<EventsState>((set, get) => ({
const currentController = controller
set({ connected: true })
console.log("[SSE] Connecting to event stream...")
addBreadcrumb({ category: "sse", message: "connecting" })
// Run in background
;(async () => {
@@ -149,6 +151,12 @@ export const useEvents = create<EventsState>((set, get) => ({
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempts - 1, RECONNECT_DELAYS_MS.length - 1)]
const jitteredDelay = Math.min(15_000, Math.round(baseDelay * (0.75 + Math.random() * 0.5)))
console.warn(`[SSE] Connection lost, reconnecting in ${jitteredDelay}ms:`, reason)
addBreadcrumb({
category: "sse",
level: "warning",
message: "reconnect scheduled",
data: { attempt: reconnectAttempts, delayMs: jitteredDelay, reason: String(reason).slice(0, 200) },
})
reconnectTimer = setTimeout(() => {
reconnectTimer = null
get().connect()
@@ -352,6 +360,7 @@ export const useEvents = create<EventsState>((set, get) => ({
disconnect: () => {
console.log("[SSE] Disconnecting")
addBreadcrumb({ category: "sse", message: "disconnected" })
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null

View File

@@ -2,6 +2,7 @@ import { create } from "zustand"
import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk"
import { useConnections } from "./connections"
import { useSettings } from "./settings"
import { addBreadcrumb } from "../lib/sentry"
// Helper to convert API response to our internal format
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
@@ -96,6 +97,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
return
}
addBreadcrumb({ category: "session", message: "select", data: { sessionID, hasDirectory: Boolean(directory) } })
try {
// Reset optimistic sending — SSE sessionStatus is the source of truth
set((state) => ({