apperr: group records by fingerprint for the Errors tab
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+112
−0
internal/apperr/group.go+79 −0internal/apperr/group_test.go+33 −0
diff --git a/internal/apperr/group.go b/internal/apperr/group.go| new file mode 100644 |
| index 000000000..3fc8e911e |
| --- /dev/null |
| +++ b/internal/apperr/group.go |
| @@ -0,0 +1,79 @@ |
| +package apperr |
| + |
| +import ( |
| + "sort" |
| + "time" |
| +) |
| + |
| +// Group is one row of the Errors tab: every record sharing a fingerprint |
| +// in the window, summed (spec §3.6). |
| +type Group struct { |
| + FP string `json:"fp"` |
| + Source string `json:"source"` |
| + Kind string `json:"kind,omitempty"` |
| + Name string `json:"name"` |
| + Level string `json:"level,omitempty"` |
| + Count int `json:"count"` |
| + First time.Time `json:"first"` |
| + Last time.Time `json:"last"` |
| + Hosts []string `json:"hosts"` |
| + Releases []string `json:"releases,omitempty"` |
| + Sample Record `json:"sample"` |
| +} |
| + |
| +// GroupRecords folds records into groups by FP, newest Last first. The |
| +// sample is the earliest record of the group (its stack and fields are |
| +// the first occurrence's). |
| +func GroupRecords(recs []Record) []Group { |
| + byFP := make(map[string]*Group) |
| + var order []string |
| + for _, r := range recs { |
| + first, last := r.First, r.Last |
| + if first.IsZero() { |
| + first = r.TS |
| + } |
| + if last.IsZero() { |
| + last = r.TS |
| + } |
| + count := r.Count |
| + if count == 0 { |
| + count = 1 |
| + } |
| + g := byFP[r.FP] |
| + if g == nil { |
| + g = &Group{FP: r.FP, Source: r.Source, Kind: r.Kind, Name: r.Name, Level: r.Level, First: first, Last: last, Sample: r} |
| + byFP[r.FP] = g |
| + order = append(order, r.FP) |
| + } |
| + g.Count += count |
| + if first.Before(g.First) { |
| + g.First = first |
| + } |
| + if last.After(g.Last) { |
| + g.Last = last |
| + } |
| + g.Hosts = addUnique(g.Hosts, r.Host) |
| + g.Releases = addUnique(g.Releases, r.Release) |
| + } |
| + out := make([]Group, 0, len(order)) |
| + for _, fp := range order { |
| + g := byFP[fp] |
| + sort.Strings(g.Hosts) |
| + sort.Strings(g.Releases) |
| + out = append(out, *g) |
| + } |
| + sort.SliceStable(out, func(i, j int) bool { return out[i].Last.After(out[j].Last) }) |
| + return out |
| +} |
| + |
| +func addUnique(list []string, v string) []string { |
| + if v == "" { |
| + return list |
| + } |
| + for _, x := range list { |
| + if x == v { |
| + return list |
| + } |
| + } |
| + return append(list, v) |
| +} |
diff --git a/internal/apperr/group_test.go b/internal/apperr/group_test.go| new file mode 100644 |
| index 000000000..d98f6f798 |
| --- /dev/null |
| +++ b/internal/apperr/group_test.go |
| @@ -0,0 +1,33 @@ |
| +package apperr |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestGroupRecords(t *testing.T) { |
| + now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| + recs := []Record{ |
| + {FP: "a", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Host: "h1", Release: "v1", Count: 3, First: now.Add(-10 * time.Minute), Last: now.Add(-5 * time.Minute), Stack: "s1"}, |
| + {FP: "a", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Host: "h2", Release: "v2", Count: 2, First: now.Add(-20 * time.Minute), Last: now.Add(-1 * time.Minute)}, |
| + {FP: "b", Source: SourceLog, Kind: KindLog, Name: "boom", Level: "error", Host: "h1", Count: 1, First: now.Add(-30 * time.Minute), Last: now.Add(-30 * time.Minute)}, |
| + {FP: OverflowFP, Source: SourceEdge, Name: "500 GET /z", Host: "h1", Count: 40, First: now, Last: now}, |
| + } |
| + gs := GroupRecords(recs) |
| + if len(gs) != 3 { |
| + t.Fatalf("groups = %d", len(gs)) |
| + } |
| + if gs[0].FP != OverflowFP || gs[1].FP != "a" || gs[2].FP != "b" { |
| + t.Fatalf("order by Last desc: %s %s %s", gs[0].FP, gs[1].FP, gs[2].FP) |
| + } |
| + a := gs[1] |
| + if a.Count != 5 || !a.First.Equal(now.Add(-20*time.Minute)) || !a.Last.Equal(now.Add(-1*time.Minute)) { |
| + t.Fatalf("a = %+v", a) |
| + } |
| + if len(a.Hosts) != 2 || a.Hosts[0] != "h1" || a.Hosts[1] != "h2" || len(a.Releases) != 2 { |
| + t.Fatalf("a hosts/releases = %v %v", a.Hosts, a.Releases) |
| + } |
| + if a.Sample.Stack != "s1" { |
| + t.Fatal("sample must be the earliest record") |
| + } |
| +} |