diff --git a/apps/api/src/lib/sentry-scrub.ts b/apps/api/src/lib/sentry-scrub.ts index 0ac907ed..2bdb2d11 100644 --- a/apps/api/src/lib/sentry-scrub.ts +++ b/apps/api/src/lib/sentry-scrub.ts @@ -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; }; } diff --git a/tests/unit/api/sentry-scrub.test.ts b/tests/unit/api/sentry-scrub.test.ts index c84ad576..f1a57005 100644 --- a/tests/unit/api/sentry-scrub.test.ts +++ b/tests/unit/api/sentry-scrub.test.ts @@ -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"); + }); });