Skip to content

Commit 7e71c03

Browse files
authored
fix(cli): render trace details by id with validation + exit-code mapping (issue #17) (#20)
Closes #17. Bug surface: `goclaw traces get <id>` was unreadable in TTY mode (dumped raw JSON), silently swallowed unmarshal errors (empty `{}`), accepted any raw id including path-traversal sequences (`..`, `/`, control chars), and collapsed every server failure to exit code 1 regardless of HTTP status. Fixes: - `cmd/traces.go` - `tracesGetCmd.RunE`: validate id allowlist before HTTP, decode payload with error surfacing, render header card + span tree + events list for TTY, pass-through JSON for `-o json` / piped stdout. - `validateTraceID`: regex allowlist `^[A-Za-z0-9._-]+$` + reserved-token reject (`.`, `..`, empty/whitespace). Returns typed `*client.APIError{Code: INVALID_REQUEST}` so the central error handler maps it to exit code 4. - `renderTraceTable` + `buildSpanTree`: insertion-ordered span tree, orphan spans attach to virtual root. No relink walk, cycle-safe (cycles silently drop). - `internal/client/http.go` - Retry loop bug: previously closed `resp.Body` on every iteration including the final attempt, then `io.ReadAll` after the loop failed with "read on closed response body". This collapsed typed `APIError` into an opaque wrapped error and lost the exit-code mapping for 5xx/429. Fixed by skipping the per-iteration close on the final attempt; outer `defer` handles cleanup. Exit-code contract for `traces get`: - 0 success / 2 permission denied / 3 not-found / 4 malformed id (pre-HTTP) / 5 server failure / 6 rate-limit. Tests: 10 new tests in `cmd/traces_get_test.go` lock the wire contract, table+JSON rendering, exit-code mapping per HTTP status, and the "malformed id makes zero HTTP calls" invariant (parameterized table). Fixture `cmd/testdata/trace_detail_get.json` is a scrubbed stub with `_TODO_refresh` marker; refresh against live gateway before merging to default. Docs: CHANGELOG `[Unreleased]` Fixed entry, README `Reading a Trace by ID` section with exit-code table, `docs/codebase-summary.md` traces bullet updated.
1 parent 2801486 commit 7e71c03

17 files changed

Lines changed: 1772 additions & 6 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5555
- `goclaw activity aggregate --group-by {action|actor_type|entity_type|actor_id} [--from --to --limit --actor-type --actor-id --action --entity-type --entity-id]` — group audit-log activity by dimension with bucket counts (`GET /v1/activity/aggregate`). Attached as subcommand of existing `activity` parent.
5656
- `goclaw logs aggregate [--group-by {level|source}] [--level --source --from]` — summarize the runtime log ring buffer (`GET /v1/logs/runtime/aggregate`, admin-only). Distinct from `logs tail`. Epoch-millis `last_seen` rendered as RFC3339, never scientific notation.
5757

58+
### Fixed
59+
60+
- `goclaw traces get <id>` — TTY mode now renders a human-readable summary (header card + span tree + events list) instead of dumping raw JSON. JSON-mode payload unchanged. Decode failures surface as wrapped errors instead of an empty `{}`. Trace ids are validated against `^[A-Za-z0-9._-]+$` and reserved tokens (`.`, `..`) are rejected before any HTTP call. Distinct exit codes per failure: not-found → 3, permission-denied → 2, malformed-id → 4, server-failure → 5. Latent retry-body bug in `internal/client/http.go` fixed: the final 5xx/429 response body is now preserved so the typed `APIError` reaches the caller (previously collapsed to exit 1). Closes #17.
61+
5862
### Notes
5963
- All new commands honor the AI-first ergonomics contract: `--output=json` envelope, central error handler, `--yes` for destructive ops, `--quiet` for CI.
6064
- P4/P5 backlog was re-swept against the current CLI surface; already-covered items were removed from residual scope before implementation.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ goclaw logs aggregate [--group-by <level|source>] [--level <l>] [--source <s>] [
124124

125125
All are one-shot HTTP — no watch loops or WS streams. `logs aggregate` is admin-only on the server; `activity aggregate --group-by actor_id` is also admin-only (server-enforced).
126126

127+
### Reading a Trace by ID
128+
129+
```bash
130+
# Human-readable: header + span tree + events
131+
goclaw traces get <trace-id>
132+
133+
# Machine-readable JSON (also auto-selected when stdout is piped)
134+
goclaw traces get <trace-id> -o json
135+
```
136+
137+
Exit codes for `traces get`: `0` on success, `2` on permission denied, `3` on not-found, `4` on malformed id (rejected before any HTTP call — allowlist `^[A-Za-z0-9._-]+$`), `5` on upstream server failure, `6` on rate-limit / network-resource exhaustion.
138+
127139
## Backup & Restore
128140

129141
### System Backup

cmd/testdata/trace_detail_get.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"_TODO_refresh": "stub fixture derived from traces follow payload shape; refresh against goclaw.zuey.me before merge per phase-03 reviewer gate",
3+
"trace_id": "trace_FIXTURE_001",
4+
"agent_id": "agent_FIXTURE_001",
5+
"session_key": "session_FIXTURE_001",
6+
"user_id": "user_REDACTED",
7+
"tenant_id": "tenant_REDACTED",
8+
"status": "success",
9+
"started_at": "2026-05-28T10:00:00Z",
10+
"ended_at": "2026-05-28T10:00:02Z",
11+
"duration_ms": 2000,
12+
"input_tokens": 120,
13+
"output_tokens": 80,
14+
"cost": "0.0042",
15+
"spans": [
16+
{
17+
"span_id": "span_001",
18+
"parent_span_id": null,
19+
"name": "agent.run",
20+
"kind": "agent",
21+
"started_at": "2026-05-28T10:00:00Z",
22+
"ended_at": "2026-05-28T10:00:02Z",
23+
"duration_ms": 2000,
24+
"status": "success"
25+
},
26+
{
27+
"span_id": "span_002",
28+
"parent_span_id": "span_001",
29+
"name": "llm.call",
30+
"kind": "llm",
31+
"started_at": "2026-05-28T10:00:00Z",
32+
"ended_at": "2026-05-28T10:00:01Z",
33+
"duration_ms": 1500,
34+
"status": "success",
35+
"input_tokens": 120,
36+
"output_tokens": 80
37+
},
38+
{
39+
"span_id": "span_003",
40+
"parent_span_id": "span_001",
41+
"name": "tool.call",
42+
"kind": "tool",
43+
"started_at": "2026-05-28T10:00:01Z",
44+
"ended_at": "2026-05-28T10:00:02Z",
45+
"duration_ms": 400,
46+
"status": "success"
47+
}
48+
],
49+
"events": [
50+
{"event_id": "ev_001", "span_id": "span_002", "type": "llm.prompt", "timestamp": "2026-05-28T10:00:00Z"},
51+
{"event_id": "ev_002", "span_id": "span_002", "type": "llm.completion", "timestamp": "2026-05-28T10:00:01Z"},
52+
{"event_id": "ev_003", "span_id": "span_003", "type": "tool.invoke", "timestamp": "2026-05-28T10:00:01Z"}
53+
]
54+
}

cmd/traces.go

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package cmd
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"io"
67
"net/url"
78
"os"
9+
"regexp"
10+
"strings"
811
"time"
912

13+
"github.com/nextlevelbuilder/goclaw-cli/internal/client"
1014
"github.com/nextlevelbuilder/goclaw-cli/internal/output"
1115
"github.com/spf13/cobra"
1216
)
@@ -61,19 +65,130 @@ var tracesListCmd = &cobra.Command{
6165
var tracesGetCmd = &cobra.Command{
6266
Use: "get <traceID>", Short: "Get trace with span tree", Args: cobra.ExactArgs(1),
6367
RunE: func(cmd *cobra.Command, args []string) error {
68+
id := strings.TrimSpace(args[0])
69+
if err := validateTraceID(id); err != nil {
70+
return err
71+
}
6472
c, err := newHTTP()
6573
if err != nil {
6674
return err
6775
}
68-
data, err := c.Get("/v1/traces/" + args[0])
76+
data, err := c.Get("/v1/traces/" + url.PathEscape(id))
6977
if err != nil {
7078
return err
7179
}
72-
printer.Print(unmarshalMap(data))
80+
var trace map[string]any
81+
if err := json.Unmarshal(data, &trace); err != nil {
82+
return fmt.Errorf("decode trace payload: %w", err)
83+
}
84+
if cfg.OutputFormat != "table" {
85+
printer.Print(trace)
86+
return nil
87+
}
88+
renderTraceTable(trace, os.Stdout)
7389
return nil
7490
},
7591
}
7692

93+
// traceIDPattern restricts trace ids to a safe, URL-safe allowlist.
94+
// Blocks path-traversal (`..`, `/`, `\`), control characters, and whitespace
95+
// before any HTTP call is issued. PathEscape is still applied on top.
96+
var traceIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
97+
98+
func validateTraceID(id string) error {
99+
if id == "" || id == "." || id == ".." {
100+
return &client.APIError{Code: "INVALID_REQUEST", Message: "trace id is empty or reserved"}
101+
}
102+
if !traceIDPattern.MatchString(id) {
103+
return &client.APIError{Code: "INVALID_REQUEST", Message: "trace id contains invalid characters (allowed: A-Z a-z 0-9 . _ -)"}
104+
}
105+
return nil
106+
}
107+
108+
// renderTraceTable prints a human-readable summary: header card, span tree, events.
109+
func renderTraceTable(t map[string]any, w io.Writer) {
110+
for _, row := range [][2]string{
111+
{"TRACE_ID", str(t, "trace_id")}, {"AGENT_ID", str(t, "agent_id")},
112+
{"SESSION_KEY", str(t, "session_key")}, {"STATUS", str(t, "status")},
113+
{"DURATION_MS", str(t, "duration_ms")},
114+
} {
115+
if row[1] != "" {
116+
fmt.Fprintf(w, "%-12s %s\n", row[0]+":", row[1])
117+
}
118+
}
119+
if in, out, cost := str(t, "input_tokens"), str(t, "output_tokens"), str(t, "cost"); in+out+cost != "" {
120+
fmt.Fprintf(w, "%-12s in=%s out=%s cost=%s\n", "TOKENS:", in, out, cost)
121+
}
122+
spans, _ := t["spans"].([]any)
123+
if len(spans) == 0 {
124+
fmt.Fprintln(w, "\nSPANS: (none)")
125+
} else {
126+
fmt.Fprintln(w, "\nSPANS:")
127+
output.PrintTreeRoot(buildSpanTree(spans), w)
128+
}
129+
events, _ := t["events"].([]any)
130+
fmt.Fprintf(w, "\nEVENTS (n=%d):\n", len(events))
131+
for _, e := range events {
132+
if m, ok := e.(map[string]any); ok {
133+
fmt.Fprintf(w, " - %s\n", str(m, "type"))
134+
}
135+
}
136+
}
137+
138+
// buildSpanTree links spans via parent_span_id; spans whose parent isn't in this
139+
// trace attach to a virtual root. Children are kept in insertion order.
140+
func buildSpanTree(spans []any) output.TreeNode {
141+
order := make([]string, 0, len(spans))
142+
labels := make(map[string]string, len(spans))
143+
children := make(map[string][]string, len(spans))
144+
parentOf := make(map[string]string, len(spans))
145+
for _, s := range spans {
146+
m, ok := s.(map[string]any)
147+
if !ok {
148+
continue
149+
}
150+
id := str(m, "span_id")
151+
if id == "" {
152+
continue
153+
}
154+
label := id
155+
if name := str(m, "name"); name != "" {
156+
label = name + " [" + id + "]"
157+
}
158+
if kind := str(m, "kind"); kind != "" {
159+
label += " kind=" + kind
160+
}
161+
if dur := str(m, "duration_ms"); dur != "" {
162+
label += " " + dur + "ms"
163+
}
164+
labels[id] = label
165+
order = append(order, id)
166+
parentOf[id], _ = m["parent_span_id"].(string)
167+
}
168+
for _, id := range order {
169+
if p := parentOf[id]; p != "" {
170+
if _, ok := labels[p]; ok {
171+
children[p] = append(children[p], id)
172+
continue
173+
}
174+
}
175+
children[""] = append(children[""], id)
176+
}
177+
var build func(id string) output.TreeNode
178+
build = func(id string) output.TreeNode {
179+
n := output.TreeNode{Name: labels[id]}
180+
for _, c := range children[id] {
181+
n.Children = append(n.Children, build(c))
182+
}
183+
return n
184+
}
185+
root := output.TreeNode{Name: "trace"}
186+
for _, id := range children[""] {
187+
root.Children = append(root.Children, build(id))
188+
}
189+
return root
190+
}
191+
77192
var tracesExportCmd = &cobra.Command{
78193
Use: "export <traceID>", Short: "Export trace to file", Args: cobra.ExactArgs(1),
79194
RunE: func(cmd *cobra.Command, args []string) error {

0 commit comments

Comments
 (0)