carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package apperr
2
3import (
4 "fmt"
5 "strconv"
6 "testing"
7 "time"
8
9 "github.com/carlosframework/platform/internal/applog"
10)
11
12func 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
43func 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.
79func 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
99func 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.
127func 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.
178func 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