mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user