carlos / platform Public

carlos errors: the app's error groups from a terminal, and the log-level setting

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev efa45eb98f6ec5031e759d9831b0c16a2e19f827 parent fa557c0
5 files changed, +244 −0
  • apimode.go +52 −0
  • docs/site/cli.md +17 −0
  • errors_cmd.go +146 −0
  • errors_cmd_test.go +25 −0
  • main.go +4 −0
diff --git a/apimode.go b/apimode.go
index fbb91f813..226121240 100644
--- a/apimode.go
+++ b/apimode.go
@@ -33,6 +33,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/apperr"
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/ledger"
@@ -2842,6 +2843,57 @@ func (s *apiSession) logsGet(ctx context.Context, a apiAcct, app string, query u
return res, nil
}
+// apiErrorsResponse is the console's errors answer (spec
+// 2026-09-07-errors-and-analytics-design.md §3.6) — groups and records are
+// apperr's own wire shapes, decoded as-is.
+type apiErrorsResponse struct {
+ Groups []apperr.Group `json:"groups"`
+ Records []apperr.Record `json:"records"`
+ Truncated bool `json:"truncated"`
+ LogLevel string `json:"log_level"`
+ Sources []string `json:"sources"`
+}
+
+// errorsGet reads one window of an app's error groups (or, with
+// query["fp"] set, one group's occurrences). query carries the filter
+// params exactly as the endpoint speaks them; apiPath may already hold a
+// query (exact=sqid), hence the separator dance.
+func (s *apiSession) errorsGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiErrorsResponse, error) {
+ path := apiPath(a, app, "/errors")
+ if enc := query.Encode(); enc != "" {
+ if strings.Contains(path, "?") {
+ path += "&" + enc
+ } else {
+ path += "?" + enc
+ }
+ }
+ var res apiErrorsResponse
+ status, body, err := s.getJSON(ctx, path)
+ if err != nil {
+ return res, err
+ }
+ if status != http.StatusOK {
+ return res, apiErrorFromBody(status, body)
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return res, fmt.Errorf("errors: unreadable response: %w", err)
+ }
+ return res, nil
+}
+
+// telemetryPut sets an app's telemetry settings (currently just the errors
+// log level) — PUT /api/cli/apps/{account}/{app}/telemetry.
+func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, body any) error {
+ status, resp, err := s.putJSON(ctx, apiPath(a, app, "/telemetry"), body)
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, resp)
+ }
+ return nil
+}
+
// instanceDelete retires one instance record. Idempotent server-side (the
// caller's goal is "not there"), so a 200 is the only success shape.
func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host string) error {
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 9b13092e1..6b7b3c38e 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -562,6 +562,23 @@ carlos logs --app hello --since 1h -f
an RE2 pattern, `--stream app` drops the platform commentary, and `--host`
narrows to one instance.
+### carlos errors
+
+One row per distinct error, newest first, with how many times it happened
+in the window and where. Two sources need no wiring at all: log lines the
+app tagged `error` (or `warn`, if you set the level) and responses of 500
+or above that the edge saw. Server-side and browser hooks come later.
+
+```sh
+carlos errors --app hello --since 24h
+carlos errors --app hello --fp 3f2a9c1b5e7d0a4c
+carlos errors --app hello --set-level warn
+```
+
+`--fp` expands one group to its occurrences. `--level warn` widens a single
+read without changing the setting; `--set-level` changes it for the app.
+The same view is the Errors tab on the app's dashboard.
+
### carlos store
Object storage for an app. `create` declares that the app wants a bucket,
diff --git a/errors_cmd.go b/errors_cmd.go
new file mode 100644
index 000000000..904f031dd
--- /dev/null
+++ b/errors_cmd.go
@@ -0,0 +1,146 @@
+// `carlos errors` — an app's error groups with no box access (spec
+// 2026-09-07-errors-and-analytics-design.md §3.6): log lines at the
+// configured level and edge-observed 5xx, coalesced by fingerprint, read
+// through the console API. API mode only, like `carlos logs`.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/url"
+ "sort"
+ "strconv"
+
+ "github.com/carlosframework/platform/internal/apperr"
+)
+
+const errorsUsage = `usage: carlos errors --app <a> [flags]
+
+Lists error groups, newest first: one row per fingerprint with its count in
+the window, the source it came from (log, edge, server, browser), the hosts
+and releases it was seen on. --fp <id> expands one group to its occurrences.
+--set-level error|warn changes which log lines count for this app.
+`
+
+func runErrors(args []string) {
+ fs := flag.NewFlagSet("errors", flag.ExitOnError)
+ app := appFlag(fs, "app whose errors to read")
+ since := fs.String("since", "24h", "window start: duration back from now, or RFC3339")
+ until := fs.String("until", "", "window end (default: now)")
+ source := fs.String("source", "", "only one source: log | edge | server | browser")
+ level := fs.String("level", "", "log lines at this level and above for this read: error | warn (default: the app's setting)")
+ host := fs.String("host", "", "only one instance (route host)")
+ rel := fs.String("release", "", "only one release")
+ fp := fs.String("fp", "", "expand one group: its occurrences, oldest first")
+ setLevel := fs.String("set-level", "", "set the app's log level for errors: error | warn")
+ consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ fs.Usage = flagUsage(fs, errorsUsage)
+ fs.Parse(args)
+
+ sess := requireAPISession("errors", *consoleFlag)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, errorsUsage)
+ ctx := context.Background()
+
+ if *setLevel != "" {
+ if *setLevel != "error" && *setLevel != "warn" {
+ log.Fatalf("errors: --set-level wants error or warn")
+ }
+ if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"errors": map[string]string{"log_level": *setLevel}}); err != nil {
+ log.Fatalf("errors: %v", err)
+ }
+ fmt.Fprintf(stdout, "Errors for %s now include %s lines and above.\n", app2, *setLevel)
+ return
+ }
+
+ q := url.Values{}
+ for k, v := range map[string]string{"since": *since, "until": *until, "source": *source, "level": *level, "host": *host, "release": *rel, "fp": *fp} {
+ if v != "" {
+ q.Set(k, v)
+ }
+ }
+ res, err := sess.errorsGet(ctx, acct, app2, q)
+ if err != nil {
+ log.Fatalf("errors: %v", err)
+ }
+ if *fp != "" {
+ printErrorRecords(stdout, res.Records)
+ if len(res.Records) == 0 {
+ mutedf(stdout, "no occurrences of %s in this window", *fp)
+ }
+ return
+ }
+ printErrorGroups(stdout, res.Groups)
+ if len(res.Groups) == 0 {
+ mutedf(stdout, "no errors in this window (log level %s; sources live: %s) — try --since 168h or --level warn", res.LogLevel, joinOr(res.Sources, "none yet"))
+ } else {
+ mutedf(stdout, "log level %s; sources live: %s; counts are to the nearest flush", res.LogLevel, joinOr(res.Sources, "none"))
+ }
+ if res.Truncated {
+ mutedf(stdout, "(the window held more than could be read; counts are within what was read — narrow it)")
+ }
+}
+
+func joinOr(xs []string, empty string) string {
+ if len(xs) == 0 {
+ return empty
+ }
+ out := xs[0]
+ for _, x := range xs[1:] {
+ out += ", " + x
+ }
+ return out
+}
+
+// printErrorGroups renders one row per group: last seen, count, source,
+// message, then the fingerprint, hosts and releases dimmed.
+func printErrorGroups(w io.Writer, groups []apperr.Group) {
+ c := colorFor(w)
+ for _, g := range groups {
+ count := strconv.Itoa(g.Count)
+ if g.FP == apperr.OverflowFP {
+ count += " (overflow)"
+ }
+ line := c.muted(g.Last.UTC().Format("2006-01-02 15:04")) + " " + c.head(fmt.Sprintf("%6s", count)) + " " +
+ c.muted(fmt.Sprintf("%-7s", g.Source)) + " " + c.bad(g.Name)
+ line += "\n" + " " + c.muted("fp="+g.FP)
+ if len(g.Hosts) > 0 {
+ line += c.muted(" hosts=" + joinOr(g.Hosts, ""))
+ }
+ if len(g.Releases) > 0 {
+ line += c.muted(" releases=" + joinOr(g.Releases, ""))
+ }
+ fmt.Fprintln(w, line)
+ }
+}
+
+// printErrorRecords renders one group's occurrences, oldest first.
+func printErrorRecords(w io.Writer, recs []apperr.Record) {
+ c := colorFor(w)
+ for _, r := range recs {
+ fmt.Fprintf(w, "%s %s %s %s\n", c.muted(r.Last.UTC().Format("2006-01-02 15:04:05")),
+ c.head(fmt.Sprintf("x%d", r.Count)), c.muted(r.Host), c.bad(r.Name))
+ if r.Path != "" {
+ fmt.Fprintf(w, " %s\n", c.muted(fmt.Sprintf("%s → %d", r.Path, r.Status)))
+ }
+ if r.Release != "" {
+ fmt.Fprintf(w, " %s\n", c.muted("release "+r.Release))
+ }
+ if r.Stack != "" {
+ fmt.Fprintf(w, " %s\n", r.Stack)
+ }
+ keys := make([]string, 0, len(r.Fields))
+ for k := range r.Fields {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ fmt.Fprintf(w, " %s\n", c.muted(k+"="+r.Fields[k]))
+ }
+ }
+}
diff --git a/errors_cmd_test.go b/errors_cmd_test.go
new file mode 100644
index 000000000..d4c44aa6d
--- /dev/null
+++ b/errors_cmd_test.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/apperr"
+)
+
+func TestPrintErrorGroups(t *testing.T) {
+ now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
+ var buf bytes.Buffer
+ printErrorGroups(&buf, []apperr.Group{
+ {FP: "abc123", Source: "edge", Name: "500 POST /checkout", Count: 12, Last: now, Hosts: []string{"a.example"}, Releases: []string{"v3"}},
+ {FP: "_overflow", Source: "edge", Name: "500 GET /z", Count: 40, Last: now},
+ })
+ out := buf.String()
+ for _, want := range []string{"2026-09-07 10:00", "12", "edge", "500 POST /checkout", "abc123", "a.example", "v3", "overflow"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("output missing %q:\n%s", want, out)
+ }
+ }
+}
diff --git a/main.go b/main.go
index fa80dc99c..1a6ff88b0 100644
--- a/main.go
+++ b/main.go
@@ -79,6 +79,8 @@ func main() {
runEmail(args)
case "logs":
runLogs(args)
+ case "errors":
+ runErrors(args)
case "channels":
runChannels(args)
case "pipeline":
@@ -196,6 +198,8 @@ Your apps — ship and run software on a CARLOS deployment
platform, never interpreted: set | list
logs an app's merged log timeline: its own output plus platform
events (wakes, restarts, failures); -f follows live
+ errors error groups for an app: log lines at the set level plus
+ 5xx the edge saw
ledger open ledgers: append | head | list | publish | blob | verify
vet check a release against the platform contract
update update this carlos binary to the latest release (or name