mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(telemetry): fingerprint stackless uncaught errors so they stop collapsing (#611)
Stackless uncaught errors reached Sentry as a bare Error with no frames and collapsed into one ungroupable issue. beforeSend now fingerprints frameless events by safe identity (name, code, one-way hash of the message) so distinct crashes separate without leaking PII. Only frameless events are touched; an upstream fingerprint is never overridden.
This commit is contained in:
@@ -84,6 +84,57 @@ function scrubBreadcrumbs(value: unknown): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-PII type name for grouping a stackless throw. Tolerates non-Error
|
||||
* thrown values (a rejected string or plain object), which is exactly the case
|
||||
* that reaches Sentry frameless.
|
||||
*/
|
||||
function errorName(err: unknown): string {
|
||||
if (err instanceof Error) return err.name || "Error";
|
||||
if (err === null) return "null";
|
||||
if (typeof err === "object") {
|
||||
const n = (err as { name?: unknown }).name;
|
||||
return typeof n === "string" && n ? n : "Object";
|
||||
}
|
||||
return typeof err;
|
||||
}
|
||||
|
||||
/** The error `code` as a short safe string (e.g. ERR_FS_FILE_TOO_LARGE), or "-". */
|
||||
function errorCode(err: unknown): string {
|
||||
const c = err && typeof err === "object" ? (err as { code?: unknown }).code : undefined;
|
||||
return typeof c === "string" || typeof c === "number" ? String(c) : "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit hex of the error's message (or a structural stand-in for a
|
||||
* non-Error). One-way: it separates distinct crashes for grouping without ever
|
||||
* putting the message (which can carry paths or PII) into the fingerprint.
|
||||
*/
|
||||
function errorDigest(err: unknown): string {
|
||||
let s = "";
|
||||
try {
|
||||
if (err instanceof Error) s = err.message || "";
|
||||
else if (typeof err === "string") s = err;
|
||||
else if (err && typeof err === "object") {
|
||||
const m = (err as { message?: unknown }).message;
|
||||
s =
|
||||
typeof m === "string"
|
||||
? m
|
||||
: Object.keys(err as object)
|
||||
.sort()
|
||||
.join(",");
|
||||
} else s = String(err);
|
||||
} catch {
|
||||
s = "";
|
||||
}
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(16);
|
||||
}
|
||||
|
||||
export function buildBeforeSend(isActive: () => boolean) {
|
||||
let windowStart = 0;
|
||||
let sentInWindow = 0;
|
||||
@@ -154,6 +205,25 @@ export function buildBeforeSend(isActive: () => boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stackless uncaught errors (a non-Error throw/rejection, or a stripped
|
||||
// stack) arrive as a bare "Error" with no frames, so Sentry collapses every
|
||||
// distinct one into a single ungroupable issue (NODE-1Y). When there is
|
||||
// nothing to group on, derive a stable fingerprint from the ORIGINAL error's
|
||||
// safe identity: type name, code, and a one-way hash of the message (never
|
||||
// the message itself). Only frameless events, and never override a
|
||||
// fingerprint an upstream reporter set deliberately.
|
||||
if (Array.isArray(values) && values.length > 0 && !event.fingerprint) {
|
||||
const last = asObj(values[values.length - 1]);
|
||||
const frames = asObj(last?.stacktrace)?.frames;
|
||||
if (last && !(Array.isArray(frames) && frames.length > 0)) {
|
||||
const orig = hint?.originalException;
|
||||
const name = errorName(orig);
|
||||
event.fingerprint = ["uncaught", name, errorCode(orig), errorDigest(orig)];
|
||||
if (!asObj(event.tags)) event.tags = {};
|
||||
(event.tags as AnyEvent).error_name = name;
|
||||
}
|
||||
}
|
||||
return event;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,4 +133,69 @@ describe("buildBeforeSend (api)", () => {
|
||||
expect(() => send({} as AnyEvent, {})).not.toThrow();
|
||||
expect(() => send(evt({ exception: { values: null } }), {})).not.toThrow();
|
||||
});
|
||||
|
||||
// Stackless uncaught errors (a non-Error throw/rejection, or a stripped stack)
|
||||
// arrive as a bare "Error" with no frames, so Sentry collapses every distinct
|
||||
// one into a single ungroupable issue (NODE-1Y). Group them by safe identity.
|
||||
const frameless = (msg: string, hint: AnyEvent, over: AnyEvent = {}) =>
|
||||
send(evt({ exception: { values: [{ type: "Error", value: msg, ...over }] } }), hint)!;
|
||||
|
||||
it("groups stackless errors by a stable fingerprint: same message groups, different separates", () => {
|
||||
const fp = (msg: string) => frameless(msg, { originalException: new Error(msg) }).fingerprint;
|
||||
expect(fp("alpha")).toEqual(fp("alpha"));
|
||||
expect(fp("alpha")).not.toEqual(fp("beta"));
|
||||
// The message itself is never part of the fingerprint (only a one-way hash).
|
||||
expect(JSON.stringify(fp("secret /data/user.png"))).not.toContain("secret");
|
||||
expect(JSON.stringify(fp("secret /data/user.png"))).not.toContain("user.png");
|
||||
});
|
||||
it("fingerprints and tags a stackless error by its safe name and code", () => {
|
||||
const out = frameless("x is not a function", {
|
||||
originalException: Object.assign(new TypeError("x is not a function"), { code: "ERR_X" }),
|
||||
});
|
||||
expect(out.fingerprint[0]).toBe("uncaught");
|
||||
expect(out.fingerprint[1]).toBe("TypeError");
|
||||
expect(out.fingerprint[2]).toBe("ERR_X");
|
||||
expect(out.tags.error_name).toBe("TypeError");
|
||||
});
|
||||
it("leaves framed errors on Sentry's default grouping (no custom fingerprint)", () => {
|
||||
// evt() carries a real frame; those already group well and must be untouched.
|
||||
const out = send(evt(), { originalException: new Error("x") })!;
|
||||
expect(out.fingerprint).toBeUndefined();
|
||||
});
|
||||
it("never overrides a fingerprint already set upstream (e.g. an operational one)", () => {
|
||||
const out = frameless(
|
||||
"x",
|
||||
{
|
||||
originalException: Object.assign(new Error("x"), { code: "ENOSPC" }),
|
||||
},
|
||||
{},
|
||||
);
|
||||
// set it upstream this time:
|
||||
const out2 = send(
|
||||
evt({
|
||||
exception: { values: [{ type: "Error", value: "x" }] },
|
||||
fingerprint: ["operational", "ENOSPC"],
|
||||
}),
|
||||
{ originalException: Object.assign(new Error("x"), { code: "ENOSPC" }) },
|
||||
)!;
|
||||
expect(out.fingerprint[0]).toBe("uncaught");
|
||||
expect(out2.fingerprint).toEqual(["operational", "ENOSPC"]);
|
||||
});
|
||||
it("handles non-Error rejections (string, object) without throwing and still groups them", () => {
|
||||
const s = frameless("x", { originalException: "bare string reason" });
|
||||
expect(s.fingerprint[0]).toBe("uncaught");
|
||||
expect(s.tags.error_name).toBe("string");
|
||||
const o = frameless("x", { originalException: { weird: true, code: 500 } });
|
||||
expect(o.fingerprint[0]).toBe("uncaught");
|
||||
expect(o.fingerprint[2]).toBe("500");
|
||||
expect(o.tags.error_name).toBe("Object");
|
||||
});
|
||||
it("treats an empty frames array as stackless too", () => {
|
||||
const out = frameless(
|
||||
"x",
|
||||
{ originalException: new Error("x") },
|
||||
{ stacktrace: { frames: [] } },
|
||||
);
|
||||
expect(out.fingerprint[0]).toBe("uncaught");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user