diff --git a/backend/internal/database/deployments.go b/backend/internal/database/deployments.go index 6c86e85..64d5c8c 100644 --- a/backend/internal/database/deployments.go +++ b/backend/internal/database/deployments.go @@ -236,6 +236,14 @@ func (db *Database) ListDeploymentsForFunction(fnID string, limit int) ([]*Deplo return out, nil } +// CountDeploymentsForFunction returns the total number of deployments for a +// function (across all pages) so list responses can carry a truncation signal. +func (db *Database) CountDeploymentsForFunction(fnID string) (int, error) { + var n int + err := db.read.QueryRow(`SELECT COUNT(*) FROM deployments WHERE function_id = ?`, fnID).Scan(&n) + return n, err +} + // AppendBuildLog writes one log line. Seq numbers are provided by the caller // to guarantee monotonicity across concurrent writers for the same // deployment (the build worker owns the deployment, so that's trivial). diff --git a/backend/internal/database/kv_atomic_test.go b/backend/internal/database/kv_atomic_test.go new file mode 100644 index 0000000..9fb9d0d --- /dev/null +++ b/backend/internal/database/kv_atomic_test.go @@ -0,0 +1,103 @@ +package database + +import ( + "testing" + "time" +) + +func kvTestFn(t *testing.T, db *Database, id string) { + t.Helper() + fn := &Function{ + ID: id, Name: id, Runtime: "node", Entrypoint: "handler.js", + TimeoutMS: 30000, MemoryMB: 64, CPUs: 0.5, + EnvVars: map[string]string{}, NetworkMode: "none", Status: "active", + } + if err := db.InsertFunction(fn); err != nil { + t.Fatal(err) + } +} + +// TestKVIncr covers the atomic counter the CLI `kv incr` and MCP kv_incr both +// drive: create-at-delta, accumulate (incl. negative), and the not-an-integer +// guard. +func TestKVIncr(t *testing.T) { + db := newTestDB(t) + kvTestFn(t, db, "fn_kvincr12345") + + // Fresh key is created at the delta. + if v, err := db.KVIncr("fn_kvincr12345", "n", 1, 0); err != nil || v != 1 { + t.Fatalf("first incr: v=%d err=%v (want 1, nil)", v, err) + } + if v, err := db.KVIncr("fn_kvincr12345", "n", 5, 0); err != nil || v != 6 { + t.Fatalf("second incr: v=%d err=%v (want 6, nil)", v, err) + } + if v, err := db.KVIncr("fn_kvincr12345", "n", -2, 0); err != nil || v != 4 { + t.Fatalf("negative incr: v=%d err=%v (want 4, nil)", v, err) + } + + // A non-integer value cannot be incremented. + if err := db.KVPut("fn_kvincr12345", "s", []byte(`"abc"`), 0); err != nil { + t.Fatal(err) + } + if _, err := db.KVIncr("fn_kvincr12345", "s", 1, 0); err == nil { + t.Error("expected incr on a non-integer value to error") + } +} + +// TestKVCAS covers compare-and-swap: insert-if-absent (expected=nil), match, +// and the precondition-miss path that returns the current value (the lock / +// optimistic-concurrency primitive the CLI `kv cas` exposes). +func TestKVCAS(t *testing.T) { + db := newTestDB(t) + kvTestFn(t, db, "fn_kvcas123456") + + // Insert-if-absent: expected=nil on a missing key swaps. + if ok, _, err := db.KVCAS("fn_kvcas123456", "lock", nil, []byte(`"held"`), 0); err != nil || !ok { + t.Fatalf("insert-if-absent: ok=%v err=%v (want true, nil)", ok, err) + } + // expected=nil on an existing key fails (already held), returns current. + ok, current, err := db.KVCAS("fn_kvcas123456", "lock", nil, []byte(`"other"`), 0) + if err != nil || ok { + t.Fatalf("insert-if-absent on existing: ok=%v err=%v (want false, nil)", ok, err) + } + if string(current) != `"held"` { + t.Errorf("expected current=%q, got %q", `"held"`, string(current)) + } + // Matching expected swaps. + if ok, _, err := db.KVCAS("fn_kvcas123456", "lock", []byte(`"held"`), []byte(`"taken"`), 0); err != nil || !ok { + t.Fatalf("matching cas: ok=%v err=%v (want true, nil)", ok, err) + } + // Stale expected fails and returns the new current. + ok, current, err = db.KVCAS("fn_kvcas123456", "lock", []byte(`"held"`), []byte(`"z"`), 0) + if err != nil || ok { + t.Fatalf("stale cas: ok=%v err=%v (want false, nil)", ok, err) + } + if string(current) != `"taken"` { + t.Errorf("expected current=%q, got %q", `"taken"`, string(current)) + } +} + +// TestCountDeploymentsForFunction pins the COUNT(*) added so the deployments +// list response can carry a truncation signal. +func TestCountDeploymentsForFunction(t *testing.T) { + db := newTestDB(t) + kvTestFn(t, db, "fn_depcount123") + + if n, err := db.CountDeploymentsForFunction("fn_depcount123"); err != nil || n != 0 { + t.Fatalf("empty count: n=%d err=%v (want 0, nil)", n, err) + } + + now := time.Now().UTC() + for i, id := range []string{"dep_count00001", "dep_count00002", "dep_count00003"} { + d := &Deployment{ + ID: id, FunctionID: "fn_depcount123", Version: int64(i + 1), + Status: "succeeded", Phase: "done", SubmittedAt: now.Add(time.Duration(i) * time.Second), + } + if err := db.InsertDeployment(d); err != nil { + t.Fatal(err) + } + } + if n, err := db.CountDeploymentsForFunction("fn_depcount123"); err != nil || n != 3 { + t.Fatalf("count: n=%d err=%v (want 3, nil)", n, err) + } +} diff --git a/backend/internal/server/handlers/deployments.go b/backend/internal/server/handlers/deployments.go index 557dc2d..ec4439e 100644 --- a/backend/internal/server/handlers/deployments.go +++ b/backend/internal/server/handlers/deployments.go @@ -53,7 +53,13 @@ func (h *DeploymentHandler) ListForFunction(w http.ResponseWriter, r *http.Reque respond.Error(w, http.StatusInternalServerError, "INTERNAL", err.Error(), reqID) return } - respond.JSON(w, http.StatusOK, map[string]any{"deployments": list}) + total, err := h.DB.CountDeploymentsForFunction(fnID) + if err != nil { + // Soft-fail on the count — the list itself succeeded, so report the + // page length rather than 500ing the whole response. + total = len(list) + } + respond.JSON(w, http.StatusOK, map[string]any{"deployments": list, "total": total}) } // GetLogs — GET /api/v1/deployments/{id}/logs?from= diff --git a/backend/internal/server/handlers/kv_operator.go b/backend/internal/server/handlers/kv_operator.go index ea064d5..8f94d08 100644 --- a/backend/internal/server/handlers/kv_operator.go +++ b/backend/internal/server/handlers/kv_operator.go @@ -254,3 +254,99 @@ func (h *KVOperatorHandler) Delete(w http.ResponseWriter, r *http.Request) { } respond.JSON(w, http.StatusOK, map[string]string{"status": "deleted", "key": key}) } + +// kvOperatorIncrRequest carries the delta and optional TTL refresh. Mirrors +// the internal kvIncrRequest (kv.go) — same shape, operator auth. +type kvOperatorIncrRequest struct { + Delta int64 `json:"delta"` + TTLSeconds int `json:"ttl_seconds,omitempty"` +} + +// Incr handles POST /api/v1/functions/{fn_id}/kv/{key}/incr. Atomically adds +// delta (default 1) to an integer value and returns the new value. The +// API-key-authed twin of the internal-token KVHandler.Incr. +func (h *KVOperatorHandler) Incr(w http.ResponseWriter, r *http.Request) { + reqID := r.Header.Get("X-Request-ID") + fnID, ok := h.resolveFnID(r.PathValue("fn_id")) + if !ok { + respond.Error(w, http.StatusNotFound, "NOT_FOUND", "function not found", reqID) + return + } + key := r.PathValue("key") + if key == "" { + respond.Error(w, http.StatusBadRequest, "VALIDATION", "key is required", reqID) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + if err != nil { + respond.Error(w, http.StatusBadRequest, "INVALID_BODY", "failed to read body", reqID) + return + } + req := kvOperatorIncrRequest{Delta: 1} + if len(body) > 0 { + if err := json.Unmarshal(body, &req); err != nil { + respond.Error(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", reqID) + return + } + } + next, err := h.DB.KVIncr(fnID, key, req.Delta, req.TTLSeconds) + if err != nil { + respond.Error(w, http.StatusConflict, "KV_INCR_FAILED", err.Error(), reqID) + return + } + respond.JSON(w, http.StatusOK, map[string]any{"value": next}) +} + +// kvOperatorCASRequest expresses "swap from Expected to New, only if Expected +// matches the current value". A null Expected means "the key must not currently +// exist" (insert-if-absent). Mirrors the internal kvCASRequest (kv.go). +type kvOperatorCASRequest struct { + Expected *json.RawMessage `json:"expected"` + New json.RawMessage `json:"new"` + TTLSeconds int `json:"ttl_seconds,omitempty"` +} + +// CAS handles POST /api/v1/functions/{fn_id}/kv/{key}/cas. A failed precondition +// is a successful HTTP 200 with {"ok": false, "current": } so callers can +// retry; only a genuine store error is a 500. +func (h *KVOperatorHandler) CAS(w http.ResponseWriter, r *http.Request) { + reqID := r.Header.Get("X-Request-ID") + fnID, ok := h.resolveFnID(r.PathValue("fn_id")) + if !ok { + respond.Error(w, http.StatusNotFound, "NOT_FOUND", "function not found", reqID) + return + } + key := r.PathValue("key") + if key == "" { + respond.Error(w, http.StatusBadRequest, "VALIDATION", "key is required", reqID) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, kvOperatorBodyCap)) + if err != nil { + respond.Error(w, http.StatusBadRequest, "INVALID_BODY", "failed to read body", reqID) + return + } + var req kvOperatorCASRequest + if err := json.Unmarshal(body, &req); err != nil { + respond.Error(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", reqID) + return + } + if len(req.New) == 0 { + respond.Error(w, http.StatusBadRequest, "VALIDATION", "new value is required", reqID) + return + } + var expectedBytes []byte + if req.Expected != nil { + expectedBytes = []byte(*req.Expected) + } + swapped, current, err := h.DB.KVCAS(fnID, key, expectedBytes, []byte(req.New), req.TTLSeconds) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "INTERNAL", "kv cas failed: "+err.Error(), reqID) + return + } + resp := map[string]any{"ok": swapped} + if !swapped && current != nil { + resp["current"] = json.RawMessage(current) + } + respond.JSON(w, http.StatusOK, resp) +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 0491678..b6c9f10 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -227,6 +227,8 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("GET /api/v1/functions/{fn_id}/kv/{key}", kvOperatorHandler.Get) r.mux.HandleFunc("PUT /api/v1/functions/{fn_id}/kv/{key}", kvOperatorHandler.Put) r.mux.HandleFunc("DELETE /api/v1/functions/{fn_id}/kv/{key}", kvOperatorHandler.Delete) + r.mux.HandleFunc("POST /api/v1/functions/{fn_id}/kv/{key}/incr", kvOperatorHandler.Incr) + r.mux.HandleFunc("POST /api/v1/functions/{fn_id}/kv/{key}/cas", kvOperatorHandler.CAS) // Saved request fixtures (v0.4 B3) — Postman-style presets reused by // the editor's Test pane and the test_function_with_fixture MCP tool. diff --git a/cli/commands/commands_test.go b/cli/commands/commands_test.go index 8a9bdee..119b708 100644 --- a/cli/commands/commands_test.go +++ b/cli/commands/commands_test.go @@ -15,7 +15,7 @@ func TestCommandTree(t *testing.T) { {"rollback"}, {"invoke"}, {"logs"}, - {"kv"}, {"kv", "get"}, {"kv", "put"}, {"kv", "delete"}, {"kv", "list"}, + {"kv"}, {"kv", "get"}, {"kv", "put"}, {"kv", "delete"}, {"kv", "list"}, {"kv", "incr"}, {"kv", "cas"}, {"cron"}, {"cron", "create"}, {"cron", "list"}, {"cron", "update"}, {"cron", "delete"}, {"jobs"}, {"jobs", "enqueue"}, {"jobs", "list"}, {"jobs", "get"}, {"jobs", "retry"}, {"jobs", "delete"}, {"secrets"}, {"secrets", "set"}, {"secrets", "list"}, {"secrets", "delete"}, @@ -80,6 +80,12 @@ func TestRequiredFlagsPresent(t *testing.T) { {[]string{"activity"}, "follow"}, {[]string{"kv", "list"}, "prefix"}, {[]string{"kv", "list"}, "limit"}, + {[]string{"kv", "incr"}, "by"}, + {[]string{"kv", "cas"}, "expected"}, + {[]string{"kv", "cas"}, "new"}, + {[]string{"functions", "list"}, "limit"}, + {[]string{"functions", "list"}, "offset"}, + {[]string{"traces", "list"}, "before"}, {[]string{"jobs", "list"}, "status"}, {[]string{"jobs", "list"}, "fn"}, {[]string{"upgrade"}, "check"}, diff --git a/cli/commands/deployments.go b/cli/commands/deployments.go index 3b51d32..f83dd93 100644 --- a/cli/commands/deployments.go +++ b/cli/commands/deployments.go @@ -127,6 +127,7 @@ func runDeploymentsList(cmd *cobra.Command, args []string) error { SubmittedAt time.Time `json:"submitted_at"` DurationMS *int64 `json:"duration_ms"` } `json:"deployments"` + Total int `json:"total"` } if err := json.Unmarshal(raw, &result); err != nil { return fmt.Errorf("decode response: %w", err) @@ -153,6 +154,13 @@ func runDeploymentsList(cmd *cobra.Command, args []string) error { ) } t.flush() + + shown := len(result.Deployments) + if shown < result.Total { + infof(cmd, "\nShowing %d of %d (raise --limit to see older deployments)", shown, result.Total) + } else { + infof(cmd, "\nShowing %d of %d", shown, result.Total) + } return nil } diff --git a/cli/commands/functions.go b/cli/commands/functions.go index 72b1f95..1a8c695 100644 --- a/cli/commands/functions.go +++ b/cli/commands/functions.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "io" + "net/url" + "strconv" "time" "github.com/spf13/cobra" @@ -17,10 +19,16 @@ var functionsCmd = &cobra.Command{ var functionsListCmd = &cobra.Command{ Use: "list", - Short: "List all functions", - Long: `List every function on the server with its runtime, status, and version. + Short: "List functions", + Long: `List functions on the server with their runtime, status, and version. + +Results are paginated. The footer reports "showing N of TOTAL"; when more +exist than the current page, raise --limit or page with --offset so a script +or agent never silently misses functions. orva functions list + orva functions list --limit 500 + orva functions list --limit 50 --offset 50 orva functions list -o json | jq '.functions[].name'`, Args: cobra.NoArgs, RunE: runFunctionsList, @@ -63,6 +71,9 @@ confirmation unless --yes is set. } func init() { + functionsListCmd.Flags().Int("limit", 100, "maximum number of functions to return") + functionsListCmd.Flags().Int("offset", 0, "number of functions to skip (pagination)") + functionsCreateCmd.Flags().String("name", "", "function name (required)") functionsCreateCmd.Flags().String("runtime", "", "runtime: node or python (required)") functionsCreateCmd.Flags().Int("memory-mb", 0, "memory limit in MB (0 = server default)") @@ -83,7 +94,21 @@ func runFunctionsList(cmd *cobra.Command, args []string) error { return err } - resp, err := client.Get("/api/v1/functions") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + q := url.Values{} + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + if offset > 0 { + q.Set("offset", strconv.Itoa(offset)) + } + path := "/api/v1/functions" + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + + resp, err := client.Get(path) if err != nil { return err } @@ -120,7 +145,13 @@ func runFunctionsList(cmd *cobra.Command, args []string) error { t.row(fn.ID, fn.Name, fn.Runtime, fn.Status, fn.Version, fn.CreatedAt.Format(time.DateTime)) } t.flush() - infof(cmd, "\nTotal: %d", result.Total) + + shown := len(result.Functions) + if offset+shown < result.Total { + infof(cmd, "\nShowing %d of %d (raise --limit or page with --offset %d)", shown, result.Total, offset+shown) + } else { + infof(cmd, "\nShowing %d of %d", shown, result.Total) + } return nil } diff --git a/cli/commands/kv.go b/cli/commands/kv.go index 0ed46ea..2148cc5 100644 --- a/cli/commands/kv.go +++ b/cli/commands/kv.go @@ -22,11 +22,9 @@ Examples: orva kv list greeter orva kv put greeter visits --value 0 orva kv get greeter visits - orva kv delete greeter visits - -Note: atomic counters (incr) and compare-and-swap (cas) are only exposed -on the internal SDK path, which requires a per-process internal token the -CLI does not hold — use them from inside a function via the runtime SDK.`, + orva kv incr greeter visits --by 1 + orva kv cas greeter lock --expected null --new '"held"' + orva kv delete greeter visits`, } var kvListCmd = &cobra.Command{ @@ -85,6 +83,42 @@ Examples: RunE: runKVDelete, } +var kvIncrCmd = &cobra.Command{ + Use: "incr ", + Short: "Atomically increment an integer KV entry", + Long: `Atomically add a delta (default 1) to an integer-valued key and print the +new value. Useful for counters and rate limiters from CI or an agent. The +key is created at the delta if it does not yet exist. Fails if the stored +value is not an integer. + +Examples: + orva kv incr greeter visits + orva kv incr greeter visits --by 5 + orva kv incr greeter remaining --by -1 + orva kv incr greeter window --by 1 --ttl 60`, + Args: cobra.ExactArgs(2), + RunE: runKVIncr, +} + +var kvCASCmd = &cobra.Command{ + Use: "cas ", + Short: "Compare-and-swap a KV entry", + Long: `Atomically set a key to --new only if its current value equals --expected. +Both values are JSON. Use --expected null to require that the key does not +yet exist (insert-if-absent) — the building block for distributed locks and +optimistic concurrency. + +On success prints the new value and exits 0. On a precondition mismatch it +prints the current value and exits non-zero, so scripts can branch or retry. + +Examples: + orva kv cas greeter lock --expected null --new '"held"' + orva kv cas greeter counter --expected 4 --new 5 + orva kv cas greeter lock --expected '"held"' --new null --ttl 0`, + Args: cobra.ExactArgs(2), + RunE: runKVCAS, +} + func init() { kvListCmd.Flags().String("prefix", "", "filter entries by key prefix") kvListCmd.Flags().Int("limit", 200, "maximum number of entries to return (max 1000)") @@ -93,10 +127,21 @@ func init() { kvPutCmd.Flags().Int("ttl", 0, "TTL in seconds (0 = no expiry)") kvPutCmd.MarkFlagRequired("value") + kvIncrCmd.Flags().Int64("by", 1, "amount to add (may be negative)") + kvIncrCmd.Flags().Int("ttl", 0, "TTL in seconds to (re)set on the key (0 = preserve existing)") + + kvCASCmd.Flags().String("expected", "", "expected current JSON value; 'null' means the key must not exist (required)") + kvCASCmd.Flags().String("new", "", "new JSON value to store if the precondition holds (required)") + kvCASCmd.Flags().Int("ttl", 0, "TTL in seconds to set on the new value (0 = no expiry)") + kvCASCmd.MarkFlagRequired("expected") + kvCASCmd.MarkFlagRequired("new") + kvCmd.AddCommand(kvListCmd) kvCmd.AddCommand(kvGetCmd) kvCmd.AddCommand(kvPutCmd) kvCmd.AddCommand(kvDeleteCmd) + kvCmd.AddCommand(kvIncrCmd) + kvCmd.AddCommand(kvCASCmd) } func runKVList(cmd *cobra.Command, args []string) error { @@ -295,3 +340,120 @@ func runKVDelete(cmd *cobra.Command, args []string) error { okf(cmd, "KV entry %q deleted", key) return nil } + +func runKVIncr(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + + fnID, err := resolveFnID(client, args[0]) + if err != nil { + return err + } + key := args[1] + by, _ := cmd.Flags().GetInt64("by") + ttl, _ := cmd.Flags().GetInt("ttl") + + body := map[string]any{"delta": by} + if ttl > 0 { + body["ttl_seconds"] = ttl + } + + resp, err := client.Post("/api/v1/functions/"+fnID+"/kv/"+url.PathEscape(key)+"/incr", body) + if err != nil { + return fmt.Errorf("incr: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if outputJSON(cmd) { + return emitRaw(raw) + } + var result struct { + Value int64 `json:"value"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("decode response: %w", err) + } + // The new value is data → stdout (pipes into scripts); status stays on stderr. + fmt.Println(result.Value) + return nil +} + +func runKVCAS(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + + fnID, err := resolveFnID(client, args[0]) + if err != nil { + return err + } + key := args[1] + expectedArg, _ := cmd.Flags().GetString("expected") + newArg, _ := cmd.Flags().GetString("new") + ttl, _ := cmd.Flags().GetInt("ttl") + + var expected json.RawMessage + if err := json.Unmarshal([]byte(expectedArg), &expected); err != nil { + return fmt.Errorf("cas: --expected must be valid JSON (use 'null' for insert-if-absent): %w", err) + } + var newVal json.RawMessage + if err := json.Unmarshal([]byte(newArg), &newVal); err != nil { + return fmt.Errorf("cas: --new must be valid JSON: %w", err) + } + + body := map[string]any{"expected": expected, "new": newVal} + if ttl > 0 { + body["ttl_seconds"] = ttl + } + + resp, err := client.Post("/api/v1/functions/"+fnID+"/kv/"+url.PathEscape(key)+"/cas", body) + if err != nil { + return fmt.Errorf("cas: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if outputJSON(cmd) { + // JSON mode prints the server envelope verbatim to stdout; still exit + // non-zero on a precondition miss so `... cas -o json && echo won` works. + if err := emitRaw(raw); err != nil { + return err + } + var r struct { + OK bool `json:"ok"` + } + _ = json.Unmarshal(raw, &r) + if !r.OK { + return fmt.Errorf("CAS precondition not met for %q", key) + } + return nil + } + + var result struct { + OK bool `json:"ok"` + Current json.RawMessage `json:"current"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("decode response: %w", err) + } + if result.OK { + okf(cmd, "swapped %q", key) + return emitRaw(newVal) + } + // Precondition mismatch: print the current value (data → stdout) so a script + // can read it, then exit non-zero with a clear message on stderr. + if len(result.Current) > 0 { + _ = emitRaw(result.Current) + } + return fmt.Errorf("CAS precondition not met for %q", key) +} diff --git a/cli/commands/traces.go b/cli/commands/traces.go index b1cb941..cdddeab 100644 --- a/cli/commands/traces.go +++ b/cli/commands/traces.go @@ -74,6 +74,7 @@ current sample count. The baseline drives the outlier flag on each span. func init() { tracesListCmd.Flags().String("fn", "", "filter to traces whose root span is this function (name or id)") tracesListCmd.Flags().Int("limit", 50, "max number of traces to return (1-200)") + tracesListCmd.Flags().String("before", "", "cursor: return traces older than this timestamp (from a prior page's next_cursor)") tracesCmd.AddCommand( tracesListCmd, @@ -156,6 +157,9 @@ func runTracesList(cmd *cobra.Command, args []string) error { if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 { q.Set("limit", fmt.Sprintf("%d", limit)) } + if before, _ := cmd.Flags().GetString("before"); before != "" { + q.Set("before", before) + } path := "/api/v1/traces" if len(q) > 0 { @@ -177,7 +181,8 @@ func runTracesList(cmd *cobra.Command, args []string) error { } var result struct { - Traces []rootSpanRow `json:"traces"` + Traces []rootSpanRow `json:"traces"` + NextCursor string `json:"next_cursor"` } if err := json.Unmarshal(raw, &result); err != nil { return fmt.Errorf("decode response: %w", err) @@ -196,6 +201,10 @@ func runTracesList(cmd *cobra.Command, args []string) error { t.row(tr.TraceID, dash(root), dash(tr.Status), formatDuration(tr.DurationMS), dash(tr.StartedAt)) } t.flush() + + if result.NextCursor != "" { + infof(cmd, "\nMore available; next page: --before %s", result.NextCursor) + } return nil }