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:
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "OpenCode",
|
"name": "OpenCode",
|
||||||
"slug": "opencode-mobile",
|
"slug": "opencode-mobile",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "opencode",
|
"scheme": "opencode",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
// Lazy-check OS permission for status display
|
// Lazy-check OS permission for status display
|
||||||
if (osGranted === null) {
|
if (osGranted === null) {
|
||||||
notificationsGranted().then(setOsGranted)
|
notificationsGranted()
|
||||||
|
.then(setOsGranted)
|
||||||
|
.catch(() => setOsGranted(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import { useEvents } from "../src/stores/events"
|
|||||||
import { useCatalog } from "../src/stores/catalog"
|
import { useCatalog } from "../src/stores/catalog"
|
||||||
import { useSettings } from "../src/stores/settings"
|
import { useSettings } from "../src/stores/settings"
|
||||||
import { AuthGate } from "../src/components/AuthGate"
|
import { AuthGate } from "../src/components/AuthGate"
|
||||||
|
import { ErrorBoundary } from "../src/components/ErrorBoundary"
|
||||||
import * as notifications from "../src/lib/notifications"
|
import * as notifications from "../src/lib/notifications"
|
||||||
import { initSentry, wrap } from "../src/lib/sentry"
|
import { addBreadcrumb, initSentry, wrap } from "../src/lib/sentry"
|
||||||
|
|
||||||
initSentry()
|
initSentry()
|
||||||
|
addBreadcrumb({ category: "app.lifecycle", message: "app started" })
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
@@ -76,10 +78,11 @@ function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<ErrorBoundary>
|
||||||
<BottomSheetModalProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<BottomSheetModalProvider>
|
||||||
<AuthGate>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthGate>
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: {
|
headerStyle: {
|
||||||
@@ -114,11 +117,12 @@ function RootLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style={isDark ? "light" : "dark"} />
|
<StatusBar style={isDark ? "light" : "dark"} />
|
||||||
</AuthGate>
|
</AuthGate>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
docs/prd.md
Normal file
121
docs/prd.md
Normal file
@@ -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).
|
||||||
183
docs/tdd.md
Normal file
183
docs/tdd.md
Normal file
@@ -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: <type>` / `…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<version>` triggers `.github/workflows/build.yml` which builds an APK and creates a GitHub Release.
|
||||||
|
- Sentry `release` is set to `opencode-mobile@<version>` 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.
|
||||||
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" },
|
||||||
|
})
|
||||||
@@ -205,6 +205,37 @@ export function formatReport(report: DiagnosticReport): string {
|
|||||||
return lines.join("\n")
|
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.
|
// Copy the report to the clipboard and open the native share sheet.
|
||||||
// Works fully offline (unlike the Sentry auto-upload).
|
// Works fully offline (unlike the Sentry auto-upload).
|
||||||
export async function shareReport(report: DiagnosticReport): Promise<void> {
|
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
|
// Centralised Sentry wrapper. The goals here:
|
||||||
// builds work without secrets. DSN comes from EXPO_PUBLIC_SENTRY_DSN
|
// 1. Capture every *unexpected* error: React render crashes, uncaught JS
|
||||||
// (Expo inlines EXPO_PUBLIC_* at build time).
|
// 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 * as Sentry from "@sentry/react-native"
|
||||||
|
import appJson from "../../app.json"
|
||||||
import { log } from "./logbuffer"
|
import { log } from "./logbuffer"
|
||||||
import type { DiagnosticReport } from "./diagnostics"
|
import type { DiagnosticReport } from "./diagnostics"
|
||||||
|
|
||||||
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
|
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
|
||||||
|
const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown"
|
||||||
|
|
||||||
let enabled = false
|
let enabled = false
|
||||||
|
|
||||||
export function initSentry() {
|
export function initSentry() {
|
||||||
if (!DSN) {
|
if (!DSN) {
|
||||||
log.info("sentry", "no DSN configured — telemetry disabled")
|
log.info("sentry", "no DSN configured — telemetry disabled")
|
||||||
|
installGlobalHandlers(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: DSN,
|
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,
|
tracesSampleRate: 0,
|
||||||
enableAutoSessionTracking: true,
|
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,
|
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
|
enabled = true
|
||||||
log.info("sentry", "initialized")
|
Sentry.setTag("app.version", APP_VERSION)
|
||||||
|
log.info("sentry", "initialized", `release=opencode-mobile@${APP_VERSION}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn("sentry", "init failed", String(e))
|
log.warn("sentry", "init failed", String(e))
|
||||||
}
|
}
|
||||||
|
installGlobalHandlers(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip basic-auth credentials from a URL before it leaves the device.
|
// Install belt-and-braces global handlers. The Sentry RN SDK already wires
|
||||||
function scrubUrl(url: string): string {
|
// these via its ReactNativeErrorHandlers integration, but we layer our own on
|
||||||
return url.replace(/\/\/[^@/]+@/, "//<redacted>@")
|
// 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) {
|
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 wrap = Sentry.wrap
|
||||||
|
export const sentryEnabled = () => enabled
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from "zustand"
|
|||||||
import * as SecureStore from "expo-secure-store"
|
import * as SecureStore from "expo-secure-store"
|
||||||
import type { ServerConnection, ConnectionType } from "../lib/types"
|
import type { ServerConnection, ConnectionType } from "../lib/types"
|
||||||
import { createClient, type Client, type Project } from "../lib/sdk"
|
import { createClient, type Client, type Project } from "../lib/sdk"
|
||||||
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
|
|
||||||
const CONNECTIONS_KEY = "opencode_connections"
|
const CONNECTIONS_KEY = "opencode_connections"
|
||||||
const PASSWORDS_PREFIX = "opencode_password_"
|
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 })
|
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) => {
|
testConnection: async (connection, password) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from "zustand"
|
|||||||
import { useConnections } from "./connections"
|
import { useConnections } from "./connections"
|
||||||
import { useSessions } from "./sessions"
|
import { useSessions } from "./sessions"
|
||||||
import { send as notify } from "../lib/notifications"
|
import { send as notify } from "../lib/notifications"
|
||||||
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
import type { Client, Part, Session, Message } from "../lib/sdk"
|
import type { Client, Part, Session, Message } from "../lib/sdk"
|
||||||
|
|
||||||
// Session status from the server
|
// Session status from the server
|
||||||
@@ -116,6 +117,7 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
const currentController = controller
|
const currentController = controller
|
||||||
set({ connected: true })
|
set({ connected: true })
|
||||||
console.log("[SSE] Connecting to event stream...")
|
console.log("[SSE] Connecting to event stream...")
|
||||||
|
addBreadcrumb({ category: "sse", message: "connecting" })
|
||||||
|
|
||||||
// Run in background
|
// Run in background
|
||||||
;(async () => {
|
;(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 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)))
|
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)
|
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 = setTimeout(() => {
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
get().connect()
|
get().connect()
|
||||||
@@ -352,6 +360,7 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
|
|
||||||
disconnect: () => {
|
disconnect: () => {
|
||||||
console.log("[SSE] Disconnecting")
|
console.log("[SSE] Disconnecting")
|
||||||
|
addBreadcrumb({ category: "sse", message: "disconnected" })
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from "zustand"
|
|||||||
import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk"
|
import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk"
|
||||||
import { useConnections } from "./connections"
|
import { useConnections } from "./connections"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
|
|
||||||
// Helper to convert API response to our internal format
|
// Helper to convert API response to our internal format
|
||||||
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
|
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
|
||||||
@@ -96,6 +97,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addBreadcrumb({ category: "session", message: "select", data: { sessionID, hasDirectory: Boolean(directory) } })
|
||||||
try {
|
try {
|
||||||
// Reset optimistic sending — SSE sessionStatus is the source of truth
|
// Reset optimistic sending — SSE sessionStatus is the source of truth
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user