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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user