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:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Sentry beforeSend for the API: allowlist-first scrubbing plus a per-process
|
||||
* event ceiling. Kept pure (factory + injected gate) so it is unit-testable
|
||||
* without initializing the SDK. See the telemetry overhaul spec for the rules.
|
||||
*/
|
||||
import { rebuildErrorValue } from "@snapotter/shared";
|
||||
|
||||
const CEILING_PER_HOUR = 20;
|
||||
const HOUR_MS = 3600_000;
|
||||
|
||||
const TAG_ALLOWLIST = new Set([
|
||||
"source",
|
||||
"tool_id",
|
||||
"pool",
|
||||
"route",
|
||||
"method",
|
||||
"error_class",
|
||||
"error_code",
|
||||
"deploy_mode",
|
||||
"subsystem",
|
||||
"status_code",
|
||||
]);
|
||||
|
||||
function basename(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
// Sentry event/hint are typed loosely on purpose: this module must not import
|
||||
// @sentry/node (instrument.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 buildBeforeSend(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.server_name = undefined;
|
||||
event.request = undefined;
|
||||
event.extra = undefined;
|
||||
event.breadcrumbs = undefined;
|
||||
event.user = undefined;
|
||||
|
||||
const ctx = asObj(event.contexts);
|
||||
const keep: AnyEvent = {};
|
||||
const os = asObj(ctx?.os);
|
||||
if (os?.name) keep.os = { name: os.name, version: os.version };
|
||||
const runtime = asObj(ctx?.runtime);
|
||||
if (runtime?.name) keep.runtime = { name: runtime.name, version: runtime.version };
|
||||
event.contexts = Object.keys(keep).length ? keep : undefined;
|
||||
|
||||
const tags = asObj(event.tags);
|
||||
if (tags) {
|
||||
for (const key of Object.keys(tags)) {
|
||||
if (!TAG_ALLOWLIST.has(key)) delete tags[key];
|
||||
}
|
||||
}
|
||||
|
||||
const rebuilt = rebuildErrorValue(hint?.originalException);
|
||||
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 (typeof frame.filename === "string") frame.filename = basename(frame.filename);
|
||||
frame.abs_path = undefined;
|
||||
frame.vars = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user