// `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 [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 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 := consoleOption(fs, "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("%-8s", 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])) } } }