---
name: sentry-crash-reporting-4914
description: Sentry crash reporting across JS/Rust/Android/iOS (PR
metadata: 
  node_type: memory
  type: project
  originSessionId: e1238bc7-0b80-4036-b949-f9a2cf0045bc
---

Sentry crash/error reporting added in PR #4914 (`feat(sentry): add crash reporting for Android, iOS, desktop, and web`). Four layers, one build-time `SENTRY_DSN` (empty => every layer no-ops):

- **JS + Rust panics**: `tauri-plugin-sentry` 0.5 + `sentry` 0.42 registered in `lib.rs::run()` (guard held to end of `run()`); rustls transport (NOT native-tls, so it cross-compiles for mobile); browser SDK auto-injected. Minidump handler is desktop-only: `#[cfg(not(any(target_os = "ios", target_os = "android")))]`.
- **Android native**: `io.sentry:sentry-android:8.47.0` in `gen/android/app/build.gradle.kts` + manifest auto-init `<meta-data>` (`io.sentry.dsn` from `${sentryDsn}` placeholder, `io.sentry.environment`). Crashes+errors only (traces=0, no PII, no replay).
- **iOS native**: `sentry-cocoa` via SPM in `project.yml`.

Config: `sentry_config.rs` holds pure helpers (`sentry_dsn`, `environment_for_version`, `app_version`, `release_name`/`sentry_release`, `corrected_os_name`, `android_version_from_uname`, `is_ignored_browser_error`, `parse_webview_info`/`set_webview_info`/`webview_info`). Scope = crashes+errors only. Symbolication (source-map/ProGuard/dSYM upload) is STILL deferred — several 2026-07 crashes (READEST-2 render loop, READEST-9) could only be triaged to a function name, not a source line, for lack of source maps; upload them.

**The Rust-client `before_send` (in `lib.rs`) now does three things**, in order, for every event (Rust panics + browser events forwarded by tauri-plugin-sentry): (1) drop known-benign browser noise via `is_ignored_browser_error` (case-insensitive match on the benign View-Transition rejections: "transition was skipped" (hidden tab READEST-7 + superseded-nav READEST-F) and "aborted because of invalid state" (READEST-G); a transition *timeout* is deliberately KEPT — real perf signal); (2) rewrite the Android OS name/version (see below); (3) tag `webview.engine`/`webview.version`. The webview tags come from a `set_webview_info` Tauri command the app calls once in `NativeAppService.init()` with `navigator.userAgent`; `parse_webview_info` extracts engine+major-version (Chromium `Chrome/140` checked before WebKit `Version/17`, because Android WebViews also carry a legacy `Version/4.0`) into a global `OnceLock` read in before_send. Added because forwarded browser events carry os/rust/device context but NO browser context, so crashes couldn't be correlated with WebView version. (feat(sentry): tag events with the WebView engine and version, merged 2026-07.)

**2026-07 production crash-fix batch (all merged).** The recurring root cause was best-effort background work throwing UNHANDLED promise rejections (callers fire-and-forget, so a throw hits the global handler): READEST-1 concurrent-use (turso, see [[turso-concurrent-use-forbidden]]); READEST-5 cloud `deleteFile` threw (log+swallow); READEST-6 statistics DB writes on teardown (`runBestEffort` wrapper in `ReadingStatsTracker`; also covers READEST-4/8 network fails); READEST-A library save to a custom shared-storage folder failed `EACCES` because the save path never called the existing `requestStoragePermission()` (`AppService.saveLibraryBooks` now requests-once-per-session + retries). READEST-2 = zustand `updateTransferProgress` allocating new state on unchanged values → React update loop (equality guard). READEST-9 = `useAppRouter` wraps EVERY nav in a View Transition; opening a book is a heavy render that overruns the ~4s DOM-update budget → `TimeoutError` (fix: book-open navs use the plain `useRouter`, matching 8/10 into-reader paths; version-gating does NOT help — the 4s budget is version-independent).

**Batch 2 (PR #4962, merged).** READEST-F/G = more benign View-Transition rejections → broadened the `before_send` filter (above). READEST-H = book-import `createDir` was non-recursive check-then-create; two concurrent imports of the same book race → Windows "Cannot create a file when that file already exists". Fix: `fs.createDir(getDir(book), 'Books', true)` (recursive = `create_dir_all`, idempotent). READEST-N = `StatisticsDb.applyRemoteEvents` runs a manual `BEGIN`/`COMMIT`; **the Rust per-op `op_lock` serializes single statements but does NOT make a multi-statement JS transaction atomic** — two concurrent pulls (split-view trackers share the `sharedDb` singleton connection) nest `BEGIN` in `BEGIN` → "cannot start a transaction within a transaction". Fix: a promise-chain mutex on `applyRemoteEvents` (works because JS is single-threaded — the synchronous grab-prev/install-new-promise is atomic; concurrency here is async *interleaving at `await`s*, not threads). General rule: any shared-connection multi-statement transaction needs JS-level serialization on top of the native op_lock. (Cleaner alternative not taken: dedupe the pull so only one tracker pulls the shared DB.) Deferred, need source maps: READEST-J (OPDS page-stream fetch, uncaught native reqwest error), READEST-K (`Failed to fetch`), READEST-M (`null appendChild`).

**Release + environment key off `package.json`, NOT the crate version.** Originally `release: sentry::release_name!()` = `CARGO_PKG_NAME@CARGO_PKG_VERSION` = `Readest@0.2.2` (stale crate version, never bumped) and `environment` read `CARGO_PKG_VERSION` (so it was ALWAYS "production" — nightly detection was dead). Fix: `build.rs::propagate_app_version()` reads the top-level `"version"` from `../package.json` (line-based parse via `read_json_string_field`, no serde) and bakes `cargo:rustc-env=READEST_APP_VERSION`; `app_version()` reads it via `option_env!` (falls back to `CARGO_PKG_VERSION`), same bake mechanism as `SENTRY_DSN`. `sentry_release()` -> `Readest@<pkg-version>` (e.g. `Readest@0.11.17`), `sentry_environment()` now derives from `app_version()` so nightly (`-YYYYMMDDHH`) correctly reports `environment=nightly`. Android/iOS **native** SDK releases already came from `versionName`/bundle version (tauri derives those from package.json), so only the Rust client (which also handles JS/browser events via tauri-plugin-sentry) needed fixing.

**OS name "Linux" -> "Android".** On Android, `sentry-contexts::os_context()` builds the OS context from `uname()` (not-macos/not-windows branch): `name = info.sysname` = "Linux", `version = info.release` = kernel string like `6.1.162-android14-11-...`. Fixed with a Rust-client `before_send` in `lib.rs` that, keyed on `std::env::consts::OS == "android"`, rewrites `Context::Os.name` -> "Android" and pulls the Android version ("14") out of the `androidNN` token in `os.version` via `android_version_from_uname`. `before_send` runs AFTER `ContextIntegration::process_event` (which inserts the os context only if `Entry::Vacant`), so the "Linux" context is present to rewrite; applies to browser events too since tauri-plugin-sentry forwards them through the same Rust client. iOS-via-Rust would show "Darwin" but that path is minor (native sentry-cocoa reports iOS correctly) — not remapped.

**Gotcha 1 — iOS generated files are gitignored + never tracked.** `gen/apple/Sources/Readest/main.mm` and `gen/apple/Readest_iOS/Info.plist` are gitignored (`.gitignore` `src-tauri/gen`) and regenerated by `tauri ios init` — editing them does NOT persist. Only `project.yml` and force-added custom files (like `ShareExtension/*`, `ReadestWidget/*`) survive. So native iOS init lives in a **force-tracked `gen/apple/SentrySupport/SentryBootstrap.m`** (`+[ReadestSentryBootstrap load]`, runs before `main`) wired via `project.yml` (SPM package + `- path: SentrySupport` source), reading the DSN from an iOS-gated Rust C-ABI `readest_sentry_dsn()` FFI (in `sentry_config.rs`) — no generated-file edits. See [[ios-widget-appgroup-stripped-appstore]].

**Gotcha 2 — sentry-android needs a lifecycle exclude.** `sentry-android-core` (7.x AND 8.x) depends on `androidx.lifecycle:lifecycle-common-java8`, discontinued at 2.9.0+. The app's `lifecycle-process:2.10.0` pin version-aligns it to a nonexistent `2.10.0` -> Gradle `Could not resolve` at `:app:mergeUniversalReleaseNativeLibs`. Fix = `exclude(group="androidx.lifecycle", module="lifecycle-common-java8")` (its Java8 APIs now live in `lifecycle-common`). Same class as [[dependabot-pnpm-overrides]].

**Gotcha 3 — no dotenv in the tauri build; wire `SENTRY_DSN` yourself.** `tauri-cli` (2.10.1) has NO dotenv dependency, so `.env.local` / `.env.*` are NOT auto-loaded into the cargo/gradle build (Next.js `.env.local` only reaches Next.js). `build.rs` resolves `SENTRY_DSN` with precedence **env -> `.env.local` -> `.env`** and bakes it via `cargo:rustc-env` (covers Rust + iOS FFI; `rerun-if-changed` avoids stale bake); `build.gradle.kts` does the same for the Android manifest placeholder. CI: `release.yml`/`nightly.yml` append `SENTRY_DSN=${{ secrets.SENTRY_DSN }}` to the `.env.local` they already build next to the PostHog/Supabase secrets (`cp .env.local apps/readest-app/.env.local`). iOS App Store release is a local script -> uses the maintainer's local `.env.local`.

Env tag: `sentry_environment()` now reads `app_version()` (baked from `package.json`), so nightly stamps (`-YYYYMMDDHH`) resolve to `environment=nightly`; store-distributed mobile stays `production`. (Previously it read `CARGO_PKG_VERSION` — crate `0.2.2`, never nightly-stamped — so it was always `production`.)
