| 1 | // `carlos availability` — the requests an app's instances could not answer, |
| 2 | // with no box access (spec 2026-09-07-errors-and-analytics-design.md §3.2, |
| 3 | // amendment 2026-09-24): edge 502/503/504 grouped by cause, and the |
| 4 | // platform's events about the instance's lifecycle, read through the |
| 5 | // console API. The Errors command's sibling; API mode only, like it. |
| 6 | package main |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "flag" |
| 11 | "log" |
| 12 | "net/url" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/apperr" |
| 15 | ) |
| 16 | |
| 17 | const availabilityUsage = `usage: carlos availability --app <a> [flags] |
| 18 | |
| 19 | Lists the requests the app's instances could not answer, newest first: one |
| 20 | row per cause (the instance refused the connection, hung up, timed out, no |
| 21 | fleet box connected, the app answered 503 itself...) with its count in the |
| 22 | window and the hosts and releases it was seen on, plus the platform's own |
| 23 | events about the instance (failed wakes, exits). A 502 on any page of a |
| 24 | dead instance is one row, not one per page. --fp <id> expands one cause to |
| 25 | its occurrences, each with the path that was being asked for. |
| 26 | ` |
| 27 | |
| 28 | func runAvailability(args []string) { |
| 29 | fs := flag.NewFlagSet("availability", flag.ExitOnError) |
| 30 | app := appFlag(fs, "app whose availability 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 | host := fs.String("host", "", "only one instance (route host)") |
| 34 | rel := fs.String("release", "", "only one release") |
| 35 | fp := fs.String("fp", "", "expand one cause: its occurrences, oldest first") |
| 36 | consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| 37 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 38 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 39 | fs.Usage = flagUsage(fs, availabilityUsage) |
| 40 | fs.Parse(args) |
| 41 | |
| 42 | sess := requireAPISession("availability", *consoleFlag) |
| 43 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 44 | app2 := resolvedApp(sess, acct, *app, availabilityUsage) |
| 45 | |
| 46 | q := url.Values{} |
| 47 | for k, v := range map[string]string{"since": *since, "until": *until, "host": *host, "release": *rel, "fp": *fp} { |
| 48 | if v != "" { |
| 49 | q.Set(k, v) |
| 50 | } |
| 51 | } |
| 52 | res, err := sess.availabilityGet(context.Background(), acct, app2, q) |
| 53 | if err != nil { |
| 54 | log.Fatalf("availability: %v", err) |
| 55 | } |
| 56 | if *fp != "" { |
| 57 | printErrorRecords(stdout, res.Records) |
| 58 | if len(res.Records) == 0 { |
| 59 | mutedf(stdout, "no occurrences of %s in this window", *fp) |
| 60 | } |
| 61 | return |
| 62 | } |
| 63 | printErrorGroups(stdout, platformSourced(res.Groups)) |
| 64 | if len(res.Groups) == 0 { |
| 65 | mutedf(stdout, "no availability problems in this window — try --since 168h") |
| 66 | } else { |
| 67 | mutedf(stdout, "counts are to the nearest flush; the app's own 500s are under `carlos errors`") |
| 68 | } |
| 69 | if res.Truncated { |
| 70 | mutedf(stdout, "(the window held more than could be read; counts are within what was read — narrow it)") |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // platformSourced names a log-derived group by what it is here: every log |
| 75 | // line Availability shows is a platform event, and "log" would read as the |
| 76 | // app's own output. |
| 77 | func platformSourced(groups []apperr.Group) []apperr.Group { |
| 78 | out := make([]apperr.Group, len(groups)) |
| 79 | for i, g := range groups { |
| 80 | if g.Source == apperr.SourceLog { |
| 81 | g.Source = "platform" |
| 82 | } |
| 83 | out[i] = g |
| 84 | } |
| 85 | return out |
| 86 | } |
| 87 | |