From 30be6636c256795ff80807c0bf5c1b7049f7f438 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 22 May 2026 17:07:44 -0700 Subject: [PATCH] feat(diagnostics): active connect-failure triage + Sentry + in-app share (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(diagnostics): active connect-failure triage + Sentry + in-app share Replaces the opaque "Connection Failed" / "Network request failed" dead-end with on-device diagnostics that classify *why* a connect attempt failed. On failure (quick connect and edit-connection test), the app now: - runs parallel probes: target /global/health, target root, and a public 204 endpoint (internet reachability check) - classifies the cause: malformed-url, no-internet, server-unreachable, health-failed, tls-error, timeout - shows a plain-English summary + a "Share report" button that copies a full report (target URL, per-probe results w/ error.cause, device/app info, recent log ring-buffer) to the clipboard and opens the share sheet - captures the same structured context to Sentry (auto-upload), gated on EXPO_PUBLIC_SENTRY_DSN so dev/CI builds work without secrets New: src/lib/logbuffer.ts (ring buffer + logger), src/lib/diagnostics.ts (regex URL parse — Hermes URL is incomplete — probe + report + share), src/lib/sentry.ts (no-op-without-DSN wrapper, scrubs basic-auth from URLs). Wired Sentry.wrap around RootLayout and initSentry() at module load. Co-Authored-By: Claude Opus 4.7 (1M context) * ci(sentry): wire Sentry DSN + source-map upload env into build; bump to 0.2.2 Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 5 + HANDOFF.md | 16 ++ app.json | 4 +- app/_layout.tsx | 7 +- app/connection/[id].tsx | 25 ++- app/connection/add.tsx | 15 +- package-lock.json | 388 ++++++++++++++++++++++++++++++++++++ package.json | 1 + src/lib/diagnostics.ts | 222 +++++++++++++++++++++ src/lib/logbuffer.ts | 58 ++++++ src/lib/sentry.ts | 66 ++++++ 11 files changed, 796 insertions(+), 11 deletions(-) create mode 100644 src/lib/diagnostics.ts create mode 100644 src/lib/logbuffer.ts create mode 100644 src/lib/sentry.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 850a697..703e9c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,11 @@ on: jobs: build: runs-on: ubuntu-latest + env: + EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} steps: - uses: actions/checkout@v4 diff --git a/HANDOFF.md b/HANDOFF.md index 608934b..b6d97df 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -61,6 +61,22 @@ 3. **Add accessibility labels** — add `content-desc="Send"` to the send button in the app for better CUA reliability 4. **More scenarios** — settings toggle, reconnect after server restart, slash commands +## Connection Diagnostics (added in feat/connection-diagnostics-sentry) + +When a connect attempt fails, the app now runs an **active triage probe** instead of +showing a generic error. It classifies the cause: `malformed-url`, `no-internet`, +`server-unreachable`, `health-failed`, `tls-error`, `timeout`. The dialog shows a +plain-English summary plus a **Share report** button (copies a full report — +target URL, per-probe results, device/app info, recent logs — to clipboard + share sheet). + +- Code: `src/lib/diagnostics.ts` (probe + report), `src/lib/logbuffer.ts` (ring buffer), + `src/lib/sentry.ts` (auto-upload wrapper). +- **Sentry auto-upload** is opt-in via env var: set `EXPO_PUBLIC_SENTRY_DSN` at build time. + Without it, Sentry is a no-op and only the in-app Share report works (fully offline). + For source-map upload at build, also set `SENTRY_AUTH_TOKEN` / `SENTRY_ORG` / `SENTRY_PROJECT`. +- The probe runs on-device, so it reports the *phone's* real network reality — + unlike the co-located emulator, it can distinguish a true remote-peer tailnet failure. + ## Repo & Auth - Repo: `dzianisv/opencode-mobile` - GitHub auth: `source ~/.env.d/github-dzianisv.env` diff --git a/app.json b/app.json index 4af8a26..e86246d 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "OpenCode", "slug": "opencode-mobile", - "version": "1.0.0", + "version": "0.2.2", "orientation": "portrait", "scheme": "opencode", "userInterfaceStyle": "automatic", @@ -21,7 +21,7 @@ "bundler": "metro", "output": "static" }, - "plugins": ["expo-router", "expo-secure-store", "expo-local-authentication"], + "plugins": ["expo-router", "expo-secure-store", "expo-local-authentication", "@sentry/react-native/expo"], "experiments": { "typedRoutes": true, "reactCompiler": true diff --git a/app/_layout.tsx b/app/_layout.tsx index ca1a129..d55b163 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,10 +12,13 @@ import { useCatalog } from "../src/stores/catalog" import { useSettings } from "../src/stores/settings" import { AuthGate } from "../src/components/AuthGate" import * as notifications from "../src/lib/notifications" +import { initSentry, wrap } from "../src/lib/sentry" + +initSentry() const queryClient = new QueryClient() -export default function RootLayout() { +function RootLayout() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" @@ -118,3 +121,5 @@ export default function RootLayout() { ) } + +export default wrap(RootLayout) diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index db2d802..2839dad 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -14,6 +14,8 @@ import { router, useLocalSearchParams } from "expo-router" import { Ionicons } from "@expo/vector-icons" import { useConnections } from "../../src/stores/connections" import type { ConnectionType } from "../../src/lib/types" +import { probeConnection, shareReport } from "../../src/lib/diagnostics" +import { captureDiagnostic } from "../../src/lib/sentry" const CONNECTION_TYPES: Array<{ type: ConnectionType @@ -79,14 +81,25 @@ export default function EditConnectionScreen() { }, password || undefined, ) + + if (result.ok) { + setIsTesting(false) + Alert.alert("Success", "Connection successful!") + return + } + + // Failed: run active diagnostics, capture to Sentry, offer a shareable report. + const report = await probeConnection( + url.trim(), + username.trim() && password ? { username: username.trim(), password } : undefined, + ) + captureDiagnostic(report, result.error ? new Error(result.error) : undefined) setIsTesting(false) - Alert.alert( - result.ok ? "Success" : "Failed", - result.ok - ? "Connection successful!" - : `Could not connect to ${url.trim()}\n\n${result.error || "Check the URL and credentials."}`, - ) + Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [ + { text: "OK", style: "cancel" }, + { text: "Share report", onPress: () => shareReport(report) }, + ]) } const handleSave = async () => { diff --git a/app/connection/add.tsx b/app/connection/add.tsx index 19c612a..09ddab4 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -14,6 +14,8 @@ import { router } from "expo-router" import { Ionicons } from "@expo/vector-icons" import { useConnections } from "../../src/stores/connections" import type { ConnectionType } from "../../src/lib/types" +import { probeConnection, shareReport } from "../../src/lib/diagnostics" +import { captureDiagnostic } from "../../src/lib/sentry" export default function AddConnectionScreen() { const colorScheme = useColorScheme() @@ -90,11 +92,20 @@ export default function AddConnectionScreen() { setIsConnecting(false) router.back() } else { + // Failed: run active diagnostics, capture to Sentry, offer a shareable report. + const report = await probeConnection( + serverUrl, + username.trim() && password ? { username: username.trim(), password } : undefined, + ) + captureDiagnostic(report, result.error ? new Error(result.error) : undefined) setIsConnecting(false) Alert.alert( "Connection Failed", - `Could not connect to ${serverUrl}\n\n${result.error || "Unknown error"}\n\nMake sure:\n1. OpenCode is running: opencode serve --hostname 0.0.0.0\n2. You're on the same network (or Tailscale is connected)\n3. The address is correct (use the IP for tailnet if MagicDNS isn't enabled)`, - [{ text: "OK" }], + `${report.summary}\n\nTarget: ${serverUrl}\nError: ${result.error || "Unknown error"}`, + [ + { text: "OK", style: "cancel" }, + { text: "Share report", onPress: () => shareReport(report) }, + ], ) } } diff --git a/package-lock.json b/package-lock.json index 2e4855f..14e8668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@react-navigation/native": "^7.0.14", + "@sentry/react-native": "~6.14.0", "@tanstack/react-query": "^5.62.0", "expo": "^54.0.0", "expo-clipboard": "8.0.8", @@ -2783,6 +2784,345 @@ "nanoid": "^3.3.11" } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.54.0.tgz", + "integrity": "sha512-DKWCqb4YQosKn6aD45fhKyzhkdG7N6goGFDeyTaJFREJDFVDXiNDsYZu30nJ6BxMM7uQIaARhPAC5BXfoED3pQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.54.0.tgz", + "integrity": "sha512-nQqRacOXoElpE0L0ADxUUII0I3A94niqG9Z4Fmsw6057QvyrV/LvTiMQBop6r5qLjwMqK+T33iR4/NQI5RhsXQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.54.0.tgz", + "integrity": "sha512-8xuBe06IaYIGJec53wUC12tY2q4z2Z0RPS2s1sLtbA00EvK1YDGuXp96IDD+HB9mnDMrQ/jW5f97g9TvPsPQUg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.54.0.tgz", + "integrity": "sha512-K/On3OAUBeq/TV2n+1EvObKC+WMV9npVXpVyJqCCyn8HYMm8FUGzuxeajzm0mlW4wDTPCQor6mK9/IgOquUzCw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz", + "integrity": "sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.54.0.tgz", + "integrity": "sha512-BgUtvxFHin0fS0CmJVKTLXXZcke0Av729IVfi+2fJ4COX8HO7/HAP02RKaSQGmL2HmvWYTfNZ7529AnUtrM4Rg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.54.0", + "@sentry-internal/feedback": "8.54.0", + "@sentry-internal/replay": "8.54.0", + "@sentry-internal/replay-canvas": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.45.0.tgz", + "integrity": "sha512-4sWu7zgzgHAjIxIjXUA/66qgeEf5ZOlloO+/JaGD5qXNSW0G7KMTR6iYjReNKMgdBCTH6bUUt9qiuA+Ex9Masw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.45.0", + "@sentry/cli-linux-arm": "2.45.0", + "@sentry/cli-linux-arm64": "2.45.0", + "@sentry/cli-linux-i686": "2.45.0", + "@sentry/cli-linux-x64": "2.45.0", + "@sentry/cli-win32-arm64": "2.45.0", + "@sentry/cli-win32-i686": "2.45.0", + "@sentry/cli-win32-x64": "2.45.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.45.0.tgz", + "integrity": "sha512-p4Uxfv/L2fQdP3/wYnKVVz9gzZJf/1Xp9D+6raax/3Bu5y87yHYUqcdt98y/VAXQD4ofp2QgmhGUVPofvQNZmg==", + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.45.0.tgz", + "integrity": "sha512-6sEskFLlFKJ+e0MOYgIclBTUX5jYMyYhHIxXahEkI/4vx6JO0uvpyRAkUJRpJkRh/lPog0FM+tbP3so+VxB2qQ==", + "cpu": [ + "arm" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.45.0.tgz", + "integrity": "sha512-gUcLoEjzg7AIc4QQGEZwRHri+EHf3Gcms9zAR1VHiNF3/C/jL4WeDPJF2YiWAQt6EtH84tHiyhw1Ab/R8XFClg==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.45.0.tgz", + "integrity": "sha512-VmmOaEAzSW23YdGNdy/+oQjCNAMY+HmOGA77A25/ep/9AV7PQB6FI7xO5Y1PVvlkxZFJ23e373njSsEeg4uDZw==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.45.0.tgz", + "integrity": "sha512-a0Oj68mrb25a0WjX/ShZ6AAd4PPiuLcgyzQr7bl2+DvYxIOajwkGbR+CZFEhOVZcfhTnixKy/qIXEzApEPHPQg==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.45.0.tgz", + "integrity": "sha512-vn+CwS4p+52pQSLNPoi20ZOrQmv01ZgAmuMnjkh1oUZfTyBAwWLrAh6Cy4cztcN8DfL5dOWKQBo8DBKURE4ttg==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.45.0.tgz", + "integrity": "sha512-8mMoDdlwxtcdNIMtteMK7dbi7054jak8wKSHJ5yzMw8UmWxC5thc/gXBc1uPduiaI56VjoJV+phWHBKCD+6I4w==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.45.0.tgz", + "integrity": "sha512-ZvK9cIqFaq7vZ0jkHJ/xh5au6902Dr+AUxSk6L6vCL7JCe2p93KGL/4d8VFB5PD/P7Y9b+105G/e0QIFKzpeOw==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/react": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-8.54.0.tgz", + "integrity": "sha512-42T/fp8snYN19Fy/2P0Mwotu4gcdy+1Lx+uYCNcYP1o7wNGigJ7qb27sW7W34GyCCHjoCCfQgeOqDQsyY8LC9w==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "8.54.0", + "@sentry/core": "8.54.0", + "hoist-non-react-statics": "^3.3.2" + }, + "engines": { + "node": ">=14.18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/react-native": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-6.14.0.tgz", + "integrity": "sha512-BBqixN6oV6tCNp1ABXfzvD531zxj1fUAH0HDPvOR/jX0h9f9pYfxCyI64B+DoQbVZKFsg8nte0QIHkZDhRAW9A==", + "license": "MIT", + "dependencies": { + "@sentry/babel-plugin-component-annotate": "3.4.0", + "@sentry/browser": "8.54.0", + "@sentry/cli": "2.45.0", + "@sentry/core": "8.54.0", + "@sentry/react": "8.54.0", + "@sentry/types": "8.54.0", + "@sentry/utils": "8.54.0" + }, + "bin": { + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + }, + "peerDependencies": { + "expo": ">=49.0.0", + "react": ">=17.0.0", + "react-native": ">=0.65.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@sentry/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.54.0.tgz", + "integrity": "sha512-wztdtr7dOXQKi0iRvKc8XJhJ7HaAfOv8lGu0yqFOFwBZucO/SHnu87GOPi8mvrTiy1bentQO5l+zXWAaMvG4uw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-JL8UDjrsKxKclTdLXfuHfE7B3KbrAPEYP7tMyN/xiO2vsF6D84fjwYyalO0ZMtuFZE6vpSze8ZOLEh6hLnPYsw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -7114,6 +7454,26 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -7732,6 +8092,12 @@ "node": ">= 6" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9213,6 +9579,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -9723,6 +10095,16 @@ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -9737,6 +10119,12 @@ "node": ">=10" } }, + "node_modules/whatwg-url/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 25aff32..3efefa6 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@react-navigation/native": "^7.0.14", + "@sentry/react-native": "~6.14.0", "@tanstack/react-query": "^5.62.0", "expo": "^54.0.0", "expo-clipboard": "8.0.8", diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts new file mode 100644 index 0000000..bd794d2 --- /dev/null +++ b/src/lib/diagnostics.ts @@ -0,0 +1,222 @@ +// Active connection diagnostics: when a connect attempt fails, run a set of +// parallel probes that classify *why* it failed, instead of swallowing the +// opaque RN "Network request failed" string. +import { Platform, Share } from "react-native" +import * as Clipboard from "expo-clipboard" +import * as Device from "expo-device" +import appJson from "../../app.json" +import { log, formatLogLines } from "./logbuffer" + +const PROBE_TIMEOUT_MS = 8_000 +// Public 204 endpoints used purely as an "is the internet reachable at all" check. +const INTERNET_CHECK_URL = "https://www.gstatic.com/generate_204" + +export type Classification = + | "ok" + | "malformed-url" + | "no-internet" + | "server-unreachable" + | "health-failed" + | "tls-error" + | "timeout" + | "unknown" + +export interface ProbeAttempt { + name: string + target: string + ok: boolean + status?: number + durationMs: number + error?: string + errorCause?: string +} + +export interface DiagnosticReport { + classification: Classification + summary: string + url: string + scheme?: string + host?: string + port?: string + isHostname: boolean + attempts: ProbeAttempt[] + device: { + platform: string + osVersion: string + model: string + appVersion: string + } + timestamp: string +} + +interface ParsedUrl { + valid: boolean + scheme?: string + host?: string + port?: string + isHostname: boolean +} + +// Hermes' built-in URL is incomplete (hostname/port often unreliable), so parse +// with a regex instead of `new URL`. +function parseUrl(url: string): ParsedUrl { + const m = url.trim().match(/^(https?):\/\/([^/:?#]+)(?::(\d+))?/i) + if (!m) return { valid: false, isHostname: false } + const scheme = m[1].toLowerCase() + const host = m[2] + const port = m[3] || (scheme === "https" ? "443" : "80") + const isHostname = !/^\d{1,3}(\.\d{1,3}){3}$/.test(host) + return { valid: true, scheme, host, port, isHostname } +} + +async function timedFetch(name: string, target: string, init?: RequestInit): Promise { + const start = Date.now() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + try { + const res = await fetch(target, { ...init, signal: controller.signal }) + return { name, target, ok: true, status: res.status, durationMs: Date.now() - start } + } catch (error: unknown) { + const err = error as { name?: string; message?: string; cause?: unknown } + const aborted = err?.name === "AbortError" + return { + name, + target, + ok: false, + durationMs: Date.now() - start, + error: aborted ? `timeout after ${PROBE_TIMEOUT_MS}ms` : err?.message || String(error), + errorCause: err?.cause != null ? String((err.cause as { message?: string })?.message ?? err.cause) : undefined, + } + } finally { + clearTimeout(timer) + } +} + +function classify(parsed: ParsedUrl, health: ProbeAttempt, internet: ProbeAttempt, root: ProbeAttempt): { + classification: Classification + summary: string +} { + if (!parsed.valid) { + return { classification: "malformed-url", summary: "The server URL could not be parsed. Check for typos or extra characters." } + } + if (health.ok) { + return { classification: "ok", summary: "Health endpoint responded — connection actually works now." } + } + + const txt = `${health.error ?? ""} ${health.errorCause ?? ""}`.toLowerCase() + const isTls = /ssl|tls|certificate|trust|handshake/.test(txt) + const isTimeout = /timeout|timed out/.test(txt) + + if (isTls) { + return { classification: "tls-error", summary: "TLS/certificate problem. Try http:// instead of https://, or fix the server certificate." } + } + if (!internet.ok) { + return { classification: "no-internet", summary: "The device has no working internet/network at all (public check also failed). Check Wi-Fi/data and Tailscale (VPN) status." } + } + // Internet works, server does not. + if (root.ok) { + return { classification: "health-failed", summary: `Server is reachable but /global/health failed (HTTP ${health.status ?? "error"}). Likely wrong path, auth, or an old server version.` } + } + if (isTimeout) { + return { classification: "timeout", summary: "Connection to the server timed out (dropped, not refused). Likely a firewall, wrong port, or Tailscale ACL blocking the device." } + } + return { + classification: "server-unreachable", + summary: + `Internet works, but the server at ${parsed.host}:${parsed.port} is unreachable. ` + + (parsed.isHostname + ? "Hostname may not resolve from this device (MagicDNS off?). Try the raw Tailscale IP. " + : "") + + "Confirm the opencode server is running, the device is on the same tailnet, and the port is correct.", + } +} + +export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise { + const parsed = parseUrl(url) + log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed)) + + const headers: Record = {} + if (auth) headers["Authorization"] = `Basic ${btoa(`${auth.username}:${auth.password}`)}` + + let health: ProbeAttempt + let root: ProbeAttempt + let internet: ProbeAttempt + + if (parsed.valid) { + const base = `${parsed.scheme}://${parsed.host}:${parsed.port}` + ;[health, root, internet] = await Promise.all([ + timedFetch("health", `${base}/global/health`, { headers }), + timedFetch("server-root", `${base}/`, { headers }), + timedFetch("internet", INTERNET_CHECK_URL), + ]) + } else { + const skipped: ProbeAttempt = { name: "health", target: url, ok: false, durationMs: 0, error: "skipped: malformed url" } + health = skipped + root = { ...skipped, name: "server-root" } + internet = await timedFetch("internet", INTERNET_CHECK_URL) + } + + const { classification, summary } = classify(parsed, health, internet, root) + + const report: DiagnosticReport = { + classification, + summary, + url, + scheme: parsed.scheme, + host: parsed.host, + port: parsed.port, + isHostname: parsed.isHostname, + attempts: [health, root, internet], + 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(), + } + + log.info("diag", "probe result", classification, "-", summary) + return report +} + +export function formatReport(report: DiagnosticReport): string { + const lines: string[] = [] + lines.push("=== OpenCode Mobile — Connection Diagnostic ===") + lines.push(`Time: ${report.timestamp}`) + lines.push(`Result: ${report.classification.toUpperCase()}`) + lines.push(`Summary: ${report.summary}`) + lines.push("") + lines.push(`Target URL: ${report.url}`) + lines.push(` scheme=${report.scheme} host=${report.host} port=${report.port} hostname=${report.isHostname}`) + lines.push("") + lines.push("Probes:") + for (const a of report.attempts) { + const status = a.ok ? `OK ${a.status ?? ""}`.trim() : `FAIL ${a.error ?? ""}`.trim() + lines.push(` - ${a.name.padEnd(12)} ${a.target}`) + lines.push(` ${status} (${a.durationMs}ms)${a.errorCause ? ` cause=${a.errorCause}` : ""}`) + } + lines.push("") + lines.push("Device:") + lines.push(` ${report.device.platform} ${report.device.osVersion} | ${report.device.model} | app ${report.device.appVersion}`) + lines.push("") + lines.push("Recent logs:") + lines.push(formatLogLines()) + return lines.join("\n") +} + +// 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 { + const text = formatReport(report) + try { + await Clipboard.setStringAsync(text) + } catch { + // clipboard optional + } + try { + await Share.share({ title: "OpenCode connection diagnostic", message: text }) + } catch (e) { + log.warn("diag", "share failed", String(e)) + } +} diff --git a/src/lib/logbuffer.ts b/src/lib/logbuffer.ts new file mode 100644 index 0000000..6fa0cfe --- /dev/null +++ b/src/lib/logbuffer.ts @@ -0,0 +1,58 @@ +// In-memory ring buffer of recent log lines, for attaching to diagnostic reports. +// Also mirrors to console so logs still show in Metro / logcat. + +export type LogLevel = "debug" | "info" | "warn" | "error" + +export interface LogEntry { + ts: number + level: LogLevel + tag: string + message: string +} + +const MAX_ENTRIES = 200 +const buffer: LogEntry[] = [] + +function push(level: LogLevel, tag: string, parts: unknown[]) { + const message = parts + .map((p) => { + if (typeof p === "string") return p + try { + return JSON.stringify(p) + } catch { + return String(p) + } + }) + .join(" ") + buffer.push({ ts: Date.now(), level, tag, message }) + if (buffer.length > MAX_ENTRIES) buffer.shift() + + const line = `[${tag}] ${message}` + if (level === "error") console.error(line) + else if (level === "warn") console.warn(line) + else console.log(line) +} + +export const log = { + debug: (tag: string, ...parts: unknown[]) => push("debug", tag, parts), + info: (tag: string, ...parts: unknown[]) => push("info", tag, parts), + warn: (tag: string, ...parts: unknown[]) => push("warn", tag, parts), + error: (tag: string, ...parts: unknown[]) => push("error", tag, parts), +} + +export function getLogEntries(): LogEntry[] { + return [...buffer] +} + +export function formatLogLines(entries: LogEntry[] = buffer): string { + return entries + .map((e) => { + const t = new Date(e.ts).toISOString().slice(11, 23) + return `${t} ${e.level.toUpperCase().padEnd(5)} [${e.tag}] ${e.message}` + }) + .join("\n") +} + +export function clearLog() { + buffer.length = 0 +} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts new file mode 100644 index 0000000..470b057 --- /dev/null +++ b/src/lib/sentry.ts @@ -0,0 +1,66 @@ +// 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). +import * as Sentry from "@sentry/react-native" +import { log } from "./logbuffer" +import type { DiagnosticReport } from "./diagnostics" + +const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN +let enabled = false + +export function initSentry() { + if (!DSN) { + log.info("sentry", "no DSN configured — telemetry disabled") + return + } + try { + Sentry.init({ + dsn: DSN, + // Capture breadcrumbs but keep performance tracing off by default. + tracesSampleRate: 0, + enableAutoSessionTracking: true, + // Don't send PII; connection URLs are attached explicitly + scrubbed below. + sendDefaultPii: false, + }) + enabled = true + log.info("sentry", "initialized") + } catch (e) { + log.warn("sentry", "init failed", String(e)) + } +} + +// Strip basic-auth credentials from a URL before it leaves the device. +function scrubUrl(url: string): string { + return url.replace(/\/\/[^@/]+@/, "//@") +} + +export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown) { + log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)") + if (!enabled) return + Sentry.withScope((scope) => { + scope.setTag("connect.classification", report.classification) + scope.setTag("connect.scheme", report.scheme ?? "n/a") + scope.setContext("connection", { + url: scrubUrl(report.url), + host: report.host, + port: report.port, + isHostname: report.isHostname, + summary: report.summary, + }) + scope.setContext("probes", { + attempts: report.attempts.map((a) => ({ + name: a.name, + ok: a.ok, + status: a.status, + durationMs: a.durationMs, + error: a.error, + cause: a.errorCause, + })), + }) + scope.setContext("device", report.device) + const err = rawError instanceof Error ? rawError : new Error(`connect ${report.classification}: ${report.summary}`) + Sentry.captureException(err) + }) +} + +export const wrap = Sentry.wrap