package main import ( "context" "errors" "fmt" "math" "strings" "testing" "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) // newTestSink wires a real TracerProvider to the sink so spans flow through the // genuine OTel End → OnEnd path (the same one production uses), not a hand-built // fake. The hub has no frame ticker (struct literal), so add() is observable // synchronously via snapshot(). func newTestSink(t *testing.T) (*traceHub, *sdktrace.TracerProvider) { t.Helper() hub := &traceHub{subs: map[chan struct{}]struct{}{}} tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(traceSink{hub: hub})) t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) return hub, tp } // The security contract: only allow-listed, content-free shape attributes may // reach the public view. IP, User-Agent, and the real issue id in url.path are // the attributes the otelhttp server span actually carries (verified against the // live deps), so they are the explicit must-drop set. func TestSanitizeSpanAllowList(t *testing.T) { hub, tp := newTestSink(t) _, span := tp.Tracer("test").Start(context.Background(), "GET /i/{id}") span.SetAttributes( attribute.String("db.strategy", "fts"), attribute.Int("db.rows", 42), attribute.Bool("query.has_text", true), attribute.Int("http.response.status_code", 200), // Sensitive — must never survive sanitization. attribute.String("client.address", "198.51.100.9"), attribute.String("network.peer.address", "203.0.113.7"), attribute.Int("network.peer.port", 51234), attribute.String("user_agent.original", "Mozilla/5.0 (probe)"), attribute.String("url.path", "/i/AbC123"), attribute.String("url.query", "text=secretquery&author=alice"), attribute.String("server.address", "dbugs.example"), ) span.End() spans := hub.snapshot() if len(spans) != 1 { t.Fatalf("got %d spans, want 1", len(spans)) } got := map[string]string{} for _, a := range spans[0].attrs { got[a.k] = a.v } for _, k := range []string{ "client.address", "network.peer.address", "network.peer.port", "user_agent.original", "url.path", "url.query", "server.address", } { if v, ok := got[k]; ok { t.Errorf("sensitive attr %q leaked to trace view (=%q)", k, v) } } for _, k := range []string{"db.strategy", "db.rows", "query.has_text", "http.response.status_code"} { if _, ok := got[k]; !ok { t.Errorf("allow-listed attr %q was dropped", k) } } if got["db.strategy"] != "fts" || got["db.rows"] != "42" { t.Errorf("attr values wrong: %v", got) } // The span NAME conveys the route without the id; the id itself (url.path) is // gone, so the name must be the bounded route, not the real path. if spans[0].name != "GET /i/{id}" { t.Errorf("name = %q, want %q", spans[0].name, "GET /i/{id}") } } // An error span surfaces only the FACT of an error — never the status // description or the exception event message, which can carry arbitrary content. func TestSanitizeSpanErrorDropsDetail(t *testing.T) { hub, tp := newTestSink(t) _, span := tp.Tracer("test").Start(context.Background(), "db.count") span.RecordError(errors.New("no such table: secret_internal")) span.SetStatus(codes.Error, "boom: leak this detail") span.End() spans := hub.snapshot() if len(spans) != 1 || !spans[0].isError { t.Fatalf("want one error span, got %+v", spans) } for _, a := range spans[0].attrs { if strings.Contains(a.v, "secret") || strings.Contains(a.v, "boom") { t.Errorf("error detail leaked via attr %q=%q", a.k, a.v) } } } // The ring is a bounded window: past traceCap it retains the most recent // traceCap spans, in arrival order. func TestTraceHubRingWraparound(t *testing.T) { hub := &traceHub{subs: map[chan struct{}]struct{}{}} total := traceCap + 50 for i := 0; i < total; i++ { hub.add(traceSpan{spanID: fmt.Sprintf("%d", i)}) } snap := hub.snapshot() if len(snap) != traceCap { t.Fatalf("snapshot len = %d, want %d", len(snap), traceCap) } if want := fmt.Sprintf("%d", total-traceCap); snap[0].spanID != want { t.Errorf("oldest retained = %q, want %q", snap[0].spanID, want) } if want := fmt.Sprintf("%d", total-1); snap[len(snap)-1].spanID != want { t.Errorf("newest retained = %q, want %q", snap[len(snap)-1].spanID, want) } } // The shared-render contract: render() publishes the region once for all // viewers (seq bumps), an unchanged re-render publishes nothing, and presence // (the subscriber count) is part of the rendered bytes — so joins/leaves morph // the view just like new spans do. func TestTraceHubSharedRenderAndPresence(t *testing.T) { hub := &traceHub{subs: map[chan struct{}]struct{}{}} hub.add(traceSpan{traceID: "A", spanID: "a1", name: "GET /", start: time.Now(), dur: time.Millisecond}) hub.render() region, seq := hub.current() if seq == 0 || region == "" { t.Fatalf("first render did not publish: seq=%d empty=%v", seq, region == "") } if !strings.Contains(region, "0 watching") { t.Error("presence count missing from rendered region") } // Same ring, same presence → no new publish (connected streams stay quiet). hub.render() if _, seq2 := hub.current(); seq2 != seq { t.Errorf("unchanged render bumped seq %d → %d", seq, seq2) } // A viewer joining marks the region dirty, and the next render both shows // the new count and wakes the subscriber. ch := hub.subscribe() if !hub.dirty.Load() { t.Error("subscribe did not mark the region dirty") } hub.render() region3, seq3 := hub.current() if seq3 == seq { t.Error("presence change did not publish a new render") } if !strings.Contains(region3, "1 watching") { t.Error("rendered region does not show the joined viewer") } select { case <-ch: default: t.Error("publish did not wake the subscriber") } } // buildTraceView groups by trace, orders cards newest-end-first, orders spans // within a card by start, and indents by parent-chain depth — even when spans // arrive child-first (as they really do: children End before their parent). func TestBuildTraceViewGroupsAndDepth(t *testing.T) { base := time.Now() spans := []traceSpan{ // trace A, arriving child-first and out of start order. {traceID: "A", spanID: "a3", parentID: "a2", name: "db.list_issues", start: base.Add(3 * time.Millisecond), dur: time.Millisecond}, {traceID: "A", spanID: "a2", parentID: "a1", name: "render.regions", start: base.Add(2 * time.Millisecond), dur: 3 * time.Millisecond}, {traceID: "A", spanID: "a1", parentID: "", name: "GET /i/{id}", start: base.Add(1 * time.Millisecond), dur: 6 * time.Millisecond}, // trace B: a later standalone root → its card sorts first (newest end). {traceID: "B", spanID: "b1", parentID: "", name: "writer.flush", start: base.Add(10 * time.Millisecond), dur: 2 * time.Millisecond}, } v := buildTraceView(spans, 0, 0) if v.Spans != 4 { t.Fatalf("Spans = %d, want 4", v.Spans) } if len(v.Groups) != 2 { t.Fatalf("groups = %d, want 2", len(v.Groups)) } if v.Groups[0].Root != "writer.flush" { t.Errorf("newest card root = %q, want writer.flush", v.Groups[0].Root) } gA := v.Groups[1] if gA.Root != "GET /i/{id}" || gA.Count != 3 { t.Errorf("A card: root=%q count=%d, want GET /i/{id}, 3", gA.Root, gA.Count) } wantName := []string{"GET /i/{id}", "render.regions", "db.list_issues"} wantDepth := []int{0, 1, 2} for i, row := range gA.Spans { if row.Name != wantName[i] || row.Depth != wantDepth[i] { t.Errorf("A.Spans[%d] = {%q d=%d}, want {%q d=%d}", i, row.Name, row.Depth, wantName[i], wantDepth[i]) } } if gA.Dur != durStr(6*time.Millisecond) { t.Errorf("A headline dur = %q, want %q (the root's)", gA.Dur, durStr(6*time.Millisecond)) } } // A span whose parent has aged out of the ring renders rooted (depth 0) instead // of vanishing or recursing. func TestBuildTraceViewOrphanIsRooted(t *testing.T) { v := buildTraceView([]traceSpan{ {traceID: "C", spanID: "c2", parentID: "c1-evicted", name: "db.count", start: time.Now(), dur: time.Millisecond}, }, 0, 0) if len(v.Groups) != 1 || len(v.Groups[0].Spans) != 1 { t.Fatalf("unexpected shape: %+v", v) } if d := v.Groups[0].Spans[0].Depth; d != 0 { t.Errorf("orphan depth = %d, want 0", d) } } // The display handle must be stable, salted (so a forged inbound traceparent // can't be recognized in the feed), and never reveal the real trace id. func TestAnonTraceHandle(t *testing.T) { const id = "10f0de2d5236935910047bf2f01e0b63" // a real 128-bit trace id (hex) const salt = uint64(0x9e3779b97f4a7c15) got := anonTrace(id, salt) if len(got) != anonWidth { t.Fatalf("handle %q len = %d, want %d", got, len(got), anonWidth) } for _, r := range got { if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z')) { t.Fatalf("handle %q has non-base36 char %q", got, r) } } if anonTrace(id, salt) != got { t.Error("anonTrace not deterministic for fixed id+salt") } if anonTrace(id, salt+1) == got { t.Error("handle did not change with salt — not actually anonymized") } if got == id || got == id[:anonWidth] { t.Errorf("handle %q leaks the real trace id", got) } // buildTraceView surfaces the anonymized handle, never the raw id. v := buildTraceView([]traceSpan{ {traceID: id, spanID: "s1", name: "GET /", start: time.Now(), dur: time.Millisecond}, }, salt, 0) if len(v.Groups) != 1 { t.Fatalf("groups = %d, want 1", len(v.Groups)) } if v.Groups[0].ShortID != got { t.Errorf("card ShortID = %q, want anon handle %q", v.Groups[0].ShortID, got) } if strings.HasPrefix(id, v.Groups[0].ShortID) { t.Errorf("card ShortID %q is a prefix of the real trace id", v.Groups[0].ShortID) } } func TestBarPct(t *testing.T) { cases := []struct { name string offset, dur, total time.Duration wantOff, wantW float64 }{ {"midspan", 2 * time.Millisecond, 3 * time.Millisecond, 10 * time.Millisecond, 20, 30}, {"full root", 0, 10 * time.Millisecond, 10 * time.Millisecond, 0, 100}, {"zero window is full width", 0, 0, 0, 0, 100}, {"tiny span keeps min width", 0, time.Nanosecond, 10 * time.Millisecond, 0, 1.5}, {"clamped within track", 9500 * time.Microsecond, 2 * time.Millisecond, 10 * time.Millisecond, 95, 5}, } for _, c := range cases { off, w := barPct(c.offset, c.dur, c.total) if math.Abs(off-c.wantOff) > 0.01 || math.Abs(w-c.wantW) > 0.01 { t.Errorf("%s: barPct = (%.2f, %.2f), want (%.2f, %.2f)", c.name, off, w, c.wantOff, c.wantW) } } } func TestSpanCat(t *testing.T) { cases := map[string]string{ "db.list_issues": "db", "db.count": "db", "listcache.fill": "cache", "countcache.fill": "cache", "render.regions": "render", "sse.push": "push", "sse.send": "push", "detail.read": "db", "writer.flush": "write", "writer.submit": "write", "GET /": "http", "POST /cmd/search": "http", "GET /i/{id}": "http", "edit.ownership_check": "other", } for name, want := range cases { if got := spanCat(name); got != want { t.Errorf("spanCat(%q) = %q, want %q", name, got, want) } } } // The waterfall geometry: a root spans its whole window; a nested child is // offset and sized as a percentage of it. func TestBuildTraceViewBars(t *testing.T) { base := time.Now() v := buildTraceView([]traceSpan{ {traceID: "A", spanID: "a1", name: "sse.push", start: base, dur: 10 * time.Millisecond}, {traceID: "A", spanID: "a2", parentID: "a1", name: "db.count", start: base.Add(4 * time.Millisecond), dur: 2 * time.Millisecond}, }, 0, 0) if len(v.Groups) != 1 || len(v.Groups[0].Spans) != 2 { t.Fatalf("unexpected shape: %+v", v) } rows := v.Groups[0].Spans if rows[0].Cat != "push" || rows[1].Cat != "db" { t.Errorf("cats = %q,%q want push,db", rows[0].Cat, rows[1].Cat) } if string(rows[0].Bar) != "left:0.00%;width:100.00%" { t.Errorf("root bar = %q, want full width", rows[0].Bar) } if string(rows[1].Bar) != "left:40.00%;width:20.00%" { t.Errorf("child bar = %q, want left:40 width:20", rows[1].Bar) } }