diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bc03b..41d8b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Pending + +### Fixed + +- `scout traces show` rendered every span's duration ~1000x too small, flattening the bar chart and making the color thresholds and `⚠ slow` warning unreachable. The API reports span durations in seconds despite the `duration_ms` key name; `--json` output is unaffected (#26) + ## [0.5.0] - 2026-09-10 This release rounds out command coverage across the whole Scout API surface diff --git a/internal/api/types.go b/internal/api/types.go index b98f207..dee5d71 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -232,16 +232,20 @@ type TracesResult struct { } // TraceSpan represents a span in a trace tree. +// +// The API populates the duration_ms and exclusive_duration_ms keys with +// values in seconds, not milliseconds. The JSON tags keep the wire names so +// --json stays a faithful pass-through; the Go fields carry the real unit. type TraceSpan struct { - ID string `json:"id"` - ParentID *string `json:"parent_id"` - Operation string `json:"operation"` - Type string `json:"type"` - Description *string `json:"description"` - DurationMs float64 `json:"duration_ms"` - ExclusiveDurationMs float64 `json:"exclusive_duration_ms"` - Allocations int64 `json:"allocations"` - Children []TraceSpan `json:"children,omitempty"` + ID string `json:"id"` + ParentID *string `json:"parent_id"` + Operation string `json:"operation"` + Type string `json:"type"` + Description *string `json:"description"` + DurationSeconds float64 `json:"duration_ms"` + ExclusiveDurationSeconds float64 `json:"exclusive_duration_ms"` + Allocations int64 `json:"allocations"` + Children []TraceSpan `json:"children,omitempty"` } // TraceDetail contains full trace information including spans. diff --git a/internal/output/format_mb_test.go b/internal/output/format_mb_test.go index 908fff2..e868b2d 100644 --- a/internal/output/format_mb_test.go +++ b/internal/output/format_mb_test.go @@ -36,7 +36,7 @@ func TestRenderSpanTreeMemoryFooter(t *testing.T) { DurationInSeconds: 0.5, MemDelta: 2.9296875, Spans: []api.TraceSpan{ - {ID: "a", Operation: "Controller/users/index", DurationMs: 500}, + {ID: "a", Operation: "Controller/users/index", DurationSeconds: 0.5}, }, } out := RenderSpanTree(trace) diff --git a/internal/output/tree.go b/internal/output/tree.go index aeac902..4eeb611 100644 --- a/internal/output/tree.go +++ b/internal/output/tree.go @@ -70,14 +70,16 @@ func renderSpan(sb *strings.Builder, span api.TraceSpan, totalMs float64, prefix childPrefix = " " } + durationMs := span.DurationSeconds * 1000 + // Duration bar barWidth := 20 - ratio := span.DurationMs / totalMs + ratio := durationMs / totalMs if ratio > 1 { ratio = 1 } filled := int(ratio * float64(barWidth)) - if filled < 1 && span.DurationMs > 0 { + if filled < 1 && durationMs > 0 { filled = 1 } bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) @@ -106,7 +108,7 @@ func renderSpan(sb *strings.Builder, span api.TraceSpan, totalMs float64, prefix prefix, connector, opFormatted, - FormatMs(span.DurationMs), + FormatMs(durationMs), barColor.Render(bar), warning, ) diff --git a/internal/output/tree_test.go b/internal/output/tree_test.go new file mode 100644 index 0000000..af1fe13 --- /dev/null +++ b/internal/output/tree_test.go @@ -0,0 +1,59 @@ +package output + +import ( + "strings" + "testing" + + "github.com/scoutapm/scout/internal/api" + "github.com/stretchr/testify/assert" +) + +// spanLine returns the rendered line containing op, with ANSI styling stripped. +func spanLine(t *testing.T, out, op string) string { + t.Helper() + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, op) { + return line + } + } + t.Fatalf("no rendered line for span %q in:\n%s", op, out) + return "" +} + +func TestRenderSpanTreeSpanDurationMatchesTraceDuration(t *testing.T) { + trace := api.TraceDetail{ + ID: 3, + MetricName: "Controller/users/index", + DurationInSeconds: 7.9, + Spans: []api.TraceSpan{ + {ID: "a", Operation: "Middleware/Summary", DurationSeconds: 7.9}, + }, + } + out := RenderSpanTree(trace) + + assert.Contains(t, out, "Trace #3 — Controller/users/index — 7.9s") + assert.Contains(t, spanLine(t, out, "Middleware/Summary"), "7.9s") +} + +func TestRenderSpanTreeBarReflectsShareOfTrace(t *testing.T) { + trace := api.TraceDetail{ + ID: 4, + MetricName: "Controller/users/index", + DurationInSeconds: 10, + Spans: []api.TraceSpan{ + {ID: "big", Operation: "ActiveRecord/User/find", DurationSeconds: 6}, + {ID: "small", Operation: "View/users/_row", DurationSeconds: 0.5}, + }, + } + out := RenderSpanTree(trace) + + big := spanLine(t, out, "ActiveRecord/User/find") + assert.Contains(t, big, "⚠ slow") + assert.Equal(t, 12, strings.Count(big, "█")) + assert.Equal(t, 8, strings.Count(big, "░")) + + small := spanLine(t, out, "View/users/_row") + assert.NotContains(t, small, "⚠ slow") + assert.Equal(t, 1, strings.Count(small, "█")) + assert.Equal(t, 19, strings.Count(small, "░")) +}