Skip to content
46 linesCodeBlameRaw
1/**
2 * Errors grouped: the same exception thrown a thousand times is one group
3 * with a count, not a thousand lines. What makes two the same is their
4 * name, their message with the parts that change taken out (numbers, ids,
5 * quoted values), and where they were thrown (the top frame of the stack,
6 * its file and function, not its line, which moves with every change).
7 */
8
9/** The message with what varies between occurrences taken out. */
10export function messageShape(message: string): string {
11 return message
12 .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
13 .replace(/\b[0-9a-f]{12,}\b/gi, "<id>")
14 .replace(/(["'`])(?:(?!\1).){0,200}\1/g, "$1…$1")
15 .replace(/https?:\/\/\S+/g, "<url>")
16 .replace(/\d+(\.\d+)?/g, "#")
17 .replace(/\s+/g, " ")
18 .trim()
19 .slice(0, 300);
20}
21
22/**
23 * The top frame that is the app's own: its function and file, without
24 * line, column or a build's hash. Frames of the runtime and of
25 * node_modules are skipped while there is another.
26 */
27export function topFrame(stack: string | null | undefined): string {
28 if (!stack) return "";
29 const frames = stack
30 .split("\n")
31 .map((line) => line.trim())
32 .filter((line) => line.startsWith("at ") || /@.+:\d+/.test(line));
33 const own = frames.find((f) => !/node_modules|node:|internal\/|<anonymous>|native/.test(f)) ?? frames[0] ?? "";
34 return own
35 .replace(/:\d+(:\d+)?\)?$/, "")
36 .replace(/[.-][0-9a-f]{6,}(?=\.\w+$)/i, "")
37 .replace(/\(|\)/g, "")
38 .replace(/^at /, "")
39 .slice(0, 300);
40}
41
42export async function fingerprint(name: string, message: string, stack: string | null | undefined, service: string): Promise<string> {
43 const key = [service, name, messageShape(message), topFrame(stack)].join("\u0000");
44 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
45 return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
46}