diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ca52d..1c07210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Pending + +### Fixed + +- `scout traces list` and `scout traces show` no longer fail with `cannot unmarshal number ... into Go struct field ... mem_delta of type int64` — the API reports `mem_delta` as a float in megabytes, so it is now decoded as such and displayed as MB (#19) +- `scout traces list` Duration column and the legacy-trace header in `scout traces show` treated `total_call_time` as seconds; the API reports it in milliseconds, so an 83-second request no longer shows as `83483.9s` (#19) + ## [0.4.0] - 2026-06-04 ### Added diff --git a/cmd/traces.go b/cmd/traces.go index 9be244c..26a968a 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -71,8 +71,8 @@ func runTracesList(cmd *cobra.Command, args []string) { rows[i] = []string{ strconv.Itoa(t.ID), output.FormatRelativeTime(t.Time), - output.FormatSeconds(t.TotalCallTime), - output.FormatBytes(t.MemDelta), + output.FormatMs(t.TotalCallTime), + output.FormatMB(t.MemDelta), t.MetricName, t.URI, } diff --git a/internal/api/traces_test.go b/internal/api/traces_test.go new file mode 100644 index 0000000..6c3db68 --- /dev/null +++ b/internal/api/traces_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The API reports mem_delta as a float (megabytes), e.g. 0.0 or 2.9296875. +// Regression test for https://github.com/scoutapp/scout-cli/issues/19. +func TestListTracesFloatMemDelta(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v0/apps/6/endpoints/YXBpL21ldHJpY3Mvc2hvdw==/traces", r.URL.Path) + _, _ = w.Write([]byte(`{ + "header": {"status": {"code": 200, "message": "OK"}, "apiVersion": "0.1"}, + "results": {"traces": [ + {"id": 1, "time": "2026-01-01T00:05:00Z", "total_call_time": 83.48, "mem_delta": 2.9296875, "metric_name": "Controller/users/index", "uri": "/users/42", "context": {}}, + {"id": 2, "time": "2026-01-01T00:06:00Z", "total_call_time": 1.5, "mem_delta": 0.0, "metric_name": "Controller/users/index", "uri": null, "context": {}}, + {"id": 3, "time": "2026-01-01T00:07:00Z", "total_call_time": 1.5, "mem_delta": null, "metric_name": "Controller/users/index", "uri": "/x", "context": {}}, + {"id": 4, "time": "2026-01-01T00:08:00Z", "total_call_time": 1.5, "mem_delta": 12, "metric_name": "Controller/users/index", "uri": "/x", "context": {}} + ]} + }`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key") + traces, err := client.ListTraces(6, "YXBpL21ldHJpY3Mvc2hvdw==", "2026-09-03T00:00:00Z", "2026-09-09T00:00:00Z") + require.NoError(t, err) + require.Len(t, traces, 4) + assert.InDelta(t, 2.9296875, traces[0].MemDelta, 1e-9) + assert.Equal(t, 0.0, traces[1].MemDelta) + assert.Equal(t, 0.0, traces[2].MemDelta, "null mem_delta should decode as zero") + assert.Equal(t, 12.0, traces[3].MemDelta, "integer mem_delta should still decode") +} + +func TestGetTraceFloatMemDelta(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v0/apps/6/traces/12345", r.URL.Path) + _, _ = w.Write([]byte(`{ + "header": {"status": {"code": 200, "message": "OK"}, "apiVersion": "0.1"}, + "results": {"trace": { + "id": 12345, "time": "2026-01-01T00:05:00Z", "total_call_time": 83483.85, + "mem_delta": 2.9296875, "metric_name": "Controller/users/index", "uri": "/x", "context": {}, + "transaction_id": "abc", "hostname": "web-1", "git_sha": "deadbeef", + "duration_in_seconds": 83.483853, "allocations_count": 15467, "limited": false, + "spans": [{"id": "span-1", "parent_id": null, "operation": "Middleware/Summary", "type": "Middleware", + "description": null, "duration_ms": 83.48, "exclusive_duration_ms": 0.0038, "allocations": 15467, "children": []}] + }} + }`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key") + trace, err := client.GetTrace(6, 12345) + require.NoError(t, err) + assert.InDelta(t, 2.9296875, trace.MemDelta, 1e-9) + assert.Equal(t, int64(15467), trace.AllocationsCount) + require.Len(t, trace.Spans, 1) + assert.Equal(t, int64(15467), trace.Spans[0].Allocations) +} diff --git a/internal/api/types.go b/internal/api/types.go index 7660d03..c3a337c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -86,7 +86,7 @@ type TraceEntry struct { ID int `json:"id"` Time string `json:"time"` TotalCallTime float64 `json:"total_call_time"` - MemDelta int64 `json:"mem_delta"` + MemDelta float64 `json:"mem_delta"` // memory increase in MB MetricName string `json:"metric_name"` URI string `json:"uri"` Context map[string]interface{} `json:"context"` @@ -115,7 +115,7 @@ type TraceDetail struct { ID int `json:"id"` Time string `json:"time"` TotalCallTime float64 `json:"total_call_time"` - MemDelta int64 `json:"mem_delta"` + MemDelta float64 `json:"mem_delta"` // memory increase in MB MetricName string `json:"metric_name"` URI string `json:"uri"` TransactionID string `json:"transaction_id"` diff --git a/internal/output/format_mb_test.go b/internal/output/format_mb_test.go new file mode 100644 index 0000000..908fff2 --- /dev/null +++ b/internal/output/format_mb_test.go @@ -0,0 +1,63 @@ +package output + +import ( + "testing" + + "github.com/scoutapm/scout/internal/api" + "github.com/stretchr/testify/assert" +) + +func TestFormatMB(t *testing.T) { + tests := []struct { + input float64 + expected string + }{ + {0, "0 MB"}, + {0.35546875, "0.4 MB"}, + {2.9296875, "2.9 MB"}, + {7.29296875, "7.3 MB"}, + {12, "12 MB"}, + {256.4, "256 MB"}, + {1024, "1.0 GB"}, + {1536, "1.5 GB"}, + {-2.5, "-2.5 MB"}, + } + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, FormatMB(tt.input)) + }) + } +} + +func TestRenderSpanTreeMemoryFooter(t *testing.T) { + trace := api.TraceDetail{ + ID: 1, + MetricName: "Controller/users/index", + DurationInSeconds: 0.5, + MemDelta: 2.9296875, + Spans: []api.TraceSpan{ + {ID: "a", Operation: "Controller/users/index", DurationMs: 500}, + }, + } + out := RenderSpanTree(trace) + assert.Contains(t, out, "Memory: +2.9 MB") + + trace.MemDelta = -0.5 + out = RenderSpanTree(trace) + assert.Contains(t, out, "Memory: -0.5 MB") + + trace.MemDelta = 0 + out = RenderSpanTree(trace) + assert.NotContains(t, out, "Memory:") +} + +func TestRenderSpanTreeLegacyDurationIsMilliseconds(t *testing.T) { + trace := api.TraceDetail{ + ID: 2, + MetricName: "Controller/home/index", + TotalCallTime: 1500, // ms + LegacyFormat: true, + } + out := RenderSpanTree(trace) + assert.Contains(t, out, "Trace #2 — Controller/home/index — 1.5s") +} diff --git a/internal/output/style.go b/internal/output/style.go index 507e599..84e6448 100644 --- a/internal/output/style.go +++ b/internal/output/style.go @@ -95,6 +95,20 @@ func FormatBytes(bytes int64) string { return fmt.Sprintf("%d B", bytes) } +// FormatMB formats a memory size given in megabytes (the unit the Scout API +// uses for trace mem_delta values). +func FormatMB(mb float64) string { + abs := math.Abs(mb) + switch { + case abs >= 1000: + return fmt.Sprintf("%.1f GB", mb/1000) + case abs >= 10, abs == 0: + return fmt.Sprintf("%.0f MB", mb) + default: + return fmt.Sprintf("%.1f MB", mb) + } +} + func FormatRelativeTime(iso string) string { t, err := time.Parse(time.RFC3339, iso) if err != nil { diff --git a/internal/output/tree.go b/internal/output/tree.go index 37bd1b0..aeac902 100644 --- a/internal/output/tree.go +++ b/internal/output/tree.go @@ -2,6 +2,7 @@ package output import ( "fmt" + "math" "strings" "github.com/charmbracelet/lipgloss" @@ -13,7 +14,8 @@ func RenderSpanTree(trace api.TraceDetail) string { totalMs := trace.DurationInSeconds * 1000 if totalMs == 0 { - totalMs = trace.TotalCallTime * 1000 + // total_call_time is reported in milliseconds by the API. + totalMs = trace.TotalCallTime } // Header @@ -41,7 +43,7 @@ func RenderSpanTree(trace api.TraceDetail) string { var meta []string if trace.MemDelta != 0 { meta = append(meta, fmt.Sprintf("Memory: %s%s", - sign(trace.MemDelta), FormatBytes(abs(trace.MemDelta)))) + sign(trace.MemDelta), FormatMB(math.Abs(trace.MemDelta)))) } if trace.AllocationsCount > 0 { meta = append(meta, fmt.Sprintf("Allocations: %s", FormatNumber(trace.AllocationsCount))) @@ -118,16 +120,9 @@ func renderSpan(sb *strings.Builder, span api.TraceSpan, totalMs float64, prefix } } -func sign(n int64) string { +func sign(n float64) string { if n >= 0 { return "+" } return "-" } - -func abs(n int64) int64 { - if n < 0 { - return -n - } - return n -}