mirror of
https://github.com/readest/readest.git
synced 2026-07-09 16:00:16 +00:00
feat(sentry): upload browser source maps for symbolicated JS crashes (#5027)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
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@<version>, ~/_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) <noreply@anthropic.com>
This commit is contained in:
parent
15cca23773
commit
870a62e0e9
8 changed files with 297 additions and 1 deletions
68
apps/readest-app/docs/sentry-symbolication.md
Normal file
68
apps/readest-app/docs/sentry-symbolication.md
Normal file
|
|
@ -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@<package.json version>` (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 <archive>/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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
96
apps/readest-app/scripts/upload-sourcemaps.mjs
Normal file
96
apps/readest-app/scripts/upload-sourcemaps.mjs
Normal file
|
|
@ -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@<version>`, 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.`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue