From 870a62e0e9ecd20fac14ac7fdf15dfb57eeafacc Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 9 Jul 2026 16:18:48 +0900 Subject: [PATCH] feat(sentry): upload browser source maps for symbolicated JS crashes (#5027) Production JS crashes report minified chunk names with no source lines, which blocked triaging most reader/TTS Sentry issues. Generate browser source maps for the Tauri export build and upload them right after `next build`: - next.config: productionBrowserSourceMaps for the export build. - scripts/upload-sourcemaps.mjs: sentry-cli injects debug IDs + uploads the maps (release Readest@, ~/_next/static url-prefix), then strips the .map files so they never ship in the app bundle. Token-gated; never fails the build. - CI (release.yml, nightly.yml): pass SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT alongside SENTRY_DSN. - docs/sentry-symbolication.md: setup + required secret, and the native (Android ProGuard/.so, iOS dSYM) follow-up. Local and fork builds are unaffected: with no token the maps are stripped and the output is unchanged. Needs the SENTRY_AUTH_TOKEN GitHub secret to take effect. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/nightly.yml | 3 + .github/workflows/release.yml | 3 + apps/readest-app/docs/sentry-symbolication.md | 68 ++++++++++ apps/readest-app/next.config.mjs | 4 + apps/readest-app/package.json | 3 +- .../readest-app/scripts/upload-sourcemaps.mjs | 96 ++++++++++++++ pnpm-lock.yaml | 120 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 8 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/docs/sentry-symbolication.md create mode 100644 apps/readest-app/scripts/upload-sourcemaps.mjs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8d4257dc8..29b03bee5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -143,6 +143,9 @@ jobs: echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env.local + echo "SENTRY_ORG=readest" >> .env.local + echo "SENTRY_PROJECT=readest" >> .env.local cp .env.local apps/readest-app/.env.local - name: install rclone diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5479fb3e..c4f4f8227 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,6 +257,9 @@ jobs: echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env.local + echo "SENTRY_ORG=readest" >> .env.local + echo "SENTRY_PROJECT=readest" >> .env.local cp .env.local apps/readest-app/.env.local - name: build and upload Android apks diff --git a/apps/readest-app/docs/sentry-symbolication.md b/apps/readest-app/docs/sentry-symbolication.md new file mode 100644 index 000000000..bd52a214b --- /dev/null +++ b/apps/readest-app/docs/sentry-symbolication.md @@ -0,0 +1,68 @@ +# Sentry symbolication (source maps + debug files) + +Crash reports from production carry minified JS chunk names (`w`, `#b`, +`chunks/05.7xrfq2vajv.js`) and unsymbolicated native frames. To turn those into +real file:line stacks, Sentry needs the browser **source maps** and the native +**debug files** for each release. + +## Prerequisite: `SENTRY_AUTH_TOKEN` + +Create an **org auth token** in Sentry (Settings -> Auth Tokens, scope +`project:releases` + `org:read`) for org `readest`, then add it as the GitHub +Actions secret **`SENTRY_AUTH_TOKEN`** (used by `release.yml` and `nightly.yml`). +`SENTRY_ORG` (`readest`) and `SENTRY_PROJECT` (`readest`) are written alongside +`SENTRY_DSN` into the build's `.env.local`. + +Nothing below runs without this token: local and fork builds are unaffected. + +## Browser JS source maps (implemented) + +Covers every `platform: javascript` issue (the reader/TTS crashes on +`tauri.localhost`). + +- `next.config.mjs` sets `productionBrowserSourceMaps` for the Tauri **export** + build, so `next build` emits `.js.map` files next to the chunks in + `out/_next/static`. +- `scripts/upload-sourcemaps.mjs` runs right after `next build` (it is chained + into the `build` script). It: + 1. `sentry-cli sourcemaps inject` — writes a Sentry debug ID into each chunk + and its map (host-agnostic matching; chunks are served from + `tauri.localhost`, which has no stable URL). + 2. `sentry-cli sourcemaps upload` — uploads the maps, associated with release + `Readest@` (matches `sentry_config.rs::sentry_release`) + and a `~/_next/static` URL prefix as a fallback matcher. + 3. Deletes every `.js.map` so maps never ship inside the app bundle. +- Any Sentry failure is logged but never fails the build. + +Validate on the next nightly: open a symbolicated JS issue and confirm real +file:line frames appear. + +## Native debug files (not yet enabled — follow-up) + +The credentials + env are already wired for these jobs; each still needs a small, +build-touching change that must be validated on a real native build (the Android +and iOS builds are fragile — see the build gotchas in project memory), so they +are intentionally left as a focused follow-up rather than shipped unverified. + +### Android +- **Kotlin/Java stacks + ANRs** (e.g. `RustWebViewClient in shouldOverride`) + need the **Sentry Android Gradle plugin** (`io.sentry.android.gradle`) in + `gen/android/app/build.gradle.kts`: it embeds the ProGuard mapping UUID into + the app and auto-uploads `mapping.txt` on release builds. A bare + `sentry-cli upload-proguard` cannot work without the embedded UUID. +- **Native (`.so`) crashes** (`android::Looper::pollInner`, + `Java_..._WryActivity_create`) need the release build to keep unstripped debug + info (`ndk.debugSymbolLevel = "full"` or an unstripped variant), then + `sentry-cli debug-files upload` the `.so` files. + +### iOS +- Upload dSYMs after the Xcode archive with + `sentry-cli debug-files upload --include-sources /dSYMs`. The App + Store release runs from a local script, so add it there (or to the CI archive + step if one is added). + +## Local release builds + +The maintainer's local `.env.local` already carries `SENTRY_DSN`; add +`SENTRY_AUTH_TOKEN` (+ optionally `SENTRY_ORG`/`SENTRY_PROJECT`, which default to +`readest`) there and `pnpm build` uploads JS source maps automatically. diff --git a/apps/readest-app/next.config.mjs b/apps/readest-app/next.config.mjs index 7412b1f41..3283fdcfc 100644 --- a/apps/readest-app/next.config.mjs +++ b/apps/readest-app/next.config.mjs @@ -28,6 +28,10 @@ const nextConfig = { // tree (see Dockerfile) so it can ship only the traced runtime; all other // web builds fall back to the default server output. output: exportOutput ? 'export' : standaloneOutput ? 'standalone' : undefined, + // Emit browser source maps for the Tauri export build so Sentry can + // symbolicate crashes. `scripts/upload-sourcemaps.mjs` uploads them after the + // build and strips the .map files, so they never ship inside the app bundle. + productionBrowserSourceMaps: exportOutput, // Monorepo: trace from the repo root so workspace packages land in the // standalone tree. Only relevant to — and only set for — the Docker build. outputFileTracingRoot: standaloneOutput ? path.join(__dirname, '../../') : undefined, diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 672484710..42001fd28 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "dotenv -e .env.tauri -- next dev", - "build": "dotenv -e .env.tauri -- next build", + "build": "dotenv -e .env.tauri -- next build && node scripts/upload-sourcemaps.mjs", "start": "dotenv -e .env.tauri -- next start", "dev-web": "dotenv -e .env.web -- next dev", "build-web": "dotenv -e .env.web -- next build", @@ -214,6 +214,7 @@ "devDependencies": { "@next/bundle-analyzer": "^15.4.2", "@playwright/test": "^1.60.0", + "@sentry/cli": "^2.39.1", "@tailwindcss/typography": "^0.5.16", "@tauri-apps/cli": "2.10.1", "@testing-library/dom": "^10.4.0", diff --git a/apps/readest-app/scripts/upload-sourcemaps.mjs b/apps/readest-app/scripts/upload-sourcemaps.mjs new file mode 100644 index 000000000..6efdca4c6 --- /dev/null +++ b/apps/readest-app/scripts/upload-sourcemaps.mjs @@ -0,0 +1,96 @@ +// Sentry browser source maps for the Tauri export build. +// +// Runs after `next build` (see the `build` script). `productionBrowserSourceMaps` +// makes Next emit `.js.map` files next to the chunks in `out/_next/static`; this +// script injects Sentry debug IDs into the chunks + maps, uploads the maps, then +// strips the `.map` files so they never ship inside the app bundle. +// +// The injected debug IDs let Sentry symbolicate regardless of the served host +// (chunks load from `tauri.localhost`), and we also associate the upload with the +// release (`Readest@`, matching sentry_config.rs::sentry_release) and a +// host-agnostic `~/_next/static` URL prefix as a fallback matcher. +// +// No-op when SENTRY_AUTH_TOKEN is absent (local + fork builds): the maps are +// still stripped so the output is identical to before. Any Sentry failure is +// logged but never fails the build — crash reporting must not block a release. + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const staticDir = path.join(appDir, 'out', '_next', 'static'); + +// Resolve a config value from the process env, falling back to `.env.local` then +// `.env` (mirrors how build.rs resolves SENTRY_DSN). +const readEnv = (key) => { + if (process.env[key]) return process.env[key]; + for (const file of ['.env.local', '.env']) { + const p = path.join(appDir, file); + if (!existsSync(p)) continue; + for (const line of readFileSync(p, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); + if (m && m[1] === key) return m[2].replace(/^["']|["']$/g, '').trim(); + } + } + return ''; +}; + +// Recursively delete every `*.js.map` under `dir`. +const stripMaps = (dir) => { + if (!existsSync(dir)) return 0; + let removed = 0; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) removed += stripMaps(full); + else if (entry.endsWith('.js.map')) { + rmSync(full); + removed += 1; + } + } + return removed; +}; + +if (!existsSync(staticDir)) { + // Not an export build (e.g. `build-web`); nothing to do. + process.exit(0); +} + +const authToken = readEnv('SENTRY_AUTH_TOKEN'); + +if (authToken) { + const org = readEnv('SENTRY_ORG') || 'readest'; + const project = readEnv('SENTRY_PROJECT') || 'readest'; + const require = createRequire(import.meta.url); + const version = require(path.join(appDir, 'package.json')).version; + const release = `Readest@${version}`; + + // The sentry-cli binary shipped by the @sentry/cli package. + const bin = require('@sentry/cli').getPath(); + const env = { ...process.env, SENTRY_AUTH_TOKEN: authToken, SENTRY_ORG: org, SENTRY_PROJECT: project }; + const run = (args) => execFileSync(bin, args, { cwd: appDir, env, stdio: 'inherit' }); + + try { + run(['sourcemaps', 'inject', staticDir]); + run([ + 'sourcemaps', + 'upload', + '--release', + release, + '--url-prefix', + '~/_next/static', + staticDir, + ]); + console.log(`Sentry: uploaded source maps for ${release}.`); + } catch (err) { + // Never fail the build over crash-reporting plumbing. + console.warn('Sentry: source map upload failed, continuing build:', err?.message ?? err); + } +} else { + console.log('Sentry: SENTRY_AUTH_TOKEN unset, skipping source map upload.'); +} + +const removed = stripMaps(staticDir); +console.log(`Sentry: stripped ${removed} .js.map file(s) from the app bundle.`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d30cefce9..76b3a4fe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -421,6 +421,9 @@ importers: '@playwright/test': specifier: ^1.60.0 version: 1.60.0 + '@sentry/cli': + specifier: ^2.39.1 + version: 2.58.6 '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) @@ -2907,6 +2910,58 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sentry/cli-darwin@2.58.6': + resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.6': + resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.6': + resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.6': + resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.6': + resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.6': + resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.6': + resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.6': + resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.6': + resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==} + engines: {node: '>= 10'} + hasBin: true + '@serwist/build@9.5.11': resolution: {integrity: sha512-PQfW+LhADYFOOp0PhEnjlgJCyKor6cYa06d3rID1OpiKzkmCApJV1WYfdTBB96jXaWv6OWcWSbSV4tqDLxvaVA==} engines: {node: '>=18.0.0'} @@ -4076,6 +4131,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -5710,6 +5769,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -11229,6 +11292,50 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sentry/cli-darwin@2.58.6': + optional: true + + '@sentry/cli-linux-arm64@2.58.6': + optional: true + + '@sentry/cli-linux-arm@2.58.6': + optional: true + + '@sentry/cli-linux-i686@2.58.6': + optional: true + + '@sentry/cli-linux-x64@2.58.6': + optional: true + + '@sentry/cli-win32-arm64@2.58.6': + optional: true + + '@sentry/cli-win32-i686@2.58.6': + optional: true + + '@sentry/cli-win32-x64@2.58.6': + optional: true + + '@sentry/cli@2.58.6': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.6 + '@sentry/cli-linux-arm': 2.58.6 + '@sentry/cli-linux-arm64': 2.58.6 + '@sentry/cli-linux-i686': 2.58.6 + '@sentry/cli-linux-x64': 2.58.6 + '@sentry/cli-win32-arm64': 2.58.6 + '@sentry/cli-win32-i686': 2.58.6 + '@sentry/cli-win32-x64': 2.58.6 + transitivePeerDependencies: + - encoding + - supports-color + '@serwist/build@9.5.11(browserslist@4.28.2)(typescript@5.9.3)': dependencies: '@serwist/utils': 9.5.11(browserslist@4.28.2) @@ -12640,6 +12747,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} agentkeepalive@4.6.0: @@ -14468,6 +14581,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b1cb6aed7..fc1337936 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ packages: - packages/foliate-js allowBuilds: + '@sentry/cli': true core-js: true edgedriver: true esbuild: true