Skip to content

Commit 056ee8f

Browse files
committed
fix(errortracking): address bouncer double-count, cap bypass, and async stack capture
- Rollover now delivers priorCount-1 (suppressed-only) to avoid double- counting the first sighting already emitted; silent rollover when no suppressions occurred (priorCount==1) - Re-check cap after pruneLocked: drop new key as pass-through when all entries are still within the window, enforcing the memory bound - Add SyncCapture handler wrapper that pre-captures the call stack in the emitting goroutine and stores it as a slog attr; Handler.Handle reads the attr before falling back to runtime.Callers, fixing full- stack dedup across an async handler boundary
1 parent f18b1d1 commit 056ee8f

3 files changed

Lines changed: 171 additions & 51 deletions

File tree

pkg/util/log/errortracking/bouncer.go

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import (
1616
// time as firstSeen. Subsequent observations of the same key inside the
1717
// same window are suppressed (return suppressed=true) and the count is
1818
// incremented. After the window elapses, the next observation returns
19-
// suppressed=false carrying the prior window's total count (so the
20-
// delivered record's Count field reflects every sighting in that
21-
// window), then resets the entry to a fresh count=1.
19+
// suppressed=false carrying the suppressed count of the prior window
20+
// (priorTotal-1, since the first sighting was already delivered), then
21+
// resets the entry to a fresh count=1. If no sightings were suppressed
22+
// (priorTotal==1), the rollover is silent and the next observation is
23+
// treated as a fresh first sighting (count=1).
2224
//
2325
// The key is an opaque uint64 — the caller is responsible for choosing
2426
// it. Today's caller (Handler.Handle) hashes the captured stack PCs
@@ -29,10 +31,9 @@ import (
2931
// (agent_telemetry.errortracking.bouncer_window_seconds) was chosen so a hot
3032
// bug path collapses to one record per quarter-hour — long enough to avoid
3133
// flooding the wire, short enough that operators see new error patterns
32-
// promptly. Count on the delivered record carries the total sightings of
33-
// the prior window so suppressed duplicates are not lost; a hot bug path
34-
// with N sightings per window ships one record per window with Count=N,
35-
// rather than N records each carrying Count=1.
34+
// promptly. A hot bug path with N sightings per window ships Count=1 at
35+
// first sighting, then Count=N-1 on rollover (the suppressed portion).
36+
// Summing both gives N without double-counting the first sighting.
3637
//
3738
// Bouncer is purpose-built rather than reusing the global
3839
// rate.Sometimes wrapper in pkg/util/log/log_limit.go — that primitive
@@ -73,19 +74,24 @@ func NewBouncer(window time.Duration, maxEntries int) *Bouncer {
7374
}
7475

7576
// Observe records a sighting of the given key at now and returns
76-
// whether the caller should suppress the record, the running count of
77-
// sightings represented by the next delivered record (≥ 1), and the
78-
// firstSeen time of the current window.
77+
// whether the caller should suppress the record, the count to attach to
78+
// the next delivered record (≥ 1), and the firstSeen time of the
79+
// current window.
7980
//
8081
// The first sighting of a key in a window returns suppressed=false,
8182
// count=1, firstSeen=now. Subsequent sightings inside the same window
8283
// return suppressed=true with an incrementing count and the original
8384
// firstSeen. When the window elapses since firstSeen, the next
84-
// sighting returns suppressed=false with count=priorWindowCount (the
85-
// total sightings observed during the elapsed window), then resets
86-
// the entry to a fresh count=1. The wire payload's Count field on the
87-
// delivered record therefore carries the total occurrences of the
88-
// prior window — no suppressed sightings are lost.
85+
// sighting returns suppressed=false with count=priorSuppressed (the
86+
// number of sightings that were suppressed in the elapsed window, i.e.
87+
// priorTotal-1), then resets the entry to a fresh count=1. If no
88+
// sightings were suppressed (priorTotal==1), the rollover is silent:
89+
// Observe returns suppressed=false, count=1, as though it were a fresh
90+
// first sighting, avoiding a Count=0 delivery.
91+
//
92+
// This design ensures consumers can sum Count fields without
93+
// double-counting: the first delivery carries Count=1, and the rollover
94+
// carries the remainder.
8995
//
9096
// When window is non-positive, Observe is a pass-through: returns
9197
// suppressed=false, count=1, firstSeen=now.
@@ -99,25 +105,35 @@ func (b *Bouncer) Observe(key uint64, now time.Time) (suppressed bool, count uin
99105

100106
if e, ok := b.entries[key]; ok {
101107
if now.Sub(e.firstSeen) > b.window {
102-
// Window elapsed — the delivered record carries the prior
103-
// window's total count so suppressed duplicates are not
104-
// lost. Reset the entry to a fresh window.
108+
// Window elapsed. Deliver the suppressed portion of the prior
109+
// window (priorTotal-1) so callers can sum without
110+
// double-counting the first sighting already delivered.
111+
// When nothing was suppressed (priorTotal==1), skip the
112+
// rollover and treat this as a fresh first sighting.
105113
priorCount := e.count
106114
e.firstSeen = now
107115
e.lastSeen = now
108116
e.count = 1
109-
return false, priorCount, now
117+
if priorCount <= 1 {
118+
return false, 1, now
119+
}
120+
return false, priorCount - 1, now
110121
}
111122
e.lastSeen = now
112123
e.count++
113124
return true, e.count, e.firstSeen
114125
}
115126

116-
// New key. Prune opportunistically if we're approaching the cap so
117-
// the map doesn't grow unboundedly under pathological churn (many
118-
// unique keys, each appearing once).
127+
// New key. Prune opportunistically if at the cap so the map doesn't
128+
// grow unboundedly under pathological churn (many unique keys, each
129+
// appearing once). Re-check after pruning: if all entries are still
130+
// within the window, pruneLocked removes nothing and the cap must be
131+
// enforced by dropping the new key (pass-through, not suppressed).
119132
if len(b.entries) >= b.maxEntries {
120133
b.pruneLocked(now)
134+
if len(b.entries) >= b.maxEntries {
135+
return false, 1, now
136+
}
121137
}
122138
b.entries[key] = &bouncerEntry{
123139
firstSeen: now,

pkg/util/log/errortracking/bouncer_test.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,22 @@ func TestBouncer_ResetsAfterWindow(t *testing.T) {
4343
b.Observe(0xCAFE, t0.Add(time.Minute)) // count=2 (suppressed)
4444

4545
// Step past the window — next sighting MUST NOT be suppressed and
46-
// MUST carry the prior window's total count (2) rather than
47-
// resetting to 1. That preserves the suppressed-duplicate count on
48-
// the delivered wire record. The entry is then reset to a fresh
46+
// MUST carry the suppressed count of the prior window (1 = total-1)
47+
// rather than the full total, to avoid double-counting the first
48+
// sighting already delivered. The entry is then reset to a fresh
4949
// window internally.
5050
t2 := t0.Add(16 * time.Minute)
5151
suppressed, count, firstSeen := b.Observe(0xCAFE, t2)
5252
assert.False(t, suppressed, "sighting after window MUST NOT be suppressed")
53-
assert.Equal(t, uint32(2), count, "post-window sighting count (want prior-window total)")
53+
assert.Equal(t, uint32(1), count, "post-window count (want suppressed-only: total-1)")
5454
assert.True(t, firstSeen.Equal(t2), "post-window firstSeen = %v, want %v", firstSeen, t2)
5555
}
5656

5757
// TestBouncer_WindowElapseCarriesPriorCount exercises the
5858
// suppressed-count carry-forward contract end-to-end: a hot bug path
59-
// with N sightings per window must deliver one record per window with
60-
// Count=N (the total occurrences of the prior window), not N records
61-
// each carrying Count=1.
59+
// with N sightings per window must deliver Count=1 at first sighting
60+
// and Count=N-1 on rollover (the suppressed sightings), so consumers
61+
// summing both get N without double-counting the first sighting.
6262
func TestBouncer_WindowElapseCarriesPriorCount(t *testing.T) {
6363
b := NewBouncer(15*time.Minute, 0)
6464
var key uint64 = 0xABCDEF
@@ -79,11 +79,11 @@ func TestBouncer_WindowElapseCarriesPriorCount(t *testing.T) {
7979
assert.True(t, suppressed, "sighting inside window MUST be suppressed")
8080
assert.Equal(t, uint32(3), count, "third sighting count")
8181

82-
// Window elapses; next sighting is delivered with the prior
83-
// window's total (3).
82+
// Window elapses; next sighting delivers the suppressed count of the
83+
// prior window (2 = total(3) - first-already-delivered(1)).
8484
suppressed, count, _ = b.Observe(key, t0.Add(20*time.Minute))
8585
assert.False(t, suppressed, "window elapsed; sighting must be delivered")
86-
assert.Equal(t, uint32(3), count, "delivered record count (want prior-window total)")
86+
assert.Equal(t, uint32(2), count, "rollover count (want suppressed-only: total-1)")
8787

8888
// Subsequent sighting in the new window — suppressed, count starts
8989
// from the post-reset 1 and increments to 2.
@@ -152,3 +152,26 @@ func TestBouncer_PrunesNearCap(t *testing.T) {
152152
defer b.mu.Unlock()
153153
assert.LessOrEqual(t, len(b.entries), b.maxEntries, "entries exceeded cap after prune")
154154
}
155+
156+
// TestBouncer_CapEnforced_WhenAllWithinWindow verifies that when the cap
157+
// is reached and pruneLocked removes nothing (all entries still within the
158+
// window), new keys are dropped rather than growing the map past maxEntries.
159+
// The dropped observation is returned as a pass-through (suppressed=false,
160+
// count=1) so the record still reaches the consumer without dedup tracking.
161+
func TestBouncer_CapEnforced_WhenAllWithinWindow(t *testing.T) {
162+
b := NewBouncer(time.Hour, 4) // large window: no entries expire
163+
t0 := time.Now()
164+
for pc := uint64(1); pc <= 4; pc++ {
165+
b.Observe(pc, t0)
166+
}
167+
168+
// New unique key inserted while all existing entries are still within window.
169+
t1 := t0.Add(time.Minute)
170+
suppressed, count, _ := b.Observe(uint64(5), t1)
171+
assert.False(t, suppressed, "dropped key must not be reported as suppressed")
172+
assert.Equal(t, uint32(1), count, "dropped key must return count=1 (pass-through)")
173+
174+
b.mu.Lock()
175+
defer b.mu.Unlock()
176+
assert.LessOrEqual(t, len(b.entries), b.maxEntries, "map must not exceed maxEntries")
177+
}

pkg/util/log/errortracking/handler.go

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ import (
1919
// between runtime.Callers and the user call site.
2020
const stackSearchBuf = MaxStackFrames + 16
2121

22+
// stackPCsAttrKey is the slog attribute key used by SyncCapture to carry
23+
// stack PCs captured in the emitting goroutine across an async handler
24+
// boundary. Handler.Handle reads this attr before falling back to
25+
// runtime.Callers so full-stack dedup is preserved even when the handler
26+
// runs in an async worker goroutine.
27+
const stackPCsAttrKey = "errortracking.pcs"
28+
2229
var _ slog.Handler = (*Handler)(nil)
2330

2431
// Handler is an slog.Handler that captures records at level >= Error and
@@ -121,28 +128,42 @@ func (h *Handler) Handle(_ context.Context, r slog.Record) error {
121128
return nil
122129
}
123130

124-
// Capture the full goroutine stack and anchor the slice at r.PC —
125-
// the call-site PC already computed by the caller (slog or the
126-
// pkg/util/log Wrapper). This is correct regardless of how many
127-
// wrapper frames sit between user code and Handle, whereas a fixed
128-
// skip would land inside the wrapper internals when the path goes
129-
// through pkg/util/log.Error → Wrapper → Handler.Handle.
130-
var buf [stackSearchBuf]uintptr
131-
n := runtime.Callers(1, buf[:]) // skip runtime.Callers itself
131+
// Capture the full call stack anchored at r.PC.
132+
//
133+
// Preferred path: read PCs pre-captured by a SyncCapture wrapper that
134+
// ran synchronously in the emitting goroutine before any async
135+
// dispatch. Present when the wiring layer uses NewSyncCapture.
136+
//
137+
// Fallback path: call runtime.Callers here. Correct only when Handle
138+
// is called synchronously in the emitting goroutine (i.e. the handler
139+
// is NOT behind handlers.NewAsync or any other async boundary).
132140
var pcs [MaxStackFrames]uintptr
133141
var pcsLen int
134-
for i := 0; i < n; i++ {
135-
if buf[i] == r.PC {
136-
pcsLen = copy(pcs[:], buf[i:n])
137-
break
142+
r.Attrs(func(a slog.Attr) bool {
143+
if a.Key == stackPCsAttrKey {
144+
if captured, ok := a.Value.Any().([]uintptr); ok {
145+
pcsLen = copy(pcs[:], captured)
146+
}
147+
return false
148+
}
149+
return true
150+
})
151+
if pcsLen == 0 {
152+
var buf [stackSearchBuf]uintptr
153+
n := runtime.Callers(1, buf[:]) // skip runtime.Callers itself
154+
for i := 0; i < n; i++ {
155+
if buf[i] == r.PC {
156+
pcsLen = copy(pcs[:], buf[i:n])
157+
break
158+
}
159+
}
160+
if pcsLen == 0 && r.PC != 0 {
161+
// r.PC not found in the captured slice (async path or
162+
// synthetic record): store it alone so downstream consumers
163+
// always have at least a valid call-site frame.
164+
pcs[0] = r.PC
165+
pcsLen = 1
138166
}
139-
}
140-
if pcsLen == 0 && r.PC != 0 {
141-
// r.PC not found in the captured slice (e.g. synthetic record
142-
// in tests): store it alone so downstream consumers always
143-
// have at least a valid call-site frame.
144-
pcs[0] = r.PC
145-
pcsLen = 1
146167
}
147168

148169
var count uint32
@@ -214,6 +235,66 @@ func hashPCs(pcs []uintptr) uint64 {
214235
return h.Sum64()
215236
}
216237

238+
// SyncCapture wraps any slog.Handler and pre-captures the goroutine's full
239+
// call stack in the emitting goroutine before forwarding the record to the
240+
// inner handler. This solves the async-boundary problem: when the inner
241+
// handler is dispatched by an async worker goroutine, runtime.Callers can
242+
// no longer see the original caller's frames. SyncCapture must be placed in
243+
// the synchronous layer of the logger chain (before handlers.NewAsync or any
244+
// other async wrapper) so that Handle is called in the same goroutine that
245+
// emitted the log record.
246+
//
247+
// Handler.Handle reads the pre-captured PCs from the stackPCsAttrKey slog
248+
// attribute added by SyncCapture.Handle, bypassing its own runtime.Callers
249+
// call when the attr is present.
250+
type SyncCapture struct {
251+
inner slog.Handler
252+
}
253+
254+
var _ slog.Handler = (*SyncCapture)(nil)
255+
256+
// NewSyncCapture returns a SyncCapture that wraps inner. Install the returned
257+
// handler in the synchronous layer of the logger chain so it is always called
258+
// from the emitting goroutine.
259+
func NewSyncCapture(inner slog.Handler) *SyncCapture {
260+
return &SyncCapture{inner: inner}
261+
}
262+
263+
// Enabled delegates to the inner handler.
264+
func (s *SyncCapture) Enabled(ctx context.Context, level slog.Level) bool {
265+
return s.inner.Enabled(ctx, level)
266+
}
267+
268+
// Handle captures the current goroutine's call stack anchored at r.PC,
269+
// attaches the PCs as a stackPCsAttrKey slog attribute, then forwards to
270+
// the inner handler. Must be called from the goroutine that emitted r.
271+
func (s *SyncCapture) Handle(ctx context.Context, r slog.Record) error {
272+
if r.Level < slog.LevelError {
273+
return s.inner.Handle(ctx, r)
274+
}
275+
var buf [stackSearchBuf]uintptr
276+
n := runtime.Callers(1, buf[:])
277+
for i := 0; i < n; i++ {
278+
if buf[i] == r.PC {
279+
pcs := make([]uintptr, n-i)
280+
copy(pcs, buf[i:n])
281+
r.AddAttrs(slog.Any(stackPCsAttrKey, pcs))
282+
break
283+
}
284+
}
285+
return s.inner.Handle(ctx, r)
286+
}
287+
288+
// WithAttrs returns a SyncCapture wrapping the inner handler's WithAttrs result.
289+
func (s *SyncCapture) WithAttrs(attrs []slog.Attr) slog.Handler {
290+
return &SyncCapture{inner: s.inner.WithAttrs(attrs)}
291+
}
292+
293+
// WithGroup returns a SyncCapture wrapping the inner handler's WithGroup result.
294+
func (s *SyncCapture) WithGroup(name string) slog.Handler {
295+
return &SyncCapture{inner: s.inner.WithGroup(name)}
296+
}
297+
217298
// WithAttrs is a required slog.Handler interface method. Attrs are not
218299
// shipped to the wire; this method is a required interface no-op.
219300
func (h *Handler) WithAttrs(_ []slog.Attr) slog.Handler {

0 commit comments

Comments
 (0)