fix: error-only Sentry telemetry, storm-proof capture, and crash fixes (#476)

Removes Sentry tracing entirely (BullMQ idle polling burned 4.8M transactions in 2 days at the baked 0.1 rate), decouples PostHog sampling, and replaces the type-only error scrub with a vetted-field sanitizer plus SafeError/ToolInputError contracts. One classified capture path with per-signature throttles and a per-process ceiling makes storms impossible (NODE-1E was 4,541 events from one 30s loop). Browser errors move to a dedicated web Sentry project with their own source maps. Adds the SNAPOTTER_TELEMETRY runtime kill switch and silences test fleets.

Crash fixes: remote 204/304 SSRF process kill (NODE-20), conversion-preset boot crash loop (NODE-21), Redis version preflight + unhandled subscribe rejection (NODE-1T), Sign PDF on plain-http origins (NODE-1K/1M), wavesurfer/pdf.js teardown rejections (NODE-1P/1N), bundle-import ZlibError to 400 (NODE-1Z), chart-maker input errors declassified (NODE-1H/1J), asset requests skip the session DB lookup (NODE-1D).
This commit is contained in:
SnapOtter
2026-07-10 21:41:49 +08:00
committed by GitHub
parent 3d1744aec8
commit ae6a4c8b7c
75 changed files with 2198 additions and 260 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ class ErrorBoundary extends Component<
track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name });
import("@sentry/react")
.then((Sentry) => {
Sentry.captureException(error);
Sentry.captureReactException(error, info);
})
.catch(() => {});
}
@@ -55,7 +55,9 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
wsRef.current = ws;
ws.load(src);
// load() rejects with AbortError when destroy() runs mid-decode (route
// change / src swap). That rejection is expected teardown, not an error.
ws.load(src).catch(() => {});
ws.on("ready", () => {
readyRef.current = true;
+11 -3
View File
@@ -5,6 +5,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re
import { useTranslation } from "@/contexts/i18n-context";
import { toNormalizedRect } from "@/lib/sign-geometry";
import type { SavedSignature } from "@/lib/signature-store";
import { safeRandomUUID } from "@/lib/uuid";
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
@@ -90,7 +91,11 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
setPageCount(doc.numPages);
setPage(0);
setDocReady(true);
})();
})().catch(() => {
// loadingTask.destroy() in the cleanup rejects the in-flight promise;
// swallowing it is the fix for Sentry NODE-1N. A genuine load failure
// leaves docReady false, which the existing UI already handles.
});
return () => {
cancelled = true;
docRef.current?.loadingTask.destroy();
@@ -181,7 +186,10 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
}
layer.batchDraw();
onSelectionChange?.(false);
})();
})().catch(() => {
// getPage()/render() reject when the loadingTask is destroyed mid-render
// (file swap or unmount); expected teardown, same as the load effect.
});
return () => {
cancelled = true;
};
@@ -241,7 +249,7 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
layer.add(node);
tr.nodes([node]);
layer.batchDraw();
placementsRef.current.push({ id: crypto.randomUUID(), page, node });
placementsRef.current.push({ id: safeRandomUUID(), page, node });
onSelectionChange?.(true);
emitCount();
};
@@ -10,6 +10,7 @@ import {
type SavedSignature,
} from "@/lib/signature-store";
import { generateId } from "@/lib/utils";
import { safeRandomUUID } from "@/lib/uuid";
import { useFileStore } from "@/stores/file-store";
import type { SignCanvasRef } from "./sign-canvas";
import { SignaturePad } from "./signature-pad";
@@ -126,7 +127,7 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
const handleSavePad = (dataUrl: string, remember: boolean) => {
const sig: SavedSignature = remember
? addSignature(dataUrl)
: { id: crypto.randomUUID(), dataUrl, createdAt: Date.now() };
: { id: safeRandomUUID(), dataUrl, createdAt: Date.now() };
if (remember) refresh();
signProps?.canvasRef.current?.addSignature(sig);
setPadOpen(false);
+38 -68
View File
@@ -6,21 +6,6 @@ let posthog: PostHogInstance | null = null;
let initialized = false;
let enabled = false; // live runtime flag; gates track() and ErrorBoundary capture
function basename(p: string): string {
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
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"]),
@@ -48,22 +33,30 @@ function sanitize(event: string, properties?: Record<string, unknown>): Record<s
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
if (initialized || !config.enabled) return;
try {
const posthogJs = (await import("posthog-js")).default;
posthog =
posthogJs.init(config.posthogApiKey, {
api_host: config.posthogHost,
autocapture: false,
capture_pageview: true,
disable_session_recording: true,
ip: false,
persistence: "localStorage",
person_profiles: "identified_only",
}) ?? null;
if (!config.posthogApiKey) {
// Web-DSN-only bake: no PostHog key, so skip the PostHog SDK entirely
// (mirrors the API guard) instead of feeding it an empty key. Still mark
// the module live so the Sentry beforeSend gate below stays active.
initialized = true;
enabled = true;
} catch (err) {
console.warn("[analytics] PostHog init failed:", err);
} else {
try {
const posthogJs = (await import("posthog-js")).default;
posthog =
posthogJs.init(config.posthogApiKey, {
api_host: config.posthogHost,
autocapture: false,
capture_pageview: true,
disable_session_recording: true,
ip: false,
persistence: "localStorage",
person_profiles: "identified_only",
}) ?? null;
initialized = true;
enabled = true;
} catch (err) {
console.warn("[analytics] PostHog init failed:", err);
}
}
if (posthog) {
@@ -80,51 +73,28 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
}
try {
if (config.sentryDsn) {
if (config.sentryDsnWeb) {
const Sentry = await import("@sentry/react");
const { buildWebBeforeSend, DENY_URLS, IGNORE_ERRORS } = await import("@/lib/sentry-scrub");
// buildWebBeforeSend is typed on loose Record shapes so sentry-scrub.ts
// never imports @sentry/react (this module loads the SDK lazily); cast
// at this one boundary to the SDK callback type.
type SentryOptions = NonNullable<Parameters<typeof Sentry.init>[0]>;
Sentry.init({
dsn: config.sentryDsn,
dsn: config.sentryDsnWeb,
release:
import.meta.env.VITE_SENTRY_RELEASE || (await import("@snapotter/shared")).APP_VERSION,
environment: "production",
tracesSampleRate: config.sampleRate,
sendDefaultPii: false,
integrations: [Sentry.browserTracingIntegration()],
beforeSend(event) {
if (!enabled) return null;
event.message = undefined;
event.logentry = undefined;
event.request = undefined;
event.extra = undefined;
event.contexts = undefined;
event.breadcrumbs = undefined;
event.user = undefined;
if (event.exception?.values) {
for (const ex of event.exception.values) {
ex.value = ex.type;
if (ex.stacktrace?.frames) {
for (const frame of ex.stacktrace.frames) {
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() {
return null;
},
sendClientReports: false,
// Errors only: no tracing options, and release-health sessions are
// dropped by removing the session integration below.
integrations: (defaults) => defaults.filter((i) => i.name !== "BrowserSession"),
ignoreErrors: IGNORE_ERRORS,
denyUrls: DENY_URLS,
maxBreadcrumbs: 0,
beforeBreadcrumb: () => null,
beforeSend: buildWebBeforeSend(() => enabled) as unknown as SentryOptions["beforeSend"],
});
}
} catch (err) {
+151
View File
@@ -0,0 +1,151 @@
/**
* Sentry beforeSend for the web app. Same allowlist-first stance as the API
* scrubber (apps/api/src/lib/sentry-scrub.ts), adapted for browsers: native
* error messages are usually safe and highly diagnostic, so they pass through
* with url/path redaction; anything else falls back to type-only. Frame paths
* keep the host-less pathname so debug-id source maps still resolve.
*/
import { rebuildErrorValue } from "@snapotter/shared";
export const IGNORE_ERRORS: (string | RegExp)[] = [
/^AbortError/,
"Failed to fetch",
"NetworkError when attempting to fetch resource.",
"Load failed",
/^ResizeObserver loop/,
"The operation was aborted.",
];
export const DENY_URLS: RegExp[] = [
/^chrome-extension:\/\//,
/^moz-extension:\/\//,
/^safari-web-extension:\/\//,
];
const NATIVE_ERRORS = new Set([
"TypeError",
"RangeError",
"SyntaxError",
"ReferenceError",
"DOMException",
"SecurityError",
"NotSupportedError",
"QuotaExceededError",
"AbortError",
]);
const CEILING_PER_HOUR = 20;
const HOUR_MS = 3600_000;
// BLOB_RE must be applied before URL_RE: "blob:http://..." would otherwise
// partially match URL_RE and leave a dangling "blob:" prefix behind.
const URL_RE = /https?:\/\/[^\s"')]+/g;
const BLOB_RE = /blob:[^\s"')]+/g;
const PATH_RE = /(?:\/Users|\/home|[A-Za-z]:\\)[^\s"')]*/g;
/** Redacted native-error message, or null when the name is not allowlisted. */
export function scrubBrowserMessage(name: string, message: string): string | null {
if (!NATIVE_ERRORS.has(name)) return null;
return message.replace(BLOB_RE, "<blob>").replace(URL_RE, "<url>").replace(PATH_RE, "<path>");
}
const TAG_ALLOWLIST = new Set(["route", "tool_id", "locale", "error_class"]);
// Sentry event/hint are typed loosely on purpose: this module must not import
// @sentry/react (analytics.ts loads the SDK lazily and passes events through).
type AnyEvent = Record<string, unknown>;
type AnyHint = { originalException?: unknown };
/** Narrow to a plain mutable object, or null for anything else (fail-closed). */
function asObj(value: unknown): AnyEvent | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as AnyEvent)
: null;
}
export function buildWebBeforeSend(isActive: () => boolean) {
let windowStart = 0;
let sentInWindow = 0;
return function beforeSend(event: AnyEvent, hint: AnyHint): AnyEvent | null {
if (!isActive()) return null;
const now = Date.now();
if (now - windowStart > HOUR_MS) {
windowStart = now;
sentInWindow = 0;
}
if (++sentInWindow > CEILING_PER_HOUR) return null;
event.message = undefined;
event.logentry = undefined;
event.request = undefined;
event.extra = undefined;
event.breadcrumbs = undefined;
event.user = undefined;
// React's componentStack is component names only; every other context goes.
const react = asObj(event.contexts)?.react;
event.contexts = react ? { react } : undefined;
const tags = asObj(event.tags);
if (tags) {
for (const key of Object.keys(tags)) {
if (!TAG_ALLOWLIST.has(key)) delete tags[key];
}
}
const orig = hint?.originalException;
let rebuilt = rebuildErrorValue(orig);
if (rebuilt === null) {
const err = asObj(orig);
const name = err?.name;
const message = err?.message;
if (typeof name === "string" && typeof message === "string") {
rebuilt = scrubBrowserMessage(name, message);
}
}
const values = asObj(event.exception)?.values;
if (Array.isArray(values)) {
for (let i = 0; i < values.length; i++) {
const ex = asObj(values[i]);
if (!ex) continue;
// The last entry is the original error; linked/outer wrappers get type-only.
ex.value = i === values.length - 1 && rebuilt ? rebuilt : ex.type;
const frames = asObj(ex.stacktrace)?.frames;
if (Array.isArray(frames)) {
for (const entry of frames) {
const frame = asObj(entry);
if (!frame) continue;
if (frame.filename) frame.filename = scrubFramePath(String(frame.filename));
if (frame.abs_path) frame.abs_path = scrubFramePath(String(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.
const images = asObj(event.debug_meta)?.images;
if (Array.isArray(images)) {
for (const entry of images) {
const img = asObj(entry);
if (img?.code_file) img.code_file = scrubFramePath(String(img.code_file));
}
}
return event;
};
}
// 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] ?? "/";
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
return i >= 0 ? p.slice(i + 1) : p;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { safeRandomUUID } from "@/lib/uuid";
export interface SavedSignature {
id: string;
dataUrl: string;
@@ -31,7 +33,7 @@ export function listSignatures(): SavedSignature[] {
export function addSignature(dataUrl: string): SavedSignature {
const sig: SavedSignature = {
id: crypto.randomUUID(),
id: safeRandomUUID(),
dataUrl,
createdAt: Date.now(),
};
+2 -6
View File
@@ -1,17 +1,13 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { safeRandomUUID } from "@/lib/uuid";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
return safeRandomUUID();
}
export async function copyToClipboard(text: string): Promise<boolean> {
+14
View File
@@ -0,0 +1,14 @@
/**
* crypto.randomUUID only exists in secure contexts, and self-hosted SnapOtter
* is very often reached over plain http on a LAN, where calling it throws
* (Sentry NODE-1K/1M crashed Sign PDF exactly this way). getRandomValues works
* everywhere, so fall back to assembling a v4 UUID from it.
*/
export function safeRandomUUID(): string {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}