feat(analytics): upload web source maps to Sentry + tie release to build (#369)

* feat(analytics): upload web source maps to Sentry + tie release to build

Web crash reports were unusable: the bundle ships minified with no source
maps uploaded, and every build reported as the frozen APP_VERSION, so a
Sentry error showed an unreadable stack under a single release.

- Add @sentry/vite-plugin: emit hidden source maps and upload them by debug
  id when SENTRY_AUTH_TOKEN is present (published Docker build only), then
  delete the maps so they never ship. No-op for dev and the source archive.
- Set the Sentry release from SENTRY_RELEASE / VITE_SENTRY_RELEASE (the Docker
  build passes the release version), falling back to APP_VERSION.
- Relax beforeSend so app bundle frames keep a host-stripped path (Sentry needs
  it to match the uploaded map) while the instance hostname, error message, and
  PII stay stripped. Filesystem paths still collapse to the basename.
- Wire the Dockerfile (sentry_auth_token build secret + SENTRY_RELEASE arg/env)
  and the release docker job.

* fix(analytics): point source map upload at the snapotter org (project node)
This commit is contained in:
SnapOtter
2026-06-29 10:47:30 +08:00
committed by GitHub
parent 9819c5885e
commit 1c202c6ef0
10 changed files with 386 additions and 10 deletions
+23 -3
View File
@@ -11,6 +11,16 @@ function basename(p: string): string {
return i >= 0 ? p.slice(i + 1) : p;
}
// App bundle frames are http(s) URLs. Keep the host-less pathname so Sentry can
// match the frame to its uploaded source map, but drop the instance hostname
// (not anonymous). Filesystem-style paths collapse to the basename so a local
// path or username can never leave the browser.
function scrubFramePath(p: string): string {
const url = p.match(/^https?:\/\/[^/]+(\/[^?#]*)?/i);
if (url) return url[1] ?? "/";
return basename(p);
}
// Only these keys may leave the browser per event, and only as primitives.
const ALLOWED: Record<string, ReadonlySet<string>> = {
tool_opened: new Set(["tool_id", "category", "modality"]),
@@ -66,7 +76,8 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
const Sentry = await import("@sentry/react");
Sentry.init({
dsn: config.sentryDsn,
release: (await import("@snapotter/shared")).APP_VERSION,
release:
import.meta.env.VITE_SENTRY_RELEASE || (await import("@snapotter/shared")).APP_VERSION,
environment: "production",
tracesSampleRate: config.sampleRate,
sendDefaultPii: false,
@@ -85,13 +96,22 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
ex.value = ex.type;
if (ex.stacktrace?.frames) {
for (const frame of ex.stacktrace.frames) {
if (frame.filename) frame.filename = basename(frame.filename);
frame.abs_path = undefined;
if (frame.filename) frame.filename = scrubFramePath(frame.filename);
if (frame.abs_path) frame.abs_path = scrubFramePath(frame.abs_path);
frame.vars = undefined;
}
}
}
}
// Keep debug_meta image paths consistent with the scrubbed frames so
// debug-id source-map matching still resolves, minus the hostname.
if (event.debug_meta?.images) {
for (const img of event.debug_meta.images) {
if ("code_file" in img && img.code_file) {
img.code_file = scrubFramePath(img.code_file);
}
}
}
return event;
},
beforeBreadcrumb() {
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
// Sentry release, injected at build time by the Docker web build. Unset in
// dev and source-archive builds, where the SDK falls back to APP_VERSION.
readonly VITE_SENTRY_RELEASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}