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>
5 files changed,
+244
−0
apimode.go+52 −0docs/site/cli.md+17 −0errors_cmd.go+146 −0errors_cmd_test.go+25 −0main.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 |