carlos / platform Public

apperr: group records by fingerprint for the Errors tab

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a8fb42f599ec3cf159e2abc6200d5438523cf291 parent 735b5af
2 files changed, +112 −0
  • internal/apperr/group.go +79 −0
  • internal/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")
+ }
+}