diff --git a/messages_devtools.go b/messages_devtools.go index c5c325c..ebfd5fb 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -72,6 +72,12 @@ func init() { "log_stream.waiting": {"%d new lines", "%d 条新日志"}, "log_stream.empty": {"No log lines", "没有日志"}, + "trace_waterfall.label": {"Trace", "调用链"}, + "trace_waterfall.empty": {"No spans", "没有跨度"}, + "trace_waterfall.summary": {"%d spans · %s", "%d 个跨度 · %s"}, + "trace_waterfall.detail": {"%s · starts at %s · takes %s", "%s · 起于 %s · 耗时 %s"}, + "trace_waterfall.error": {"Error", "错误"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/testdata/snapshots/darwin/trace_waterfall-dark.png b/testdata/snapshots/darwin/trace_waterfall-dark.png new file mode 100644 index 0000000..637f862 Binary files /dev/null and b/testdata/snapshots/darwin/trace_waterfall-dark.png differ diff --git a/testdata/snapshots/darwin/trace_waterfall-light.png b/testdata/snapshots/darwin/trace_waterfall-light.png new file mode 100644 index 0000000..314480b Binary files /dev/null and b/testdata/snapshots/darwin/trace_waterfall-light.png differ diff --git a/trace_waterfall.go b/trace_waterfall.go new file mode 100644 index 0000000..9ac6b6b --- /dev/null +++ b/trace_waterfall.go @@ -0,0 +1,178 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// TraceSpan is one timed operation of a request; Parent is "" for the root. +type TraceSpan struct { + ID, Parent string + Name string + Service string + // Start is the offset from the trace start. + Start, Duration time.Duration + Error bool +} + +// TraceWaterfallOptions configure a TraceWaterfall. +type TraceWaterfallOptions struct { + // Height of the span list; zero means 320. + Height float32 +} + +// traceRow is a span in tree order with its depth. +type traceRow struct { + span int + depth int +} + +type traceState struct { + key string + rows []traceRow + total time.Duration + selected string + services map[string]int // palette slot of each service +} + +// TraceWaterfall lays spans out by parent and time; choosing a span shows its detail. +func TraceWaterfall(c *ui.Context, spans []TraceSpan, opts TraceWaterfallOptions) ChoiceResult { + if opts.Height < 0 { + panic("mujica: TraceWaterfall Height is negative") + } + h := opts.Height + if h == 0 { + h = 320 + } + k := Tokens(c) + u := Density(c).Unit() + var r ChoiceResult + r.Element = codePanel(c).Label(msg(c, "trace_waterfall.label")) + st := ui.Local(r.Element, "mujica.trace", func() traceState { return traceState{} }) + traceLayout(st, spans) + r.Element.Children(func() { + if len(st.rows) == 0 { + codeCaption(c, msg(c, "trace_waterfall.empty")).Padding(u * 4) + return + } + codeHeader(c, func() { + codeCaption(c, fmt.Sprintf(msg(c, "trace_waterfall.summary"), len(spans), agentDuration(st.total))).Grow(1) + }) + agentRows(c, len(st.rows), func(i int) { + row := st.rows[i] + s := spans[row.span] + if traceSpanRow(c, s, row.depth, st.total, st.services[s.Service], s.ID == st.selected) { + st.selected, r.changed = s.ID, true + slog.Debug("mujica: trace_waterfall select", "span", s.ID) + } + }).Height(h).FocusGroup(ui.Vertical) + for _, s := range spans { + if s.ID == st.selected { + ui.Column(c).Shrink(0).Gap(u).Padding(u*2, u*3).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border). + Role(ui.RoleStatus).Children(func() { + ui.Text(c, s.Name).FontWeight(600) + codeCaption(c, fmt.Sprintf(msg(c, "trace_waterfall.detail"), s.Service, agentDuration(s.Start), agentDuration(s.Duration))) + }) + } + } + }) + return r +} + +// traceLayout orders spans depth-first by start, only when the spans change. +func traceLayout(st *traceState, spans []TraceSpan) { + key := fmt.Sprintf("%d", len(spans)) + if len(spans) > 0 { + key += fmt.Sprintf("%p", &spans[0]) + } + if key == st.key { + return + } + st.key, st.rows, st.total, st.services = key, st.rows[:0], 0, map[string]int{} + index := make(map[string]int, len(spans)) + kids := map[string][]int{} + for i, s := range spans { + if s.ID == "" || s.Name == "" || s.Start < 0 || s.Duration < 0 { + panic(fmt.Sprintf("mujica: TraceWaterfall span %d needs an ID, a Name and non-negative times", i)) + } + if _, dup := index[s.ID]; dup { + panic("mujica: TraceWaterfall span IDs must be unique: " + s.ID) + } + index[s.ID] = i + if _, ok := st.services[s.Service]; !ok { + st.services[s.Service] = len(st.services) + } + st.total = max(st.total, s.Start+s.Duration) + } + for i, s := range spans { + if _, ok := index[s.Parent]; s.Parent != "" && !ok { + panic("mujica: TraceWaterfall span " + s.ID + " has an unknown Parent") + } + kids[s.Parent] = append(kids[s.Parent], i) + } + var walk func(parent string, depth int) + walk = func(parent string, depth int) { + list := kids[parent] + slices.SortStableFunc(list, func(a, b int) int { return cmp.Compare(spans[a].Start, spans[b].Start) }) + for _, i := range list { + st.rows = append(st.rows, traceRow{i, depth}) + walk(spans[i].ID, depth+1) + } + } + walk("", 0) + if len(st.rows) != len(spans) { + panic("mujica: TraceWaterfall spans form a cycle") + } +} + +// traceSpanRow shows a span's name indented by depth beside its bar; it reports a choice. +func traceSpanRow(c *ui.Context, s TraceSpan, depth int, total time.Duration, slot int, selected bool) bool { + k := Tokens(c) + d := Density(c) + u := d.Unit() + row := ui.ButtonBase(c).Key(s.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()-4).Padding(0, u*2). + Label(fmt.Sprintf("%s, %s, %s", s.Name, s.Service, agentDuration(s.Duration))) + internal.Ring(row, k.Focus, theme.ControlRadius) + if selected { + row.Background(k.Selection) + } else if row.Hovered() { + row.Background(k.SurfaceHover) + } + pal := internal.Palette(k) + col := pal[slot%len(pal)] + if s.Error { + col = k.Danger + } + row.Children(func() { + ui.Row(c).Width(220).Shrink(0).Gap(u).Padding(0, 0, 0, float32(depth)*u*4).AlignItems(ui.Center).Children(func() { + if s.Error { + ui.Icon(c, icons.Must("circle-x")).Size(12, 12).TextColor(k.Danger).Label(msg(c, "trace_waterfall.error")) + } + ui.Text(c, s.Name).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Shrink(1) + }) + ui.Box(c).Grow(1).Height(14).Draw(func(p *ui.Painter, r ui.Rect) { + span := max(float64(total), 1) + x0 := r.X + r.W*float32(float64(s.Start)/span) + w := max(r.W*float32(float64(s.Duration)/span), 2) + bar := ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: w, H: r.H} + if s.Error { // hollow, so errors differ by shape too + p.Fill(bar, col.Alpha(0.25), 2) + p.Stroke(bar, col, 2, 1.5) + return + } + p.Fill(bar, col, 2) + }) + ui.Text(c, agentDuration(s.Duration)).Width(64).TextAlign(ui.End).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.TextMuted).FontFeatures("tnum").Shrink(0) + }) + return row.Clicked() +} diff --git a/trace_waterfall_test.go b/trace_waterfall_test.go new file mode 100644 index 0000000..a15ad5a --- /dev/null +++ b/trace_waterfall_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var traceSample = []TraceSpan{ + {ID: "db", Parent: "api", Name: "SELECT guests", Service: "postgres", Start: 40 * time.Millisecond, Duration: 90 * time.Millisecond}, + {ID: "root", Name: "GET /ball", Service: "gateway", Duration: 320 * time.Millisecond}, + {ID: "api", Parent: "root", Name: "ListGuests", Service: "api", Start: 20 * time.Millisecond, Duration: 200 * time.Millisecond}, + {ID: "pay", Parent: "root", Name: "Charge", Service: "payments", Start: 230 * time.Millisecond, Duration: 80 * time.Millisecond, Error: true}, +} + +func traceTester(spans []TraceSpan, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TraceWaterfall(c, spans, TraceWaterfallOptions{}).Changed() { + *changes++ + } + }, 700, 460) +} + +func TestTraceWaterfallOrderAndDetail(t *testing.T) { + n := 0 + tt := traceTester(traceSample, &n) + if !tt.HasText("4 spans · 320 ms") { + t.Fatalf("summary: %q", tt.Texts()) + } + root, _ := tt.Find("GET /ball") + api, _ := tt.Find("ListGuests") + db, _ := tt.Find("SELECT guests") + pay, _ := tt.Find("Charge") + if !(root.Y < api.Y && api.Y < db.Y && db.Y < pay.Y && db.X > api.X && api.X > root.X) { + t.Fatalf("tree order: root %v api %v db %v pay %v", root, api, db, pay) + } + _ = tt.Click("Charge") + if n != 1 || !tt.HasText("payments · starts at 230 ms · takes 80 ms") { + t.Fatalf("detail: %q", tt.Texts()) + } + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + if !tt.HasText("postgres · starts at 40 ms · takes 90 ms") { + t.Fatalf("keyboard detail: %q", tt.Texts()) + } +} + +func TestTraceWaterfallRulesAndMany(t *testing.T) { + n := 0 + tt := traceTester(nil, &n) + if !tt.HasText("No spans") { + t.Fatal("empty") + } + many := make([]TraceSpan, 100000) + many[0] = TraceSpan{ID: "0", Name: "root", Duration: time.Second} + for i := 1; i < len(many); i++ { + many[i] = TraceSpan{ID: fmt.Sprint(i), Parent: "0", Name: fmt.Sprint("span ", i), Start: time.Duration(i) * time.Microsecond, Duration: time.Microsecond} + } + tt = traceTester(many, &n) + if got := len(tt.Texts()); got > 400 { + t.Fatalf("%d texts for 100000 spans", got) + } + defer func() { + if recover() == nil { + t.Fatal("cycle did not panic") + } + }() + traceTester([]TraceSpan{{ID: "a", Parent: "b", Name: "a"}, {ID: "b", Parent: "a", Name: "b"}}, &n) +} + +func TestTraceWaterfallSnapshot(t *testing.T) { + snapshot(t, "trace_waterfall", 680, 280, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { TraceWaterfall(c, traceSample, TraceWaterfallOptions{Height: 180}) }) + }) +}