| 1 | package apperr |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "regexp" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | var ( |
| 12 | reQuoted = regexp.MustCompile(`"[^"]*"|'[^']*'`) |
| 13 | reUUIDAny = regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`) |
| 14 | reHexRun = regexp.MustCompile(`\b[0-9a-fA-F]{8,}\b`) |
| 15 | reDigits4 = regexp.MustCompile(`\b\d{4,}\b`) |
| 16 | reLineCol = regexp.MustCompile(`:\d+(:\d+)?`) |
| 17 | reBundleJS = regexp.MustCompile(`\.[0-9a-fA-F]{8,}\.js`) |
| 18 | ) |
| 19 | |
| 20 | // Normalize folds the parts of a message that vary per occurrence into |
| 21 | // placeholders on token boundaries (spec §3.2): quoted strings, UUIDs, hex |
| 22 | // runs of 8+, digit runs of 4+. Tokens of 1–3 digits and mixed tokens |
| 23 | // (v2, E404) survive, so "HTTP 503" and "retry 3" still separate groups |
| 24 | // while "user 4123" and "user 9911" do not. Small ids under 1,000 will |
| 25 | // split groups; that is the stated trade-off, and a caller-supplied FP |
| 26 | // overrides it. |
| 27 | func Normalize(s string) string { |
| 28 | s = reQuoted.ReplaceAllString(s, `"?"`) |
| 29 | s = reUUIDAny.ReplaceAllString(s, "<uuid>") |
| 30 | s = reHexRun.ReplaceAllStringFunc(s, func(m string) string { |
| 31 | if _, err := strconv.ParseUint(m, 10, 64); err == nil { |
| 32 | return "<n>" // all digits: a digit run, not a hex run |
| 33 | } |
| 34 | return "<hex>" |
| 35 | }) |
| 36 | s = reDigits4.ReplaceAllString(s, "<n>") |
| 37 | return s |
| 38 | } |
| 39 | |
| 40 | // TopFrame is the first non-empty line of a stack with its line, column |
| 41 | // and bundle hash removed, so a redeploy does not split every group. |
| 42 | func TopFrame(stack string) string { |
| 43 | for _, line := range strings.Split(stack, "\n") { |
| 44 | line = strings.TrimSpace(line) |
| 45 | if line == "" { |
| 46 | continue |
| 47 | } |
| 48 | line = reBundleJS.ReplaceAllString(line, ".js") |
| 49 | line = reLineCol.ReplaceAllString(line, "") |
| 50 | return line |
| 51 | } |
| 52 | return "" |
| 53 | } |
| 54 | |
| 55 | // Fingerprint groups occurrences: sha256 over length-delimited (source, |
| 56 | // kind, Normalize(name), Normalize(TopFrame(stack)))[:16] as hex. For a |
| 57 | // stack with a message line first (JS "TypeError: x\n at …"), the top |
| 58 | // frame is the second line; callers pass the message as name and the |
| 59 | // whole stack here. |
| 60 | func Fingerprint(source, kind, name, stack string) string { |
| 61 | h := sha256.New() |
| 62 | frame := TopFrame(stack) |
| 63 | if strings.HasPrefix(frame, name) || strings.Contains(frame, ": "+name) { |
| 64 | // The stack's first line restates the message; take the next. |
| 65 | rest := strings.TrimSpace(stack) |
| 66 | if i := strings.IndexByte(rest, '\n'); i >= 0 { |
| 67 | frame = TopFrame(rest[i+1:]) |
| 68 | } |
| 69 | } |
| 70 | for _, part := range []string{source, kind, Normalize(name), Normalize(frame)} { |
| 71 | h.Write([]byte(strconv.Itoa(len(part)))) |
| 72 | h.Write([]byte{':'}) |
| 73 | h.Write([]byte(part)) |
| 74 | } |
| 75 | return hex.EncodeToString(h.Sum(nil))[:16] |
| 76 | } |
| 77 | |