mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractPythonErrorInfo } from "../../../packages/ai/src/bridge.js";
|
||||
|
||||
describe("extractPythonErrorInfo", () => {
|
||||
it("reads the structured envelope from stdout JSON", () => {
|
||||
const stdout = JSON.stringify({
|
||||
success: false,
|
||||
error: "CUDA out of memory for <path>",
|
||||
errorInfo: {
|
||||
type: "RuntimeError",
|
||||
frames: [{ file: "remove_bg.py", line: 88, func: "run" }],
|
||||
},
|
||||
});
|
||||
const info = extractPythonErrorInfo({ stdout, stderr: "" });
|
||||
expect(info).toEqual({
|
||||
type: "RuntimeError",
|
||||
frames: [{ file: "remove_bg.py", line: 88, func: "run" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no envelope (back-compat)", () => {
|
||||
expect(extractPythonErrorInfo({ stdout: "boom", stderr: "" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ vi.mock("@sentry/node", () => ({
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/analytics-gate.js", () => ({
|
||||
analyticsEnabled: () => true,
|
||||
sentryDiagnostic: () => false,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -113,6 +113,17 @@ describe("classifyError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError redis", () => {
|
||||
it("classifies a Redis OOM ReplyError as operational", () => {
|
||||
const err = Object.assign(new Error("OOM command not allowed"), { name: "ReplyError" });
|
||||
expect(classifyError(err, "worker")).toBe("operational");
|
||||
});
|
||||
it("classifies a Redis READONLY ReplyError as operational", () => {
|
||||
const err = Object.assign(new Error("READONLY You can't write"), { name: "ReplyError" });
|
||||
expect(classifyError(err, "worker")).toBe("operational");
|
||||
});
|
||||
});
|
||||
|
||||
describe("throttle", () => {
|
||||
beforeEach(() => resetThrottleForTests());
|
||||
it("operational: 1 per signature per hour; bug: 10", () => {
|
||||
|
||||
@@ -40,6 +40,18 @@ describe("buildBeforeSend (api)", () => {
|
||||
it("returns null when the gate is off", () => {
|
||||
expect(buildBeforeSend(() => false)(evt(), {})).toBeNull();
|
||||
});
|
||||
it("keeps the raw message and request when diagnostic is on", () => {
|
||||
const diag = buildBeforeSend(() => true, true);
|
||||
const event = {
|
||||
exception: { values: [{ type: "Error", value: "open /data/uploads/a/report.pdf" }] },
|
||||
request: { method: "POST", url: "https://host/api/v1/tools/image/rounded-crop" },
|
||||
};
|
||||
const out = diag(event as never, {
|
||||
originalException: new Error("open /data/uploads/a/report.pdf"),
|
||||
}) as never as { exception: { values: Array<{ value: string }> }; request?: unknown };
|
||||
expect(out.exception.values[0].value).toBe("open /data/uploads/a/report.pdf");
|
||||
expect(out.request).toBeDefined();
|
||||
});
|
||||
it("strips high-risk surfaces but keeps full stack paths for debugging", () => {
|
||||
const hint = {
|
||||
originalException: Object.assign(new Error("x"), { code: "EACCES", syscall: "mkdir" }),
|
||||
@@ -78,9 +90,9 @@ describe("buildBeforeSend (api)", () => {
|
||||
{ message: "reading <path>", category: "console", level: "info" },
|
||||
]);
|
||||
});
|
||||
it("falls back to type-only for unknown errors", () => {
|
||||
it("keeps a redacted message for unknown errors", () => {
|
||||
const out = send(evt(), { originalException: new Error("user path /tmp/z") })!;
|
||||
expect(out.exception.values[0].value).toBe("Error");
|
||||
expect(out.exception.values[0].value).toBe("user path <path>");
|
||||
});
|
||||
it("applies the rebuilt value to the last (original) exception entry only", () => {
|
||||
const event = evt({
|
||||
@@ -125,6 +137,25 @@ describe("buildBeforeSend (api)", () => {
|
||||
const out = send(evt({ contexts: { device: { hostname: "leak" } } }), {})!;
|
||||
expect(out.contexts).toBeUndefined();
|
||||
});
|
||||
it("keeps a vetted python context and drops overlong fields", () => {
|
||||
const event = {
|
||||
...evt(),
|
||||
contexts: {
|
||||
python: {
|
||||
type: "RuntimeError",
|
||||
frames: [
|
||||
{ file: "remove_bg.py", line: 88, func: "run" },
|
||||
{ file: "x".repeat(200), line: 1, func: "y".repeat(200) },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const out = send(event, { originalException: new Error("x") })!;
|
||||
expect(out.contexts.python.type).toBe("RuntimeError");
|
||||
expect(out.contexts.python.frames).toHaveLength(2);
|
||||
expect(out.contexts.python.frames[1].file.length).toBeLessThanOrEqual(64);
|
||||
expect(out.contexts.python.frames[1].func.length).toBeLessThanOrEqual(64);
|
||||
});
|
||||
it("enforces the 500-events-per-hour ceiling", () => {
|
||||
for (let i = 0; i < 500; i++) expect(send(evt(), {})).not.toBeNull();
|
||||
expect(send(evt(), {})).toBeNull();
|
||||
|
||||
@@ -66,8 +66,11 @@ describe("rebuildErrorValue", () => {
|
||||
});
|
||||
expect(rebuildErrorValue(err)).toBe("zod invalid_type at files.~.0");
|
||||
});
|
||||
it("returns null for unknown errors (caller falls back to type-only)", () => {
|
||||
expect(rebuildErrorValue(new Error("user file /tmp/x.pdf broke"))).toBeNull();
|
||||
it("keeps a redacted message for an unknown Error, null only for non-objects", () => {
|
||||
// Was type-only before the fallback ladder; now the message survives, redacted.
|
||||
expect(rebuildErrorValue(new Error("user file /tmp/x.pdf broke"))).toBe(
|
||||
"user file <path> broke",
|
||||
);
|
||||
expect(rebuildErrorValue("string")).toBeNull();
|
||||
expect(rebuildErrorValue(null)).toBeNull();
|
||||
});
|
||||
@@ -78,10 +81,61 @@ describe("rebuildErrorValue", () => {
|
||||
});
|
||||
expect(rebuildErrorValue(err)).toBe("HttpError 502");
|
||||
});
|
||||
it("returns null for a circular cause chain without hanging", () => {
|
||||
it("keeps the redacted message for a circular cause chain without hanging", () => {
|
||||
const err = new Error("loop") as Error & { cause?: unknown };
|
||||
err.cause = err;
|
||||
expect(rebuildErrorValue(err)).toBeNull();
|
||||
// chain()'s max cap breaks the cycle; the top message still surfaces.
|
||||
expect(rebuildErrorValue(err)).toBe("loop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebuildErrorValue ladder", () => {
|
||||
it("keeps a redacted message for an unknown error (was type-only)", () => {
|
||||
expect(rebuildErrorValue(new Error("Background removal failed"))).toBe(
|
||||
"Background removal failed",
|
||||
);
|
||||
expect(rebuildErrorValue(new Error("open /data/uploads/9f/in.bin"))).toBe("open <path>");
|
||||
});
|
||||
|
||||
it("redacts a SafeError message (NODE-4W path leak)", () => {
|
||||
const e = new SafeError("[Errno 13] Permission denied: '/root/.u2net/x.onnx'", { kind: "bug" });
|
||||
expect(rebuildErrorValue(e)).toBe("[Errno 13] Permission denied: '<path>'");
|
||||
});
|
||||
|
||||
it("appends the redacted cause to a SafeError title (NODE-3D)", () => {
|
||||
const cause = new Error("unsupported image format for /data/x.heic");
|
||||
const e = new SafeError("Image conversion failed", { kind: "bug", cause });
|
||||
expect(rebuildErrorValue(e)).toBe(
|
||||
"Image conversion failed: unsupported image format for <path>",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives a frame title for an empty-message error (NODE-3C)", () => {
|
||||
const e = new Error("");
|
||||
e.stack = "Error\n at Object.process (/app/apps/api/src/routes/tools/rounded-crop.ts:96:10)";
|
||||
expect(rebuildErrorValue(e)).toBe("at rounded-crop.ts:96");
|
||||
});
|
||||
|
||||
it("still prefers a pg SQLSTATE rebuild over the raw message", () => {
|
||||
const e = Object.assign(new Error(`password authentication failed for user "x"`), {
|
||||
code: "28P01",
|
||||
routine: "auth_failed",
|
||||
});
|
||||
expect(rebuildErrorValue(e)).toBe("pg 28P01 auth_failed");
|
||||
});
|
||||
|
||||
it("returns null only when there is no message and no stack", () => {
|
||||
expect(rebuildErrorValue({ name: "Weird" })).toBeNull();
|
||||
});
|
||||
|
||||
it("handles a pathological stack without catastrophic backtracking", () => {
|
||||
const e = new Error("");
|
||||
e.stack = `Error\n at x (/apps/${".".repeat(100000)})`;
|
||||
const start = performance.now();
|
||||
const out = rebuildErrorValue(e);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactMessage } from "../../../packages/shared/src/analytics/redact-message.js";
|
||||
|
||||
describe("redactMessage (default)", () => {
|
||||
it("masks absolute paths", () => {
|
||||
expect(redactMessage("ENOENT open /data/uploads/9f/input.bin")).toBe("ENOENT open <path>");
|
||||
});
|
||||
it("masks a user filename token by known extension", () => {
|
||||
expect(redactMessage("cannot read family_photo.JPG")).toBe("cannot read <file>");
|
||||
});
|
||||
it("keeps a source filename (code extension, not a user file)", () => {
|
||||
expect(redactMessage("failed in rounded-crop.ts")).toBe("failed in rounded-crop.ts");
|
||||
});
|
||||
it("masks a non-ASCII user filename", () => {
|
||||
expect(redactMessage("写真.jpg not found")).toBe("<file> not found");
|
||||
});
|
||||
it("masks a data: URI so base64 content cannot leak", () => {
|
||||
expect(redactMessage("bad img data:image/png;base64,iVBORw0KGgoAAAA end")).toBe(
|
||||
"bad img <data> end",
|
||||
);
|
||||
});
|
||||
it("masks emails", () => {
|
||||
expect(redactMessage("login failed for a.b+x@example.com")).toBe("login failed for <email>");
|
||||
});
|
||||
it("masks a long quoted literal but keeps the quotes", () => {
|
||||
expect(redactMessage(`bad value "this is a long user supplied caption here"`)).toBe(
|
||||
`bad value "<value>"`,
|
||||
);
|
||||
});
|
||||
it("masks urls and blob refs, blob before url", () => {
|
||||
expect(redactMessage("fetch blob:https://x/y then https://a.b/c")).toBe(
|
||||
"fetch <blob> then <url>",
|
||||
);
|
||||
});
|
||||
it("keeps a version string intact", () => {
|
||||
expect(redactMessage("torch 2.2.0 cannot access GPU")).toBe("torch 2.2.0 cannot access GPU");
|
||||
});
|
||||
it("caps length", () => {
|
||||
const long = redactMessage("x".repeat(500));
|
||||
expect(long.length).toBeLessThanOrEqual(301);
|
||||
expect(long.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactMessage (raw)", () => {
|
||||
it("keeps paths and filenames, strips only control chars and caps", () => {
|
||||
expect(redactMessage("open /data/x/report.pdf", { raw: true })).toBe("open /data/x/report.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactMessage adversarial", () => {
|
||||
it("masks an IPv4 address (never-collect: IP)", () => {
|
||||
expect(redactMessage("connect to 192.168.10.5:5432 refused")).toBe(
|
||||
"connect to <ip>:5432 refused",
|
||||
);
|
||||
});
|
||||
it("masks a windows path", () => {
|
||||
expect(redactMessage("open C:\\Users\\jane\\photo.png failed")).toBe("open <path> failed");
|
||||
});
|
||||
it("masks an email inside a long quoted parameter", () => {
|
||||
expect(
|
||||
redactMessage(`Failed query: update where email = 'averylonguseraddress@example.com'`),
|
||||
).toContain("<email>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactMessage IPv6 and relative keys", () => {
|
||||
it("masks a link-local IPv6 address", () => {
|
||||
expect(redactMessage("connect to fe80::1ff:fe23:4567:890a failed")).toBe(
|
||||
"connect to <ip> failed",
|
||||
);
|
||||
});
|
||||
it("masks a bracketed IPv6 with port", () => {
|
||||
expect(redactMessage("peer [2001:db8::8a2e:370:7334]:443 down")).toBe("peer [<ip>]:443 down");
|
||||
});
|
||||
it("masks the IPv6 loopback", () => {
|
||||
expect(redactMessage("bind ::1 ok")).toBe("bind <ip> ok");
|
||||
});
|
||||
it("masks a full 8-group IPv6", () => {
|
||||
expect(redactMessage("host 2001:db8:0:0:0:0:0:1 up")).toBe("host <ip> up");
|
||||
});
|
||||
it("still masks IPv4 and leaves versions intact", () => {
|
||||
expect(redactMessage("host 10.0.0.1 up")).toBe("host <ip> up");
|
||||
expect(redactMessage("torch 2.2.0 ok")).toBe("torch 2.2.0 ok");
|
||||
});
|
||||
it("masks a relative object-storage key", () => {
|
||||
expect(redactMessage("ENOENT uploads/3f2a/input.bin missing")).toBe("ENOENT <path> missing");
|
||||
expect(redactMessage("wrote outputs/9b7c/result.dat")).toBe("wrote <path>");
|
||||
});
|
||||
it("does not mangle C++/Rust scope resolution", () => {
|
||||
expect(redactMessage("terminate called: std::bad_alloc")).toBe(
|
||||
"terminate called: std::bad_alloc",
|
||||
);
|
||||
expect(redactMessage("panic in core::result::unwrap")).toBe("panic in core::result::unwrap");
|
||||
});
|
||||
});
|
||||
@@ -1,60 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWebBeforeSend,
|
||||
DENY_URLS,
|
||||
IGNORE_ERRORS,
|
||||
scrubBrowserMessage,
|
||||
} from "@/lib/sentry-scrub";
|
||||
|
||||
describe("scrubBrowserMessage", () => {
|
||||
it("keeps browser-native messages with urls/paths redacted", () => {
|
||||
expect(scrubBrowserMessage("TypeError", "Failed to fetch https://intra.host/x?q=1")).toBe(
|
||||
"Failed to fetch <url>",
|
||||
);
|
||||
expect(scrubBrowserMessage("TypeError", "cannot read /Users/bob/file.png")).toBe(
|
||||
"cannot read <path>",
|
||||
);
|
||||
expect(scrubBrowserMessage("RangeError", "Invalid array length")).toBe("Invalid array length");
|
||||
});
|
||||
|
||||
it("redacts blob urls and windows paths", () => {
|
||||
expect(scrubBrowserMessage("DOMException", "load blob:http://x/abc failed")).toBe(
|
||||
"load <blob> failed",
|
||||
);
|
||||
expect(scrubBrowserMessage("TypeError", "open C:\\Users\\bob\\tax.pdf")).toBe("open <path>");
|
||||
});
|
||||
|
||||
it("drops messages for non-native error names", () => {
|
||||
expect(scrubBrowserMessage("CustomerDataError", "contains secret.pdf")).toBeNull();
|
||||
});
|
||||
|
||||
// DOMExceptions report their specific name ("NotFoundError"), not
|
||||
// "DOMException", so listing only the base name dropped the diagnostic
|
||||
// browser message for the whole family (WEB-3/4/6 showed as
|
||||
// "NotFoundError: NotFoundError" with no way to tell which DOM call failed).
|
||||
it("keeps messages for specific DOMException names, still redacted", () => {
|
||||
expect(
|
||||
scrubBrowserMessage(
|
||||
"NotFoundError",
|
||||
"Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.",
|
||||
),
|
||||
).toBe(
|
||||
"Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.",
|
||||
);
|
||||
expect(scrubBrowserMessage("InvalidStateError", "The object is in an invalid state.")).toBe(
|
||||
"The object is in an invalid state.",
|
||||
);
|
||||
expect(scrubBrowserMessage("NotAllowedError", "Write permission denied.")).toBe(
|
||||
"Write permission denied.",
|
||||
);
|
||||
expect(scrubBrowserMessage("NotReadableError", "error reading /Users/bob/file.png")).toBe(
|
||||
"error reading <path>",
|
||||
);
|
||||
expect(scrubBrowserMessage("DataCloneError", "could not be cloned.")).toBe(
|
||||
"could not be cloned.",
|
||||
);
|
||||
});
|
||||
});
|
||||
import { buildWebBeforeSend, DENY_URLS, IGNORE_ERRORS } from "@/lib/sentry-scrub";
|
||||
|
||||
describe("static filter lists", () => {
|
||||
it("deny extension frames and ignore noisy network errors", () => {
|
||||
@@ -119,11 +64,19 @@ describe("buildWebBeforeSend", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to type-only for non-native exceptions without a rebuild", () => {
|
||||
it("keeps a redacted message for a non-native error", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
const custom = Object.assign(new Error("user secret"), { name: "WeirdLibError" });
|
||||
const out = send(baseEvent(), { originalException: custom })!;
|
||||
expect(out.exception.values[0].value).toBe("TypeError");
|
||||
expect(out.exception.values[0].value).toBe("user secret");
|
||||
});
|
||||
|
||||
it("keeps a redacted message for a non-native app error", () => {
|
||||
const send = buildWebBeforeSend(() => true);
|
||||
const out = send(baseEvent(), {
|
||||
originalException: new Error("upload failed for report.pdf"),
|
||||
})!;
|
||||
expect(out.exception.values[0].value).toBe("upload failed for <file>");
|
||||
});
|
||||
|
||||
it("enforces the 500-per-hour ceiling", () => {
|
||||
|
||||
Reference in New Issue
Block a user