package apperr import ( "fmt" "strconv" "testing" "time" "github.com/carlosframework/platform/internal/applog" ) func TestSinkCoalescesByFingerprintHostRelease(t *testing.T) { now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) clock := now s := NewSink("box1", func() time.Time { return clock }) for i := 0; i < 10000; i++ { clock = now.Add(time.Duration(i) * time.Millisecond) s.Add("bab", "web", Record{Host: "h1", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Release: "v1"}) } s.Add("bab", "web", Record{Host: "h2", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Release: "v1"}) out := s.Drain() recs := out[applog.AppKey{Acct: "bab", App: "web"}] if len(recs) != 2 { t.Fatalf("records = %d, want 2 (one per host)", len(recs)) } var h1 Record for _, r := range recs { if r.Host == "h1" { h1 = r } } if h1.Count != 10000 || !h1.First.Equal(now) || !h1.Last.Equal(now.Add(9999*time.Millisecond)) { t.Fatalf("h1 = count %d first %v last %v", h1.Count, h1.First, h1.Last) } if h1.FP == "" || h1.FPV != FPVersion || h1.Box != "box1" || h1.Seq == 0 { t.Fatalf("stamping: %+v", h1) } if len(s.Drain()) != 0 { t.Fatal("second drain must be empty") } } func TestSinkCapsGroupsPerAppPerHour(t *testing.T) { now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) s := NewSink("box1", func() time.Time { return now }) for i := 0; i < MaxGroupsPerAppHour+5; i++ { s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: fmt.Sprintf("500 GET /p%d-x", i), FP: fmt.Sprintf("fp%d", i)}) } s.Add("bab", "other", Record{Source: SourceEdge, Kind: KindHTTP, Name: "unaffected", FP: "z"}) out := s.Drain() web := out[applog.AppKey{Acct: "bab", App: "web"}] if len(web) != MaxGroupsPerAppHour+1 { t.Fatalf("web records = %d, want cap+1 (the overflow row)", len(web)) } var over *Record for i := range web { if web[i].FP == OverflowFP { over = &web[i] } } if over == nil || over.Count != 5 || over.Name != "500 GET /p1000-x" { t.Fatalf("overflow = %+v", over) } if len(out[applog.AppKey{Acct: "bab", App: "other"}]) != 1 { t.Fatal("a neighbour app must not be suppressed") } // The cap counts per hour: the next hour admits new groups again. now = now.Add(time.Hour) s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "fresh", FP: "fresh"}) if recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}]; len(recs) != 1 || recs[0].FP != "fresh" { t.Fatalf("next hour = %+v", recs) } } // TestSinkDrainOverflowOnly: Drain resets the groups but not the per-hour // `seen` cap counter, so an app that was already at the cap when last // drained stays at the cap — the next genuinely new key goes straight to // overflow, and a drain right after that Add must show nothing else. func TestSinkDrainOverflowOnly(t *testing.T) { now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) s := NewSink("box1", func() time.Time { return now }) for i := 0; i < MaxGroupsPerAppHour; i++ { s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: fmt.Sprintf("500 GET /p%d-x", i), FP: fmt.Sprintf("fp%d", i)}) } if recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}]; len(recs) != MaxGroupsPerAppHour { t.Fatalf("fill drain = %d, want %d", len(recs), MaxGroupsPerAppHour) } // One brand-new key, nothing else: the cap counter survived the drain. s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "brand new", FP: "brandnew"}) recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}] if len(recs) != 1 { t.Fatalf("overflow-only drain = %d records, want 1: %+v", len(recs), recs) } if recs[0].FP != OverflowFP || recs[0].Count != 1 { t.Fatalf("record = %+v", recs[0]) } } func TestSinkSanitisesAndFingerprintsOnAdd(t *testing.T) { now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) s := NewSink("box1", func() time.Time { return now }) long := make([]byte, MaxStack+100) for i := range long { long[i] = 'x' } s.Add("bab", "web", Record{Source: SourceServer, Kind: "RuntimeError", Name: "mail to paul@tito.io bounced", Path: "/reset/8f3a9c1e2b4d6f708a9b0c1d2e3f4a5b?t=1", Stack: string(long), Fields: map[string]string{"user": "paul@tito.io", "k": "v"}}) r := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}][0] if r.Name != "mail to bounced" || r.Path != "/reset/:token" || r.Fields["user"] != "" || r.Fields["k"] != "v" { t.Fatalf("sanitised = %+v", r) } if len(r.Stack) != MaxStack { t.Fatalf("stack len %d, want %d", len(r.Stack), MaxStack) } if r.FP != Fingerprint(SourceServer, "RuntimeError", "mail to bounced", r.Stack) || r.FPV != FPVersion { t.Fatalf("fp = %q", r.FP) } if r.Count != 1 || !r.First.Equal(now) || !r.Last.Equal(now) || !r.TS.Equal(now) { t.Fatalf("stamps = %+v", r) } } // TestSinkCapCountsDistinctKeysAcrossDrains replays the ship loop: a 10 s // drain, a handful of distinct errors recurring in every tick. Six distinct // errors must never overflow, however many ticks the hour has. func TestSinkCapCountsDistinctKeysAcrossDrains(t *testing.T) { base := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) clock := base s := NewSink("box1", func() time.Time { return clock }) for tick := 0; tick < 360; tick++ { clock = base.Add(time.Duration(tick*10) * time.Second) for i := 0; i < 6; i++ { s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: fmt.Sprintf("500 GET /p%d", i), Host: "h1", Release: "v1"}) } for _, recs := range s.Drain() { for _, r := range recs { if r.FP == OverflowFP { t.Fatalf("overflow at tick %d with only 6 distinct errors", tick) } } if len(recs) != 6 { t.Fatalf("tick %d drained %d records, want 6", tick, len(recs)) } } } // The cap still binds on genuinely distinct keys within the hour, and // a key already seen this hour is admitted even after the cap is hit. clock = base.Add(30 * time.Minute) for i := 0; i < MaxGroupsPerAppHour+5; i++ { s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "x", FP: fmt.Sprintf("d%d", i)}) } s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /p0", Host: "h1", Release: "v1"}) // seen this hour: admitted, not overflow out := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}] var over, p0 int for _, r := range out { if r.FP == OverflowFP { over = r.Count } if r.Name == "500 GET /p0" && r.FP != OverflowFP { p0++ } } if over != 11 || p0 != 1 { // 6 recurring + 994 new = 1000; 11 new beyond it overflow; p0 was seen t.Fatalf("overflow count = %d (want 11), p0 admitted = %d (want 1)", over, p0) } // A new hour starts the count over. clock = base.Add(time.Hour) s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "fresh", FP: "fresh"}) if recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}]; len(recs) != 1 || recs[0].FP != "fresh" { t.Fatalf("next hour = %+v", recs) } } // TestSinkOverflowPerFamily: past the hourly cap, an app's own errors and // its availability failures overflow into separate rows, so neither tab's // excess is counted on the other's. func TestSinkOverflowPerFamily(t *testing.T) { now := time.Date(2026, 9, 24, 10, 0, 0, 0, time.UTC) s := NewSink("box1", func() time.Time { return now }) for i := 0; i < MaxGroupsPerAppHour; i++ { s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /p" + strconv.Itoa(i), Status: 500, Host: "h1"}) } s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Host: "h1"}) for i := 0; i < 3; i++ { s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindUnavailable, Cause: CauseRefused, Name: AvailabilityName(502, CauseRefused) + strconv.Itoa(i), Status: 502, Host: "h1"}) } var errs, avail int for _, r := range s.Drain()[applog.AppKey{Acct: "bab", App: "web"}] { if r.FP != OverflowFP { continue } if IsAvailability(r) { avail += r.Count } else { errs += r.Count } } if errs != 1 || avail != 3 { t.Fatalf("overflow errors=%d availability=%d, want 1 and 3", errs, avail) } if got := AsAvailability(Record{Source: SourceEdge, Kind: KindHTTP, Status: 502, FP: OverflowFP}); got.FP != OverflowFP { t.Fatalf("the overflow row was regrouped: %+v", got) } }