Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
22 changes: 13 additions & 9 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/output/format_mb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions internal/output/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand Down
59 changes: 59 additions & 0 deletions internal/output/tree_test.go
Original file line number Diff line number Diff line change
@@ -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, "░"))
}
Loading