carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// `carlos errors` — an app's error groups with no box access (spec
2// 2026-09-07-errors-and-analytics-design.md §3.6): log lines at the
3// configured level and edge-observed 5xx, coalesced by fingerprint, read
4// through the console API. API mode only, like `carlos logs`.
5package main
6
7import (
8 "context"
9 "flag"
10 "fmt"
11 "io"
12 "log"
13 "net/url"
14 "sort"
15 "strconv"
16
17 "github.com/carlosframework/platform/internal/apperr"
18)
19
20const errorsUsage = `usage: carlos errors --app <a> [flags]
21
22Lists error groups, newest first: one row per fingerprint with its count in
23the window, the source it came from (log, edge, server, browser), the hosts
24and releases it was seen on. --fp <id> expands one group to its occurrences.
25--set-level error|warn changes which log lines count for this app.
26`
27
28func runErrors(args []string) {
29 fs := flag.NewFlagSet("errors", flag.ExitOnError)
30 app := appFlag(fs, "app whose errors to read")
31 since := fs.String("since", "24h", "window start: duration back from now, or RFC3339")
32 until := fs.String("until", "", "window end (default: now)")
33 source := fs.String("source", "", "only one source: log | edge | server | browser")
34 level := fs.String("level", "", "log lines at this level and above for this read: error | warn (default: the app's setting)")
35 host := fs.String("host", "", "only one instance (route host)")
36 rel := fs.String("release", "", "only one release")
37 fp := fs.String("fp", "", "expand one group: its occurrences, oldest first")
38 setLevel := fs.String("set-level", "", "set the app's log level for errors: error | warn")
39 consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)")
40 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
41 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
42 fs.Usage = flagUsage(fs, errorsUsage)
43 fs.Parse(args)
44
45 sess := requireAPISession("errors", *consoleFlag)
46 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
47 app2 := resolvedApp(sess, acct, *app, errorsUsage)
48 ctx := context.Background()
49
50 if *setLevel != "" {
51 if *setLevel != "error" && *setLevel != "warn" {
52 log.Fatalf("errors: --set-level wants error or warn")
53 }
54 if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"errors": map[string]string{"log_level": *setLevel}}); err != nil {
55 log.Fatalf("errors: %v", err)
56 }
57 fmt.Fprintf(stdout, "Errors for %s now include %s lines and above.\n", app2, *setLevel)
58 return
59 }
60
61 q := url.Values{}
62 for k, v := range map[string]string{"since": *since, "until": *until, "source": *source, "level": *level, "host": *host, "release": *rel, "fp": *fp} {
63 if v != "" {
64 q.Set(k, v)
65 }
66 }
67 res, err := sess.errorsGet(ctx, acct, app2, q)
68 if err != nil {
69 log.Fatalf("errors: %v", err)
70 }
71 if *fp != "" {
72 printErrorRecords(stdout, res.Records)
73 if len(res.Records) == 0 {
74 mutedf(stdout, "no occurrences of %s in this window", *fp)
75 }
76 return
77 }
78 printErrorGroups(stdout, res.Groups)
79 if len(res.Groups) == 0 {
80 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"))
81 } else {
82 mutedf(stdout, "log level %s; sources live: %s; counts are to the nearest flush", res.LogLevel, joinOr(res.Sources, "none"))
83 }
84 if res.Truncated {
85 mutedf(stdout, "(the window held more than could be read; counts are within what was read — narrow it)")
86 }
87}
88
89func joinOr(xs []string, empty string) string {
90 if len(xs) == 0 {
91 return empty
92 }
93 out := xs[0]
94 for _, x := range xs[1:] {
95 out += ", " + x
96 }
97 return out
98}
99
100// printErrorGroups renders one row per group: last seen, count, source,
101// message, then the fingerprint, hosts and releases dimmed.
102func printErrorGroups(w io.Writer, groups []apperr.Group) {
103 c := colorFor(w)
104 for _, g := range groups {
105 count := strconv.Itoa(g.Count)
106 if g.FP == apperr.OverflowFP {
107 count += " (overflow)"
108 }
109 line := c.muted(g.Last.UTC().Format("2006-01-02 15:04")) + " " + c.head(fmt.Sprintf("%6s", count)) + " " +
110 c.muted(fmt.Sprintf("%-8s", g.Source)) + " " + c.bad(g.Name)
111 line += "\n" + " " + c.muted("fp="+g.FP)
112 if len(g.Hosts) > 0 {
113 line += c.muted(" hosts=" + joinOr(g.Hosts, ""))
114 }
115 if len(g.Releases) > 0 {
116 line += c.muted(" releases=" + joinOr(g.Releases, ""))
117 }
118 fmt.Fprintln(w, line)
119 }
120}
121
122// printErrorRecords renders one group's occurrences, oldest first.
123func printErrorRecords(w io.Writer, recs []apperr.Record) {
124 c := colorFor(w)
125 for _, r := range recs {
126 fmt.Fprintf(w, "%s %s %s %s\n", c.muted(r.Last.UTC().Format("2006-01-02 15:04:05")),
127 c.head(fmt.Sprintf("x%d", r.Count)), c.muted(r.Host), c.bad(r.Name))
128 if r.Path != "" {
129 fmt.Fprintf(w, " %s\n", c.muted(fmt.Sprintf("%s → %d", r.Path, r.Status)))
130 }
131 if r.Release != "" {
132 fmt.Fprintf(w, " %s\n", c.muted("release "+r.Release))
133 }
134 if r.Stack != "" {
135 fmt.Fprintf(w, " %s\n", r.Stack)
136 }
137 keys := make([]string, 0, len(r.Fields))
138 for k := range r.Fields {
139 keys = append(keys, k)
140 }
141 sort.Strings(keys)
142 for _, k := range keys {
143 fmt.Fprintf(w, " %s\n", c.muted(k+"="+r.Fields[k]))
144 }
145 }
146}
147