Skip to content

Commit 010f195

Browse files
committed
fix(dashboard): truncate all caller-controlled span fields, wire max-spans-per-trace, and close the untested retention gaps
Extends the truncation fix to http.path, http.host, and the span Name (all three were still unbounded and the path was stored twice per span). Wires TraceMaxSpansPerTrace through Config/MemoryProfile into the trace store constructor, so the per-trace cap is finally reachable outside tests. Adds an integration test that drives the real TracingMiddleware and inspects the stored span, and a test pinning down that trace retention keeps evicting even while a saturated trace makes every insert on it fail.
1 parent cb87fb8 commit 010f195

5 files changed

Lines changed: 176 additions & 8 deletions

File tree

extensions/dashboard/collector/trace_store_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,84 @@ func TestTraceStore_TTLGateOpensOnAccessAndLapses(t *testing.T) {
290290
}
291291
}
292292

293+
// TestTraceStore_EvictsExpiredTraceEvenWhileAnotherTraceIsSaturated pins down
294+
// the guarantee documented on the ts.evict() call inside AddSpan's
295+
// cap-exceeded branch: retention must not become conditional on a successful
296+
// insert. Without that call, a store whose only traffic is one saturated
297+
// trace ("hot") would never call evict() again — every further AddSpan on
298+
// "hot" takes the early-return cap-rejection branch — so an unrelated expired
299+
// trace ("old") would sit in memory forever instead of aging out.
300+
//
301+
// "old" is seeded directly into the store's internals (this test lives in
302+
// package collector, so it can) rather than through AddSpan, for two reasons.
303+
// First, the span() helper always stamps StartTime with time.Now(), so an
304+
// already-expired span has to be built by hand. Second, and more subtly, if
305+
// "old" were seeded before "hot" existed, the unconditional ts.evict() call
306+
// at the end of AddSpan's normal (non-cap) path — the one that runs on every
307+
// successful insert, not only the cap branch — would reclaim it the moment
308+
// "hot"'s first (still normal-path) span landed, long before "hot" ever
309+
// saturates. So "hot" is saturated first, while "old" does not exist yet,
310+
// and "old" is only seeded afterward, bypassing AddSpan entirely so its
311+
// insertion triggers no evict() call of its own. It is placed ahead of
312+
// "hot" in eviction order, because evict() walks oldest-first and stops at
313+
// the first non-expired trace — "hot" is fresh, so if it came first, evict()
314+
// would never even look at "old".
315+
func TestTraceStore_EvictsExpiredTraceEvenWhileAnotherTraceIsSaturated(t *testing.T) {
316+
const retention = 200 * time.Millisecond
317+
const spanCap = 3
318+
319+
ts := NewTraceStore(10, retention, WithMaxSpansPerTrace(spanCap))
320+
321+
// Saturate "hot" first, before "old" exists, so nothing here can
322+
// accidentally evict "old".
323+
for i := 0; i < spanCap; i++ {
324+
ts.AddSpan(span("hot", "span"))
325+
}
326+
327+
ts.mu.RLock()
328+
hotCount := len(ts.traces["hot"])
329+
ts.mu.RUnlock()
330+
if hotCount != spanCap {
331+
t.Fatalf("trace \"hot\" has %d spans after saturating, want %d", hotCount, spanCap)
332+
}
333+
334+
expiredStart := time.Now().Add(-time.Hour)
335+
oldSpan := &SpanView{
336+
SpanID: "span",
337+
TraceID: "old",
338+
Name: "GET /x",
339+
Kind: SpanKindServer,
340+
Status: SpanStatusOK,
341+
StartTime: expiredStart,
342+
EndTime: expiredStart.Add(time.Millisecond),
343+
Duration: time.Millisecond,
344+
Attributes: map[string]string{},
345+
Events: []SpanEventView{},
346+
}
347+
ts.mu.Lock()
348+
ts.traces["old"] = []*SpanView{oldSpan}
349+
ts.order = append([]string{"old"}, ts.order...)
350+
ts.mu.Unlock()
351+
352+
// From here, every AddSpan on "hot" takes the cap-rejection branch
353+
// exclusively (its span count is already at the cap and no insert can
354+
// ever succeed again). That branch's evict() call is the only thing
355+
// that can still reclaim "old".
356+
for i := 0; i < 20; i++ {
357+
ts.AddSpan(span("hot", "span"))
358+
359+
ts.mu.RLock()
360+
_, stillPresent := ts.traces["old"]
361+
ts.mu.RUnlock()
362+
363+
if !stillPresent {
364+
return // "old" was reclaimed: the guarantee holds.
365+
}
366+
}
367+
368+
t.Error("trace \"old\" was never evicted while \"hot\" stayed saturated at its cap")
369+
}
370+
293371
// MarkAccessed runs on the request path alongside AddSpan. It must not race.
294372
func TestTraceStore_MarkAccessedIsRaceFree(t *testing.T) {
295373
ts := NewTraceStore(100, time.Hour)

extensions/dashboard/config.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ type Config struct {
5858
// Tracing
5959
TraceMaxCount int `json:"trace_max_count" yaml:"trace_max_count"`
6060
TraceRetention time.Duration `json:"trace_retention" yaml:"trace_retention"`
61+
// TraceMaxSpansPerTrace caps how many spans one trace retains. A single
62+
// long-lived trace, such as a websocket, would otherwise grow unbounded.
63+
TraceMaxSpansPerTrace int `json:"trace_max_spans_per_trace" yaml:"trace_max_spans_per_trace"`
6164
// TraceIdleTTL is how long after the last dashboard request spans keep being
6265
// retained. A negative duration disables the gate, retaining always (the
6366
// pre-gate behaviour). Zero is not a reliable way to disable it: under a
@@ -126,9 +129,10 @@ func DefaultConfig() Config {
126129
HistoryDuration: 30 * time.Minute,
127130
MaxDataPoints: 120,
128131

129-
TraceMaxCount: 200,
130-
TraceRetention: 30 * time.Minute,
131-
TraceIdleTTL: 5 * time.Minute,
132+
TraceMaxCount: 200,
133+
TraceRetention: 30 * time.Minute,
134+
TraceMaxSpansPerTrace: 200,
135+
TraceIdleTTL: 5 * time.Minute,
132136

133137
ProxyTimeout: 10 * time.Second,
134138
CacheMaxSize: 100,
@@ -279,6 +283,11 @@ func WithTraceRetention(duration time.Duration) ConfigOption {
279283
return func(c *Config) { c.TraceRetention = duration }
280284
}
281285

286+
// WithTraceMaxSpansPerTrace sets how many spans a single trace retains.
287+
func WithTraceMaxSpansPerTrace(n int) ConfigOption {
288+
return func(c *Config) { c.TraceMaxSpansPerTrace = n }
289+
}
290+
282291
// WithTraceIdleTTL sets how long after the last dashboard request traces keep
283292
// being collected. Pass a negative duration to collect always, disabling the
284293
// gate. Passing zero is not reliable for this: under a ConfigManager, config
@@ -391,20 +400,23 @@ func WithMemoryProfile(profile MemoryProfile) ConfigOption {
391400
c.HistoryDuration = 15 * time.Minute
392401
c.TraceMaxCount = 50
393402
c.TraceRetention = 10 * time.Minute
403+
c.TraceMaxSpansPerTrace = 50
394404
c.TraceIdleTTL = 1 * time.Minute
395405
c.CacheMaxSize = 50
396406
case MemoryProfileHigh:
397407
c.MaxDataPoints = 500
398408
c.HistoryDuration = 1 * time.Hour
399409
c.TraceMaxCount = 1000
400410
c.TraceRetention = 1 * time.Hour
411+
c.TraceMaxSpansPerTrace = 500
401412
c.TraceIdleTTL = 15 * time.Minute
402413
c.CacheMaxSize = 500
403414
default: // medium — same as DefaultConfig
404415
c.MaxDataPoints = 120
405416
c.HistoryDuration = 30 * time.Minute
406417
c.TraceMaxCount = 200
407418
c.TraceRetention = 30 * time.Minute
419+
c.TraceMaxSpansPerTrace = 200
408420
c.TraceIdleTTL = 5 * time.Minute
409421
c.CacheMaxSize = 100
410422
}

extensions/dashboard/extension.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ func (e *Extension) Register(app forge.App) error {
213213
e.collector.SetCacheTTL(e.config.RefreshInterval)
214214

215215
// Initialize trace store for dashboard tracing UI
216-
e.traceStore = collector.NewTraceStore(e.config.TraceMaxCount, e.config.TraceRetention)
216+
e.traceStore = collector.NewTraceStore(e.config.TraceMaxCount, e.config.TraceRetention, collector.WithMaxSpansPerTrace(e.config.TraceMaxSpansPerTrace))
217217

218218
// Initialize SSE broker (if real-time is enabled)
219219
if e.config.EnableRealtime {

extensions/dashboard/tracing_middleware.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,14 @@ func TracingMiddleware(store *collector.TraceStore, basePath string) forge.Middl
109109
status = collector.SpanStatusError
110110
}
111111

112-
// Build attributes.
112+
// Build attributes. http.path and http.host are caller-controlled
113+
// and unbounded (Go accepts a request line up to MaxHeaderBytes+4096,
114+
// about 1MB by default), so they are truncated at the point of
115+
// storage just like the query and user-agent attributes below.
113116
attrs := map[string]string{
114117
"http.method": req.Method,
115-
"http.path": path,
116-
"http.host": req.Host,
118+
"http.path": truncateAttr(req.URL.Path, maxAttrValueLen),
119+
"http.host": truncateAttr(req.Host, maxAttrValueLen),
117120
"protocol": protocol,
118121
}
119122
if req.URL.RawQuery != "" {
@@ -129,7 +132,7 @@ func TracingMiddleware(store *collector.TraceStore, basePath string) forge.Middl
129132
span := &collector.SpanView{
130133
SpanID: spanID,
131134
TraceID: traceID,
132-
Name: req.Method + " " + path,
135+
Name: truncateAttr(req.Method+" "+path, maxAttrValueLen),
133136
Kind: collector.SpanKindServer,
134137
Status: status,
135138
StartTime: start,

extensions/dashboard/tracing_middleware_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,78 @@ func TestTracingMiddleware_StampsAccessOnDashboardRequests(t *testing.T) {
143143
})
144144
}
145145
}
146+
147+
// TestTracingMiddleware_TruncatesStoredAttributes drives the real
148+
// TracingMiddleware end to end and inspects what actually landed in the
149+
// TraceStore. Unit tests on truncateAttr alone cannot see this: the reviewer
150+
// deleted every truncateAttr call site inside TracingMiddleware and the
151+
// truncateAttr unit tests, plus the rest of the suite, stayed green — nothing
152+
// exercised the middleware's own attribute construction. This test also
153+
// covers the http.path, http.host, and span Name fields, which were never
154+
// truncated at all: all three are caller-controlled (a request line can run
155+
// to about 1MB by default) and the path was being stored twice per span,
156+
// unbounded, for the whole retention window.
157+
func TestTracingMiddleware_TruncatesStoredAttributes(t *testing.T) {
158+
const basePath = "/dashboard"
159+
160+
store := collector.NewTraceStore(10, time.Hour)
161+
// No ingest gate installed: a store with no gate retains everything. If a
162+
// gate were installed here and left closed, AddSpan would silently discard
163+
// the span and every assertion below would pass vacuously.
164+
165+
noopNext := func(ctx forge.Context) error { return nil }
166+
167+
longQuery := strings.Repeat("q", 5000)
168+
longUA := strings.Repeat("a", 5000)
169+
longPath := "/" + strings.Repeat("p", 5000)
170+
longHost := strings.Repeat("h", 5000) + ".example.com"
171+
172+
req := httptest.NewRequest("GET", longPath+"?"+longQuery, nil)
173+
req.Host = longHost
174+
req.Header.Set("User-Agent", longUA)
175+
w := httptest.NewRecorder()
176+
ctx := forge_http.NewContext(w, req, nil)
177+
178+
middleware := TracingMiddleware(store, basePath)
179+
handler := middleware(noopNext)
180+
181+
if err := handler(ctx); err != nil {
182+
t.Fatalf("unexpected error handling request: %v", err)
183+
}
184+
185+
// The middleware picks its own trace ID from time.Now(), so recover it by
186+
// listing the store rather than guessing it.
187+
summaries, _ := store.ListTraces(collector.TraceFilter{})
188+
if len(summaries) != 1 {
189+
t.Fatalf("store has %d traces after one traced request, want 1", len(summaries))
190+
}
191+
traceID := summaries[0].TraceID
192+
193+
detail := store.GetTrace(traceID)
194+
if detail == nil {
195+
t.Fatalf("GetTrace(%q) returned nil", traceID)
196+
}
197+
if len(detail.Spans) != 1 {
198+
t.Fatalf("trace has %d spans, want 1", len(detail.Spans))
199+
}
200+
201+
span := detail.Spans[0]
202+
203+
if len(span.Name) > maxAttrValueLen {
204+
t.Errorf("stored span Name is %d bytes, want at most %d", len(span.Name), maxAttrValueLen)
205+
}
206+
207+
for key, val := range span.Attributes {
208+
if len(val) > maxAttrValueLen {
209+
t.Errorf("stored attribute %q is %d bytes, want at most %d", key, len(val), maxAttrValueLen)
210+
}
211+
}
212+
213+
// Spot-check the two attributes the earlier review found unbounded.
214+
if got := len(span.Attributes["http.path"]); got > maxAttrValueLen {
215+
t.Errorf("http.path attribute is %d bytes, want at most %d", got, maxAttrValueLen)
216+
}
217+
if got := len(span.Attributes["http.host"]); got > maxAttrValueLen {
218+
t.Errorf("http.host attribute is %d bytes, want at most %d", got, maxAttrValueLen)
219+
}
220+
}

0 commit comments

Comments
 (0)