package apperr import ( "crypto/sha256" "encoding/hex" "regexp" "strconv" "strings" ) var ( reQuoted = regexp.MustCompile(`"[^"]*"|'[^']*'`) 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`) reHexRun = regexp.MustCompile(`\b[0-9a-fA-F]{8,}\b`) reDigits4 = regexp.MustCompile(`\b\d{4,}\b`) reLineCol = regexp.MustCompile(`:\d+(:\d+)?`) reBundleJS = regexp.MustCompile(`\.[0-9a-fA-F]{8,}\.js`) ) // Normalize folds the parts of a message that vary per occurrence into // placeholders on token boundaries (spec §3.2): quoted strings, UUIDs, hex // runs of 8+, digit runs of 4+. Tokens of 1–3 digits and mixed tokens // (v2, E404) survive, so "HTTP 503" and "retry 3" still separate groups // while "user 4123" and "user 9911" do not. Small ids under 1,000 will // split groups; that is the stated trade-off, and a caller-supplied FP // overrides it. func Normalize(s string) string { s = reQuoted.ReplaceAllString(s, `"?"`) s = reUUIDAny.ReplaceAllString(s, "") s = reHexRun.ReplaceAllStringFunc(s, func(m string) string { if _, err := strconv.ParseUint(m, 10, 64); err == nil { return "" // all digits: a digit run, not a hex run } return "" }) s = reDigits4.ReplaceAllString(s, "") return s } // TopFrame is the first non-empty line of a stack with its line, column // and bundle hash removed, so a redeploy does not split every group. func TopFrame(stack string) string { for _, line := range strings.Split(stack, "\n") { line = strings.TrimSpace(line) if line == "" { continue } line = reBundleJS.ReplaceAllString(line, ".js") line = reLineCol.ReplaceAllString(line, "") return line } return "" } // Fingerprint groups occurrences: sha256 over length-delimited (source, // kind, Normalize(name), Normalize(TopFrame(stack)))[:16] as hex. For a // stack with a message line first (JS "TypeError: x\n at …"), the top // frame is the second line; callers pass the message as name and the // whole stack here. func Fingerprint(source, kind, name, stack string) string { h := sha256.New() frame := TopFrame(stack) if strings.HasPrefix(frame, name) || strings.Contains(frame, ": "+name) { // The stack's first line restates the message; take the next. rest := strings.TrimSpace(stack) if i := strings.IndexByte(rest, '\n'); i >= 0 { frame = TopFrame(rest[i+1:]) } } for _, part := range []string{source, kind, Normalize(name), Normalize(frame)} { h.Write([]byte(strconv.Itoa(len(part)))) h.Write([]byte{':'}) h.Write([]byte(part)) } return hex.EncodeToString(h.Sum(nil))[:16] }