From 597af629edaf80f407b0da41d2a446f4819eb25e Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Wed, 9 Sep 2026 15:21:30 -0700 Subject: [PATCH 1/3] Fix traces unmarshal error on fractional mem_delta The Scout API reports a trace's mem_delta as a float in megabytes (e.g. 0.0, 2.9296875), but TraceEntry and TraceDetail declared it as int64, so `scout traces list` and `scout traces show` failed with: json: cannot unmarshal number 2.9296875 into Go struct field TraceEntry.traces.mem_delta of type int64 Decode mem_delta as float64 and format it as MB (matching the Scout UI) via a new output.FormatMB helper, instead of treating it as a byte count. Adds regression tests using live-shaped payloads (float, 0.0, null and integer mem_delta values). Closes #19 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- CHANGELOG.md | 6 +++ cmd/traces.go | 2 +- internal/api/traces_test.go | 63 +++++++++++++++++++++++++++++++ internal/api/types.go | 4 +- internal/output/format_mb_test.go | 52 +++++++++++++++++++++++++ internal/output/style.go | 14 +++++++ internal/output/tree.go | 12 ++---- 7 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 internal/api/traces_test.go create mode 100644 internal/output/format_mb_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ca52d..3bbf0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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) + ## [0.4.0] - 2026-06-04 ### Added diff --git a/cmd/traces.go b/cmd/traces.go index 9be244c..357c219 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -72,7 +72,7 @@ func runTracesList(cmd *cobra.Command, args []string) { strconv.Itoa(t.ID), output.FormatRelativeTime(t.Time), output.FormatSeconds(t.TotalCallTime), - output.FormatBytes(t.MemDelta), + 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..85432ca --- /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-09-06T17:15:00.000-07:00", "total_call_time": 83.48, "mem_delta": 2.9296875, "metric_name": "Controller/api/metrics/show", "uri": "/api/v0/apps/4241/metrics/response_time", "context": {}}, + {"id": 2, "time": "2026-09-06T17:16:00.000-07:00", "total_call_time": 1.5, "mem_delta": 0.0, "metric_name": "Controller/api/metrics/show", "uri": null, "context": {}}, + {"id": 3, "time": "2026-09-06T17:17:00.000-07:00", "total_call_time": 1.5, "mem_delta": null, "metric_name": "Controller/api/metrics/show", "uri": "/x", "context": {}}, + {"id": 4, "time": "2026-09-06T17:18:00.000-07:00", "total_call_time": 1.5, "mem_delta": 12, "metric_name": "Controller/api/metrics/show", "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/1827043988", r.URL.Path) + _, _ = w.Write([]byte(`{ + "header": {"status": {"code": 200, "message": "OK"}, "apiVersion": "0.1"}, + "results": {"trace": { + "id": 1827043988, "time": "2026-09-06T17:15:00.000-07:00", "total_call_time": 83483.85, + "mem_delta": 2.9296875, "metric_name": "Controller/api/metrics/show", "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, 1827043988) + 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..ef7ecc2 --- /dev/null +++ b/internal/output/format_mb_test.go @@ -0,0 +1,52 @@ +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/api/metrics/show", + DurationInSeconds: 0.5, + MemDelta: 2.9296875, + Spans: []api.TraceSpan{ + {ID: "a", Operation: "Controller/api/metrics/show", 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:") +} 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..7a9f18e 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" @@ -41,7 +42,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 +119,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 -} From 74a5bf575eb96d4779c2eefdc1096276d57f8b6c Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Wed, 9 Sep 2026 15:22:24 -0700 Subject: [PATCH 2/3] Treat trace total_call_time as milliseconds The API reports total_call_time in milliseconds (the Scout UI renders it with an "ms" suffix and Request#total_call_time_in_seconds converts from ms). `scout traces list` formatted it as seconds, so an 83-second request displayed as "83483.9s". Use FormatMs for the Duration column and drop the extra *1000 in the legacy-trace fallback of the span tree header. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- CHANGELOG.md | 1 + cmd/traces.go | 2 +- internal/output/format_mb_test.go | 11 +++++++++++ internal/output/tree.go | 3 ++- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbf0ad..1c07210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 diff --git a/cmd/traces.go b/cmd/traces.go index 357c219..26a968a 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -71,7 +71,7 @@ func runTracesList(cmd *cobra.Command, args []string) { rows[i] = []string{ strconv.Itoa(t.ID), output.FormatRelativeTime(t.Time), - output.FormatSeconds(t.TotalCallTime), + output.FormatMs(t.TotalCallTime), output.FormatMB(t.MemDelta), t.MetricName, t.URI, diff --git a/internal/output/format_mb_test.go b/internal/output/format_mb_test.go index ef7ecc2..5835f09 100644 --- a/internal/output/format_mb_test.go +++ b/internal/output/format_mb_test.go @@ -50,3 +50,14 @@ func TestRenderSpanTreeMemoryFooter(t *testing.T) { 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/tree.go b/internal/output/tree.go index 7a9f18e..aeac902 100644 --- a/internal/output/tree.go +++ b/internal/output/tree.go @@ -14,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 From 7212a02d3eacc9891234905efdff3b515d20354f Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Wed, 9 Sep 2026 15:54:01 -0700 Subject: [PATCH 3/3] Use synthetic values in trace tests Replace trace ids, URIs and endpoint names copied from live output with generic placeholders. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BK3fzED9ksTWsvoPfsEwpS --- internal/api/traces_test.go | 16 ++++++++-------- internal/output/format_mb_test.go | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/api/traces_test.go b/internal/api/traces_test.go index 85432ca..6c3db68 100644 --- a/internal/api/traces_test.go +++ b/internal/api/traces_test.go @@ -17,10 +17,10 @@ func TestListTracesFloatMemDelta(t *testing.T) { _, _ = w.Write([]byte(`{ "header": {"status": {"code": 200, "message": "OK"}, "apiVersion": "0.1"}, "results": {"traces": [ - {"id": 1, "time": "2026-09-06T17:15:00.000-07:00", "total_call_time": 83.48, "mem_delta": 2.9296875, "metric_name": "Controller/api/metrics/show", "uri": "/api/v0/apps/4241/metrics/response_time", "context": {}}, - {"id": 2, "time": "2026-09-06T17:16:00.000-07:00", "total_call_time": 1.5, "mem_delta": 0.0, "metric_name": "Controller/api/metrics/show", "uri": null, "context": {}}, - {"id": 3, "time": "2026-09-06T17:17:00.000-07:00", "total_call_time": 1.5, "mem_delta": null, "metric_name": "Controller/api/metrics/show", "uri": "/x", "context": {}}, - {"id": 4, "time": "2026-09-06T17:18:00.000-07:00", "total_call_time": 1.5, "mem_delta": 12, "metric_name": "Controller/api/metrics/show", "uri": "/x", "context": {}} + {"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": {}} ]} }`)) })) @@ -38,12 +38,12 @@ func TestListTracesFloatMemDelta(t *testing.T) { 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/1827043988", r.URL.Path) + 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": 1827043988, "time": "2026-09-06T17:15:00.000-07:00", "total_call_time": 83483.85, - "mem_delta": 2.9296875, "metric_name": "Controller/api/metrics/show", "uri": "/x", "context": {}, + "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", @@ -54,7 +54,7 @@ func TestGetTraceFloatMemDelta(t *testing.T) { defer server.Close() client := NewClient(server.URL, "test-key") - trace, err := client.GetTrace(6, 1827043988) + 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) diff --git a/internal/output/format_mb_test.go b/internal/output/format_mb_test.go index 5835f09..908fff2 100644 --- a/internal/output/format_mb_test.go +++ b/internal/output/format_mb_test.go @@ -32,11 +32,11 @@ func TestFormatMB(t *testing.T) { func TestRenderSpanTreeMemoryFooter(t *testing.T) { trace := api.TraceDetail{ ID: 1, - MetricName: "Controller/api/metrics/show", + MetricName: "Controller/users/index", DurationInSeconds: 0.5, MemDelta: 2.9296875, Spans: []api.TraceSpan{ - {ID: "a", Operation: "Controller/api/metrics/show", DurationMs: 500}, + {ID: "a", Operation: "Controller/users/index", DurationMs: 500}, }, } out := RenderSpanTree(trace)