feat(telemetry): readable Sentry errors, Python tracebacks, and diagnostic mode

Keeps a real, redacted error message instead of "Error: Error", surfaces Python tracebacks in Sentry as a vetted context, and adds an opt-in SNAPOTTER_SENTRY_DIAGNOSTIC verbose mode plus SNAPOTTER_SENTRY_DSN_OVERRIDE. The default fleet path ships nothing on the never-collect list; raw detail is reachable only via the opt-in flag. Also classifies Redis OOM/READONLY replies as operational and removes a ReDoS in stack-frame extraction.
This commit is contained in:
SnapOtter
2026-08-03 13:13:38 +08:00
committed by GitHub
parent 865390ce66
commit 04ef1141fb
22 changed files with 593 additions and 178 deletions
@@ -5,6 +5,7 @@
* author). Returning null means "no safe rebuild known, use type-only".
*/
import { isSafeMessageError } from "../tool-errors.js";
import { redactMessage } from "./redact-message.js";
interface ErrLike {
name?: unknown;
@@ -56,13 +57,39 @@ function looksLikePg(links: ErrLike[]): boolean {
);
}
/** "at file.ts:NN" from the first in-app stack frame, mirroring errorSignature. */
function frameHint(err: unknown): string | null {
const stack = (err as { stack?: unknown } | null)?.stack;
if (typeof stack !== "string") return null;
const line = stack.split("\n").find((l) => l.includes("/apps/") || l.includes("/packages/"));
const m = line?.slice(0, 300).match(/([^/\\\s():]+):(\d+)/);
return m ? `at ${m[1]}:${m[2]}` : null;
}
/** Safe replacement for exception.value, or null for type-only fallback. */
export function rebuildErrorValue(err: unknown): string | null {
try {
if (isSafeMessageError(err)) return err.message;
const links = chain(err);
if (links.length === 0) return null;
// 1. SafeError: our authored (or toSidecarError-wrapped) message, redacted,
// plus the redacted immediate cause so a wrapper title never hides detail.
if (isSafeMessageError(err)) {
let out = redactMessage(err.message);
for (let i = 1; i < links.length; i++) {
const m = links[i].message;
if (typeof m === "string" && m.trim()) {
const detail = redactMessage(m);
if (detail && !out.includes(detail)) out = `${out}: ${detail}`;
break;
}
}
return out;
}
if (links.length === 0) return null;
const top = links[0];
// 2. Structured rebuilds: pg SQLSTATE, node E-code, reply, zod, http status.
for (const l of links) {
if (typeof l.code === "string" && SQLSTATE.test(l.code) && !NODE_CODE.test(l.code)) {
return typeof l.routine === "string" && PG_ROUTINE.test(l.routine)
@@ -72,11 +99,9 @@ export function rebuildErrorValue(err: unknown): string | null {
}
for (const l of links) {
if (typeof l.code === "string" && NODE_CODE.test(l.code)) {
// syscall is libuv vocabulary or "spawn <server-binary>", never user args.
return typeof l.syscall === "string" ? `${l.code} ${l.syscall}` : l.code;
}
}
const top = links[0];
if (top.name === "ReplyError" && typeof top.message === "string") {
const token = top.message.split(" ")[0];
return REPLY_TOKEN.test(token) ? `reply ${token}` : "reply";
@@ -96,6 +121,17 @@ export function rebuildErrorValue(err: unknown): string | null {
typeof top.name === "string" && SAFE_NAME.test(top.name) ? top.name : "HttpError";
return `${name} ${top.status}`;
}
// 3. Non-empty message: keep it, redacted (the rich default).
if (typeof top.message === "string" && top.message.trim().length > 0) {
return redactMessage(top.message);
}
// 4. Empty message with a stack: derive a title from the first in-app frame.
const hint = frameHint(err);
if (hint) return hint;
// 5. Nothing safe to surface: type-only.
return null;
} catch {
return null;
@@ -0,0 +1,56 @@
/**
* The single redactor for error text sent to Sentry. Denylist-shaped, so it is
* only trusted for messages we already gate (SafeError, our own throws, known
* libraries) and for the redacted fallback in rebuildErrorValue. It keeps the
* published never-collect promise (no file names / paths / contents) true while
* still surfacing the human-readable message.
*/
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally strip ASCII C0 controls + DEL from error text.
const CTRL_RE = /[\x00-\x1f\x7f]/g;
const BLOB_RE = /blob:[^\s"')]+/g;
const DATA_RE = /data:[^\s"')]+/g;
const URL_RE = /https?:\/\/[^\s"')]+/g;
const PATH_RE = /(?:\/(?:Users|home|root|data|tmp|var|app|opt|mnt|srv)|[A-Za-z]:\\)[^\s"')]*/g;
// Relative object-storage keys (uploads/<jobId>/…, outputs/…, previews/…) carry a
// user-supplied filename tail, so mask them like absolute paths. Runs after
// PATH_RE, which already swallows the absolute /data/uploads/… form.
const RELKEY_RE = /\b(?:uploads|outputs|previews)\/[^\s"')]+/g;
const IP_RE = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
// IPv6: a full 8-group form, or any ::-compressed form (::1, fe80::…, …::). The
// negative lookbehind/lookahead ((?<![\w:]) … (?![\w:])) require the address to
// stand alone, so C++/Rust scope resolution (std::bad_alloc, core::result) is left
// intact. A plain decimal version like 2.2.0 has no colons, and a bare HH:MM needs
// no ::, so both survive too.
const IPV6_RE =
/(?<![\w:])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*)?::(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*)?)(?![\w:])/g;
const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
// A user file is a name plus one of the formats SnapOtter processes. Restricting
// to this set avoids eating version strings ("2.2.0") and code filenames
// ("rounded-crop.ts"), which carry no user data and aid triage. Unicode-aware
// (\p{L} + the u flag) so a non-ASCII user filename (CJK, Arabic) is masked too.
const USER_FILE_EXT =
"jpe?g|png|gif|webp|avif|heif?|tiff?|bmp|svg|raw|psd|mp4|mov|avi|mkv|webm|flv|wmv|m4v|mp3|wav|flac|aac|ogg|m4a|opus|pdf|docx?|xlsx?|pptx?|odt|ods|odp|txt|csv|epub|zip";
const FILE_RE = new RegExp(`[\\p{L}\\p{N}_-]{1,80}\\.(?:${USER_FILE_EXT})`, "giu");
const QUOTED_RE = /(['"])(.{24,}?)\1/g;
const HEX_RE = /\b[0-9a-fA-F]{16,}\b/g;
const MAX_LEN = 300;
export function redactMessage(message: unknown, opts?: { raw?: boolean }): string {
let s = String(message ?? "").replace(CTRL_RE, " ");
if (!opts?.raw) {
s = s
.replace(BLOB_RE, "<blob>")
.replace(DATA_RE, "<data>")
.replace(URL_RE, "<url>")
.replace(PATH_RE, "<path>")
.replace(RELKEY_RE, "<path>")
.replace(IP_RE, "<ip>")
.replace(IPV6_RE, "<ip>")
.replace(EMAIL_RE, "<email>")
.replace(QUOTED_RE, (_m, q) => `${q}<value>${q}`)
.replace(HEX_RE, "<hex>")
.replace(FILE_RE, "<file>");
}
s = s.replace(/\s+/g, " ").trim();
return s.length > MAX_LEN ? `${s.slice(0, MAX_LEN)}` : s;
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./analytics/baked.js";
export * from "./analytics/error-sanitize.js";
export * from "./analytics/events.js";
export * from "./analytics/feedback.js";
export { redactMessage } from "./analytics/redact-message.js";
export * from "./analytics/types.js";
export * from "./audit-events.js";
export * from "./constants.js";