| 1 | package apperr |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strconv" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/applog" |
| 10 | ) |
| 11 | |
| 12 | func TestSinkCoalescesByFingerprintHostRelease(t *testing.T) { |
| 13 | now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| 14 | clock := now |
| 15 | s := NewSink("box1", func() time.Time { return clock }) |
| 16 | for i := 0; i < 10000; i++ { |
| 17 | clock = now.Add(time.Duration(i) * time.Millisecond) |
| 18 | s.Add("bab", "web", Record{Host: "h1", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Release: "v1"}) |
| 19 | } |
| 20 | s.Add("bab", "web", Record{Host: "h2", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Release: "v1"}) |
| 21 | out := s.Drain() |
| 22 | recs := out[applog.AppKey{Acct: "bab", App: "web"}] |
| 23 | if len(recs) != 2 { |
| 24 | t.Fatalf("records = %d, want 2 (one per host)", len(recs)) |
| 25 | } |
| 26 | var h1 Record |
| 27 | for _, r := range recs { |
| 28 | if r.Host == "h1" { |
| 29 | h1 = r |
| 30 | } |
| 31 | } |
| 32 | if h1.Count != 10000 || !h1.First.Equal(now) || !h1.Last.Equal(now.Add(9999*time.Millisecond)) { |
| 33 | t.Fatalf("h1 = count %d first %v last %v", h1.Count, h1.First, h1.Last) |
| 34 | } |
| 35 | if h1.FP == "" || h1.FPV != FPVersion || h1.Box != "box1" || h1.Seq == 0 { |
| 36 | t.Fatalf("stamping: %+v", h1) |
| 37 | } |
| 38 | if len(s.Drain()) != 0 { |
| 39 | t.Fatal("second drain must be empty") |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestSinkCapsGroupsPerAppPerHour(t *testing.T) { |
| 44 | now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| 45 | s := NewSink("box1", func() time.Time { return now }) |
| 46 | for i := 0; i < MaxGroupsPerAppHour+5; i++ { |
| 47 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: fmt.Sprintf("500 GET /p%d-x", i), FP: fmt.Sprintf("fp%d", i)}) |
| 48 | } |
| 49 | s.Add("bab", "other", Record{Source: SourceEdge, Kind: KindHTTP, Name: "unaffected", FP: "z"}) |
| 50 | out := s.Drain() |
| 51 | web := out[applog.AppKey{Acct: "bab", App: "web"}] |
| 52 | if len(web) != MaxGroupsPerAppHour+1 { |
| 53 | t.Fatalf("web records = %d, want cap+1 (the overflow row)", len(web)) |
| 54 | } |
| 55 | var over *Record |
| 56 | for i := range web { |
| 57 | if web[i].FP == OverflowFP { |
| 58 | over = &web[i] |
| 59 | } |
| 60 | } |
| 61 | if over == nil || over.Count != 5 || over.Name != "500 GET /p1000-x" { |
| 62 | t.Fatalf("overflow = %+v", over) |
| 63 | } |
| 64 | if len(out[applog.AppKey{Acct: "bab", App: "other"}]) != 1 { |
| 65 | t.Fatal("a neighbour app must not be suppressed") |
| 66 | } |
| 67 | // The cap counts per hour: the next hour admits new groups again. |
| 68 | now = now.Add(time.Hour) |
| 69 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "fresh", FP: "fresh"}) |
| 70 | if recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}]; len(recs) != 1 || recs[0].FP != "fresh" { |
| 71 | t.Fatalf("next hour = %+v", recs) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // TestSinkDrainOverflowOnly: Drain resets the groups but not the per-hour |
| 76 | // `seen` cap counter, so an app that was already at the cap when last |
| 77 | // drained stays at the cap — the next genuinely new key goes straight to |
| 78 | // overflow, and a drain right after that Add must show nothing else. |
| 79 | func TestSinkDrainOverflowOnly(t *testing.T) { |
| 80 | now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| 81 | s := NewSink("box1", func() time.Time { return now }) |
| 82 | for i := 0; i < MaxGroupsPerAppHour; i++ { |
| 83 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: fmt.Sprintf("500 GET /p%d-x", i), FP: fmt.Sprintf("fp%d", i)}) |
| 84 | } |
| 85 | if recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}]; len(recs) != MaxGroupsPerAppHour { |
| 86 | t.Fatalf("fill drain = %d, want %d", len(recs), MaxGroupsPerAppHour) |
| 87 | } |
| 88 | // One brand-new key, nothing else: the cap counter survived the drain. |
| 89 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "brand new", FP: "brandnew"}) |
| 90 | recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}] |
| 91 | if len(recs) != 1 { |
| 92 | t.Fatalf("overflow-only drain = %d records, want 1: %+v", len(recs), recs) |
| 93 | } |
| 94 | if recs[0].FP != OverflowFP || recs[0].Count != 1 { |
| 95 | t.Fatalf("record = %+v", recs[0]) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestSinkSanitisesAndFingerprintsOnAdd(t *testing.T) { |
| 100 | now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| 101 | s := NewSink("box1", func() time.Time { return now }) |
| 102 | long := make([]byte, MaxStack+100) |
| 103 | for i := range long { |
| 104 | long[i] = 'x' |
| 105 | } |
| 106 | s.Add("bab", "web", Record{Source: SourceServer, Kind: "RuntimeError", Name: "mail to paul@tito.io bounced", |
| 107 | Path: "/reset/8f3a9c1e2b4d6f708a9b0c1d2e3f4a5b?t=1", Stack: string(long), |
| 108 | Fields: map[string]string{"user": "paul@tito.io", "k": "v"}}) |
| 109 | r := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}][0] |
| 110 | if r.Name != "mail to <email> bounced" || r.Path != "/reset/:token" || r.Fields["user"] != "<email>" || r.Fields["k"] != "v" { |
| 111 | t.Fatalf("sanitised = %+v", r) |
| 112 | } |
| 113 | if len(r.Stack) != MaxStack { |
| 114 | t.Fatalf("stack len %d, want %d", len(r.Stack), MaxStack) |
| 115 | } |
| 116 | if r.FP != Fingerprint(SourceServer, "RuntimeError", "mail to <email> bounced", r.Stack) || r.FPV != FPVersion { |
| 117 | t.Fatalf("fp = %q", r.FP) |
| 118 | } |
| 119 | if r.Count != 1 || !r.First.Equal(now) || !r.Last.Equal(now) || !r.TS.Equal(now) { |
| 120 | t.Fatalf("stamps = %+v", r) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // TestSinkCapCountsDistinctKeysAcrossDrains replays the ship loop: a 10 s |
| 125 | // drain, a handful of distinct errors recurring in every tick. Six distinct |
| 126 | // errors must never overflow, however many ticks the hour has. |
| 127 | func TestSinkCapCountsDistinctKeysAcrossDrains(t *testing.T) { |
| 128 | base := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| 129 | clock := base |
| 130 | s := NewSink("box1", func() time.Time { return clock }) |
| 131 | for tick := 0; tick < 360; tick++ { |
| 132 | clock = base.Add(time.Duration(tick*10) * time.Second) |
| 133 | for i := 0; i < 6; i++ { |
| 134 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: fmt.Sprintf("500 GET /p%d", i), Host: "h1", Release: "v1"}) |
| 135 | } |
| 136 | for _, recs := range s.Drain() { |
| 137 | for _, r := range recs { |
| 138 | if r.FP == OverflowFP { |
| 139 | t.Fatalf("overflow at tick %d with only 6 distinct errors", tick) |
| 140 | } |
| 141 | } |
| 142 | if len(recs) != 6 { |
| 143 | t.Fatalf("tick %d drained %d records, want 6", tick, len(recs)) |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | // The cap still binds on genuinely distinct keys within the hour, and |
| 148 | // a key already seen this hour is admitted even after the cap is hit. |
| 149 | clock = base.Add(30 * time.Minute) |
| 150 | for i := 0; i < MaxGroupsPerAppHour+5; i++ { |
| 151 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "x", FP: fmt.Sprintf("d%d", i)}) |
| 152 | } |
| 153 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /p0", Host: "h1", Release: "v1"}) // seen this hour: admitted, not overflow |
| 154 | out := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}] |
| 155 | var over, p0 int |
| 156 | for _, r := range out { |
| 157 | if r.FP == OverflowFP { |
| 158 | over = r.Count |
| 159 | } |
| 160 | if r.Name == "500 GET /p0" && r.FP != OverflowFP { |
| 161 | p0++ |
| 162 | } |
| 163 | } |
| 164 | if over != 11 || p0 != 1 { // 6 recurring + 994 new = 1000; 11 new beyond it overflow; p0 was seen |
| 165 | t.Fatalf("overflow count = %d (want 11), p0 admitted = %d (want 1)", over, p0) |
| 166 | } |
| 167 | // A new hour starts the count over. |
| 168 | clock = base.Add(time.Hour) |
| 169 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "fresh", FP: "fresh"}) |
| 170 | if recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}]; len(recs) != 1 || recs[0].FP != "fresh" { |
| 171 | t.Fatalf("next hour = %+v", recs) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // TestSinkOverflowPerFamily: past the hourly cap, an app's own errors and |
| 176 | // its availability failures overflow into separate rows, so neither tab's |
| 177 | // excess is counted on the other's. |
| 178 | func TestSinkOverflowPerFamily(t *testing.T) { |
| 179 | now := time.Date(2026, 9, 24, 10, 0, 0, 0, time.UTC) |
| 180 | s := NewSink("box1", func() time.Time { return now }) |
| 181 | for i := 0; i < MaxGroupsPerAppHour; i++ { |
| 182 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /p" + strconv.Itoa(i), Status: 500, Host: "h1"}) |
| 183 | } |
| 184 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Host: "h1"}) |
| 185 | for i := 0; i < 3; i++ { |
| 186 | s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindUnavailable, Cause: CauseRefused, Name: AvailabilityName(502, CauseRefused) + strconv.Itoa(i), Status: 502, Host: "h1"}) |
| 187 | } |
| 188 | var errs, avail int |
| 189 | for _, r := range s.Drain()[applog.AppKey{Acct: "bab", App: "web"}] { |
| 190 | if r.FP != OverflowFP { |
| 191 | continue |
| 192 | } |
| 193 | if IsAvailability(r) { |
| 194 | avail += r.Count |
| 195 | } else { |
| 196 | errs += r.Count |
| 197 | } |
| 198 | } |
| 199 | if errs != 1 || avail != 3 { |
| 200 | t.Fatalf("overflow errors=%d availability=%d, want 1 and 3", errs, avail) |
| 201 | } |
| 202 | if got := AsAvailability(Record{Source: SourceEdge, Kind: KindHTTP, Status: 502, FP: OverflowFP}); got.FP != OverflowFP { |
| 203 | t.Fatalf("the overflow row was regrouped: %+v", got) |
| 204 | } |
| 205 | } |
| 206 | |