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:
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user