From 24667ee4f4569813c95a87f5e6523de95748677f Mon Sep 17 00:00:00 2001 From: Dennis V <2119348+dzianisv@users.noreply.github.com> Date: Sat, 23 May 2026 09:00:26 +0000 Subject: [PATCH] feat(crash): comprehensive crash + error reporting for v0.2.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.json | 2 +- app/(tabs)/settings.tsx | 4 +- app/_layout.tsx | 24 ++-- docs/prd.md | 121 +++++++++++++++++++ docs/tdd.md | 183 +++++++++++++++++++++++++++++ src/components/ErrorBoundary.tsx | 122 +++++++++++++++++++ src/lib/diagnostics.ts | 31 +++++ src/lib/sentry.ts | 193 +++++++++++++++++++++++++++++-- src/stores/connections.ts | 6 + src/stores/events.ts | 9 ++ src/stores/sessions.ts | 2 + 11 files changed, 676 insertions(+), 21 deletions(-) create mode 100644 docs/prd.md create mode 100644 docs/tdd.md create mode 100644 src/components/ErrorBoundary.tsx diff --git a/app.json b/app.json index e86246d..343e639 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "OpenCode", "slug": "opencode-mobile", - "version": "0.2.2", + "version": "0.2.3", "orientation": "portrait", "scheme": "opencode", "userInterfaceStyle": "automatic", diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 9bcebb3..af68f8b 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -94,7 +94,9 @@ export default function SettingsScreen() { // Lazy-check OS permission for status display if (osGranted === null) { - notificationsGranted().then(setOsGranted) + notificationsGranted() + .then(setOsGranted) + .catch(() => setOsGranted(false)) } return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index d55b163..2d1c25f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -11,10 +11,12 @@ import { useEvents } from "../src/stores/events" import { useCatalog } from "../src/stores/catalog" import { useSettings } from "../src/stores/settings" import { AuthGate } from "../src/components/AuthGate" +import { ErrorBoundary } from "../src/components/ErrorBoundary" import * as notifications from "../src/lib/notifications" -import { initSentry, wrap } from "../src/lib/sentry" +import { addBreadcrumb, initSentry, wrap } from "../src/lib/sentry" initSentry() +addBreadcrumb({ category: "app.lifecycle", message: "app started" }) const queryClient = new QueryClient() @@ -76,10 +78,11 @@ function RootLayout() { } return ( - - - - + + + + + - - - - - + + + + + + ) } diff --git a/docs/prd.md b/docs/prd.md new file mode 100644 index 0000000..2fbb75f --- /dev/null +++ b/docs/prd.md @@ -0,0 +1,121 @@ +# OpenCode Mobile — Product Requirements Document (PRD) + +> Audience: product, design, support, and any engineer onboarding to the app. +> For implementation/architecture details, see [`tdd.md`](./tdd.md). + +## 1. Product Vision + +OpenCode Mobile is a phone-first companion for the [OpenCode](https://github.com/anomalyco/opencode) coding agent. It lets a developer keep an agent running on a powerful machine (laptop, cloud VM, Tailscale host) and stay in the loop from anywhere — review what the agent is doing, approve permissions, answer follow-up questions, and kick off new tasks — without needing to be at the keyboard. + +The phone is not where heavy coding happens. The phone is where **continuity** happens: catching a long-running task at the right moment, unblocking it, and resuming work later from a real workstation. + +## 2. Target Users + +| User | Why they use the app | +| ---- | -------------------- | +| Solo dev with a home dev box / cloud VM | Trigger or babysit long jobs while away from the laptop (commute, errands, meetings). | +| Devs working over Tailscale / Cloudflare Tunnel | Need a secure way to reach a private server from a phone. | +| Power users who run multiple agents | Want one place to monitor several servers and switch between them. | + +Out of scope: writing code on the phone full-time, replacing the desktop IDE, mass-collaboration features. + +## 3. Core User Stories + +### 3.1 Connect to a server +- As a user, I add a server by URL (`https://host:port`), optionally with HTTP basic-auth credentials, and the app stores them in the OS keychain. +- I can save multiple connections (local network, Tailscale, tunnel, cloud) and switch between them. +- If a connection fails, I get a **plain-English diagnosis** (not "Network request failed"), and a one-tap **Share report** that I can email or paste into Slack. + +### 3.2 Authenticate +- On launch, the app can require Face ID / Touch ID / device biometrics before showing any sessions (opt-in, per-device setting). +- Sending a message can require an additional biometric confirmation (opt-in, for high-stakes setups). + +### 3.3 Browse sessions +- The Sessions tab lists all sessions on the active connection, newest first, with title and a busy/idle indicator. +- Tapping a session opens its chat history; old messages load on scroll-up. + +### 3.4 Chat with the agent +- New messages stream in over SSE in real time, including reasoning, tool calls, and final answers. +- Markdown is rendered with code blocks that have a one-tap **Copy** button. +- A status pill ("Thinking…", "Running command…", "Searching codebase…") tells the user what the agent is doing right now. +- The user can send a new message; the app posts it fire-and-forget and waits for SSE events to confirm. +- The user can **abort** a running task. + +### 3.5 Approve permissions / answer questions +- When the agent requests permission to run a tool (e.g. `bash`, `edit`), the user gets an in-app prompt **and** an OS push notification. +- When the agent asks the user a question (multi-choice or free-form), the user gets the same dual prompt. +- The user can approve, deny, or answer from anywhere — including a notification tap that deep-links into the relevant session. + +### 3.6 Stay informed +- Notifications cover: task completed, permission asked, question asked, session error, prolonged disconnect. +- Each notification category can be toggled in Settings. +- A tap on any notification opens the right session. + +### 3.7 Recover from failures +- If the network drops, SSE reconnects with exponential backoff and jitter. +- If a render crash happens, the user sees a **diagnostic screen** (not a white screen / native red box) with the error, a **Share Report** button, and a **Try Again** button. +- Unexpected errors anywhere in the app are reported to Sentry automatically (when telemetry is enabled at build time); expected errors (timeouts, biometric-cancelled, etc.) are not — to keep signal high. + +## 4. Non-Functional Requirements + +- **Privacy.** URLs that contain credentials (basic-auth or `?token=…`) are scrubbed before they leave the device. No PII in default Sentry payloads. No content of chat messages is uploaded. +- **Offline-first diagnostics.** Even with no internet, the user can share a full report via the OS share sheet (clipboard + native share). +- **No required cloud dependency.** The app talks only to the user's own OpenCode server. Sentry is opt-in via build-time env var and a hard no-op when absent. +- **Battery / data.** No background polling; SSE only when the app is in the foreground or actively used. Reconnect backoff caps at 15s. +- **Security.** Credentials live in Keychain/Keystore via `expo-secure-store`. Optional biometric gate. Cleartext HTTP is allowed on Android only because users frequently hit `http://192.168.x.x:4096` on their own LAN. + +## 5. Developer Workflow + +> See [`tdd.md`](./tdd.md) for architecture details; this section covers the day-to-day workflow. + +### 5.1 Local dev +```bash +npm install +npx expo start # Metro bundler, scan QR with Expo Go +npx expo run:android # build + install dev client on emulator/device +npx expo run:ios # macOS only +``` + +### 5.2 Required env (optional but recommended) +| Var | Purpose | Where | +| --- | ------- | ----- | +| `EXPO_PUBLIC_SENTRY_DSN` | Enables crash reporting; absent → telemetry is a no-op | `.env` (local) + Bitwarden + GitHub Actions secret | +| `SENTRY_AUTH_TOKEN` | sentry-cli source-map upload during build | Bitwarden + GitHub Actions | +| `SENTRY_ORG` / `SENTRY_PROJECT` | sentry-cli release tagging | Bitwarden + GitHub Actions | + +All secrets are stored in Bitwarden under folder `opencode-mobile`. See `AGENTS.md` for retrieval commands. + +### 5.3 Type / lint +```bash +npm run typecheck # strict TypeScript, must pass before merge +``` + +### 5.4 Release +```bash +# 1. Bump app.json expo.version +# 2. Commit: "release: vX.Y.Z" +# 3. Tag and push: +git tag vX.Y.Z +git push origin main --tags +# CI (.github/workflows/build.yml) builds APK + GitHub Release +``` + +### 5.5 Observability +- **Sentry dashboard:** `sentry.io → vibetechnologies → opencode-mobile` +- **Alert email:** configure issue alert rules on the Sentry project to send to `dzianisvv@gmail.com` (or your distribution list). +- **In-app log buffer:** the last 200 log lines are always available in any shared diagnostic report; users can reproduce a bug and immediately share the trace. + +## 6. Success Metrics + +- **P0 — Crash-free sessions:** ≥ 99.5 % (Sentry sessions). Any release that drops below 99 % requires a hotfix. +- **P1 — Connection diagnostic helpfulness:** support requests for "Connection Failed" → 0 generic complaints; all reports come with a classification (`tls-error`, `timeout`, `no-internet`, etc.). +- **P2 — Reconnect recovery:** when the network blips for < 30 s, the user notices via a small banner but does not lose state. +- **P3 — Notification latency:** ≤ 5 s from agent event → push notification on the device. + +## 7. Out of Scope (v0.x) + +- Editing files on the phone. +- Running OpenCode locally on the device. +- Multi-user / shared sessions. +- Voice input beyond what the OS dictation keyboard provides. +- iPad / tablet-optimised layout (phone-first only; tablet works but is not designed for). diff --git a/docs/tdd.md b/docs/tdd.md new file mode 100644 index 0000000..3dca015 --- /dev/null +++ b/docs/tdd.md @@ -0,0 +1,183 @@ +# OpenCode Mobile — Technical Design Document (TDD) + +> Audience: engineers working on the app. For product-level context and user +> stories, see [`prd.md`](./prd.md). + +## 1. Stack + +| Layer | Choice | Notes | +| ----- | ------ | ----- | +| Runtime | React Native 0.81 + Hermes | New architecture / Fabric on by default via Expo SDK 54 | +| Framework | Expo SDK 54 + Expo Router 6 | File-based routing under `app/`; typed routes enabled | +| State | Zustand 5 | Plain stores, no provider tree. Avoids redux boilerplate. | +| Server data | TanStack Query 5 (cache) + custom `fetch`-based SDK | SSE is hand-rolled (RN's `EventSource` is unreliable). | +| Persistence | `expo-secure-store` (Keychain / Keystore) | All connection URLs + passwords. | +| Auth | `expo-local-authentication` | Optional biometric gate at startup and per-send. | +| Notifications | `expo-notifications` | Local + push; tap deep-links into session. | +| Crash reporting | `@sentry/react-native` 6.x | Opt-in via env DSN; full URL scrubbing in `beforeSend`. | + +## 2. Repo Layout + +``` +app/ Expo Router screens (file-based routes) +├── _layout.tsx Root layout: Sentry init, ErrorBoundary, providers, SSE wiring +├── (tabs)/ Bottom tabs: sessions, connections, settings +├── session/[id].tsx Chat screen +└── connection/ Add / edit connection screens + +src/ +├── components/ +│ ├── ErrorBoundary.tsx App-wide React error boundary with Share Report fallback +│ ├── AuthGate.tsx Biometric prompt before app contents render +│ ├── chat/ Message bubbles, tool call cards, etc. +│ └── markdown/ react-native-marked wrapper + custom code-block +├── lib/ +│ ├── sdk.ts HTTP + SSE client for the opencode server API +│ ├── sentry.ts Sentry wrapper + global JS/promise handlers + scrubbing +│ ├── diagnostics.ts Active connection probes + crash report builder + share +│ ├── logbuffer.ts 200-line ring buffer mirroring console output +│ ├── notifications.ts Notification setup + categories + dedupe +│ └── types.ts Re-exported model/connection types +└── stores/ + ├── auth.ts Biometric state + ├── connections.ts Server list, active client, project metadata + ├── sessions.ts Session list, messages, parts, optimistic sends + ├── events.ts SSE event loop, reconnect, status tracking + ├── settings.ts User preferences (notifications, biometrics) + └── catalog.ts Models / providers / commands catalog + +docs/ PRD + TDD (this file) +scripts/ E2E test rig (LLM-driven CUA Android smoke) +``` + +## 3. Data Flow + +``` ++--------------+ HTTP +-----------------+ +| React UI | ---------> | src/lib/sdk.ts | ++------+-------+ +--------+--------+ + ^ | + | | fetch / SSE + | v ++------+-------+ +-----------------+ +| Zustand | <--------- | opencode srv | +| stores | events +-----------------+ ++--------------+ +``` + +- The UI never calls `fetch` directly. It calls store actions, which call the SDK. +- SSE is owned by `stores/events.ts`. It dispatches events into `sessions.ts`, `connections.ts` and notification helpers — UI components only read derived state. +- Optimistic updates (e.g. `sendMessage`) are rolled back when the server's SSE truth disagrees. + +## 4. Connection Lifecycle + +1. `loadConnections()` reads stored connections from SecureStore at startup. +2. Active connection (if any) builds a `Client` via `createClient({ baseUrl, directory, auth })`. +3. `client.project.current()` and `client.path.get()` fill in project + server-home metadata; failures are non-fatal (server might be offline). +4. The `useEffect` in `_layout.tsx` keyed on `client` starts the SSE event loop the moment a client exists, and stops it when the user removes/changes the connection. +5. **Active diagnostics on failure.** When a connection is added or tested and fails, `src/lib/diagnostics.ts:probeConnection` runs three parallel probes (health, server-root, public-internet) and classifies the cause (`tls-error`, `timeout`, `no-internet`, `server-unreachable`, `health-failed`, `malformed-url`, `unknown`). The result drives both the UI alert and the Sentry capture. + +## 5. SSE & Reconnect + +Implemented in `src/stores/events.ts` (`connect`): + +- `AbortController` per connection attempt. +- Async iteration over `client.global.events(signal)`. +- A **stable-connection timer** (`STABLE_CONNECTION_MS = 10s`) resets the reconnect attempt counter once the stream has been alive long enough — this prevents a healthy stream from accumulating false-positive attempt history. +- Backoff: `[1s, 2s, 4s, 8s, 15s]` with ±25 % jitter, capped at 15 s. +- After `PROLONGED_DISCONNECT_MS = 30s` of being down, a notification fires once (deduped with a 60 s cooldown). +- Disconnect clears all in-flight sessionStatus / statusText / permissions / questions — SSE is the source of truth, never local cache. + +## 6. Error Handling & Crash Reporting + +We treat errors at three layers and route them differently: + +| Layer | Mechanism | Sent to Sentry? | +| ----- | --------- | --------------- | +| Expected operational errors (timeout, biometric cancel, network blip) | Caught locally, stored in store `error` field, surfaced as `Alert` / inline UI | **No** — keeps signal high | +| Connection failures (specifically connect/test/add) | `probeConnection()` → `captureDiagnostic()` with classification + probe context | **Yes**, always, when DSN present | +| Unexpected crashes | Global handlers + React `ErrorBoundary` | **Yes**, always, when DSN present | + +### 6.1 Sentry init (`src/lib/sentry.ts`) + +- DSN comes from `EXPO_PUBLIC_SENTRY_DSN`; if missing, init is a no-op and a breadcrumb-free build is shipped. +- `release` and `dist` are set from `app.json.expo.version` so Sentry can correlate stack traces to source-map artifacts. +- `tracesSampleRate: 0` — performance tracing intentionally off. +- `enableNative: true`, `enableNativeCrashHandling: true` — native (Android NDK / iOS Mach) crashes are captured. +- `maxBreadcrumbs: 100`. +- `beforeSend` and `beforeBreadcrumb` run every outgoing event/breadcrumb through `scrubEvent` / `scrubString` / `scrubObject`. These strip basic-auth (`//user:pw@`) and known query-secret keys (`token`, `access_token`, `api_key`, `key`, `password`, `pwd`, `auth`) from any URL anywhere in the payload (request URL, exception value, breadcrumb data). + +### 6.2 Global handlers + +Even though `@sentry/react-native` wires its own `ReactNativeErrorHandlers` by default, we install our own thin handlers on top so: + +1. The 200-line in-memory log buffer (`logbuffer.ts`) always sees the crash — so the *offline* "Share Report" path includes the stack trace even when telemetry is disabled. +2. Telemetry-disabled builds still leave a forensic trail. + +Handlers wrap (not replace) the previous handler: + +- `ErrorUtils.setGlobalHandler` — captures uncaught JS exceptions from the RN bridge. Tagged `crash.source=js-global`, `crash.fatal=true|false`. +- `globalThis.onunhandledrejection` — captures unhandled promise rejections. Tagged `crash.source=promise-rejection`. + +### 6.3 React Error Boundary (`src/components/ErrorBoundary.tsx`) + +A class component because `getDerivedStateFromError` / `componentDidCatch` have no hook equivalent. It: + +- Catches render-phase exceptions anywhere in the tree. +- Calls `captureException(err, { level: 'fatal', tags: { 'crash.source': 'react-boundary' }, extra: { componentStack } })`. +- Renders a dark-themed recovery screen with the error message, top 6 stack frames, top 6 component-stack frames, and two buttons: + - **Share Report** → `buildCrashReport(err, 'react-boundary')` → `shareReport()` (clipboard + native share sheet, fully offline). + - **Try Again** → resets boundary state, remounting children. +- Wraps the entire app inside `_layout.tsx`, outside `GestureHandlerRootView`. `Sentry.wrap(RootLayout)` remains as a second layer of safety net but is rarely the visible one. + +### 6.4 Breadcrumbs + +Selective, high-signal — not on every action. Crash reports need *just enough* context to reconstruct what the user was doing: + +| Site | Category | Message | +| ---- | -------- | ------- | +| `_layout.tsx` initial load | `app.lifecycle` | `app started` | +| `connections.setActiveConnection` | `connection` | `active connection set: ` / `…cleared` | +| `events.connect` | `sse` | `connecting` | +| `events.scheduleReconnect` | `sse` (warning) | `reconnect scheduled` (attempt, delay, reason) | +| `events.disconnect` | `sse` | `disconnected` | +| `sessions.selectSession` | `session` | `select` (sessionID, hasDirectory) | + +Adding more breadcrumbs is encouraged when triaging a real bug — just keep them out of hot loops. + +### 6.5 Diagnostic report (`src/lib/diagnostics.ts`) + +A `DiagnosticReport` is the canonical shape both connection-failure flows and crash flows produce. The same `shareReport()` function copies it to the clipboard and opens the native share sheet, so users see one consistent UI regardless of error source. + +`formatReport()` includes: classification, summary, target URL, per-probe results, device info, **and the full log-buffer dump**. That last bit is what makes user-shared reports actionable: we get a 200-line trace of what was happening immediately before the failure. + +### 6.6 URL / secret scrubbing + +Single source of truth: `scrubUrl(url)` in `sentry.ts`. Applied: + +- in `captureDiagnostic` before attaching the URL to the Sentry context; +- in `beforeSend` to recurse over every `event.request.url`, `event.message`, every `exception.value`, every breadcrumb; +- in `beforeBreadcrumb` for breadcrumb data added between events. + +A test for this would feed a basic-auth URL into a fake event and assert the scrubbed output. (Not yet written — TODO.) + +## 7. Versioning & Releases + +- Single source of version: `app.json` → `expo.version` (e.g. `0.2.3`). `package.json` version is unused. +- Git tag `v` triggers `.github/workflows/build.yml` which builds an APK and creates a GitHub Release. +- Sentry `release` is set to `opencode-mobile@` so source maps (uploaded by `sentry-cli` during build) line up with reported stack frames. + +## 8. Style Guide (from AGENTS.md) + +- Prefer `const` over `let`. +- Early returns over `else`. +- Single-word variable names where reasonable. +- Avoid `any` — use `unknown` and narrow, or a defined type. +- Avoid `try/catch` where you can use `.catch` at the call site; reserve it for boundary points (init, fetch wrappers, event loops). + +## 9. Known Gaps / Tech Debt + +- **No automated tests for the error pipeline.** Unit tests for `scrubUrl`, `buildCrashReport`, and the global handlers would catch regressions in the privacy guarantees. +- **No fallback UI for SSE disconnect.** Today the user sees the existing chat with a (small) banner; a more deliberate "Reconnecting…" affordance would help. +- **Silent `.catch(() => null)` in stores.** Intentional today (these are non-critical fetches), but should be revisited once we have proper severity tiers for breadcrumbs. +- **PRD analytics.** No usage analytics; only crash telemetry. A future opt-in product-analytics provider could close that loop without compromising the privacy posture. diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..52f28e8 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( + + + Something went wrong + + The app hit an unexpected error. It has been reported automatically. You can share a detailed report to help us + fix it faster. + + + + Error + + {message} + + + + {stack ? ( + + Stack + + {stack} + + + ) : null} + + {compStack ? ( + + Component + + {compStack} + + + ) : null} + + + + Share Report + + + Try Again + + + + + ) + } +} + +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" }, +}) diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index bd794d2..46fca8f 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -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 { diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index 470b057..9fef1a2 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -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) + } + 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(/\/\/[^@/]+@/, "//@") +// 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(/\/\/[^@/]+@/, "//@") + .replace(/([?&](?:token|access_token|api_key|key|password|pwd|auth)=)[^&#]*/gi, "$1") +} + +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): Record { + const out: Record = {} + 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) + else out[k] = v + } + return out +} + +function scrubEvent(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) + } + } + } + return event +} + +// --- Helpers exposed to the rest of the app ------------------------------ + +export type Breadcrumb = { + category: string + message: string + level?: "debug" | "info" | "warning" | "error" + data?: Record +} + +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; extra?: Record; 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 diff --git a/src/stores/connections.ts b/src/stores/connections.ts index cb96f42..ec87e3c 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -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((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) => { diff --git a/src/stores/events.ts b/src/stores/events.ts index bdbba46..5c1482a 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -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((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((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((set, get) => ({ disconnect: () => { console.log("[SSE] Disconnecting") + addBreadcrumb({ category: "sse", message: "disconnected" }) if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 1019129..7db656b 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -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 } { @@ -96,6 +97,7 @@ export const useSessions = create((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) => ({