| 1 | // `carlos logs` — an app's merged log timeline with no box access (spec |
| 2 | // 2026-08-10-carlos-logs-design.md §7, issues #131/#78): the app's own |
| 3 | // output plus the platform's events about it (wakes, restarts, failures), |
| 4 | // read through the console API. API mode ONLY, deliberately: the journal on |
| 5 | // a box was never the member surface, and the box-local form is exactly the |
| 6 | // gap this verb closes. |
| 7 | package main |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "flag" |
| 12 | "fmt" |
| 13 | "io" |
| 14 | "log" |
| 15 | "net/url" |
| 16 | "sort" |
| 17 | "strconv" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/carlosframework/platform/internal/applog" |
| 21 | ) |
| 22 | |
| 23 | const logsUsage = `usage: carlos logs --app <a> [flags] |
| 24 | |
| 25 | Shows the newest lines first-to-last: the app's own stdout/stderr ("app") |
| 26 | merged with the platform's events about it ("platform" — wakes, restarts, |
| 27 | failures with the reason). A site served from files alone has no app |
| 28 | stream; platform events still appear. |
| 29 | ` |
| 30 | |
| 31 | // logsFollowInterval is the -f poll cadence — the serial poll's own 2s, the |
| 32 | // deployment's one convergence heartbeat. |
| 33 | const logsFollowInterval = 2 * time.Second |
| 34 | |
| 35 | // logsMaxPolls bounds -f for the tests' benefit: 0 = forever (production). |
| 36 | var logsMaxPolls = 0 |
| 37 | |
| 38 | func runLogs(args []string) { |
| 39 | fs := flag.NewFlagSet("logs", flag.ExitOnError) |
| 40 | app := appFlag(fs, "app whose logs to read") |
| 41 | since := fs.String("since", "1h", "window start: duration back from now, or RFC3339") |
| 42 | until := fs.String("until", "", "window end (default: now)") |
| 43 | stream := fs.String("stream", "", "only one stream: app | platform | edge | release") |
| 44 | host := fs.String("host", "", "only one instance (route host)") |
| 45 | level := fs.String("level", "", "minimum level: debug | info | warn | error") |
| 46 | grep := fs.String("grep", "", "only messages matching this RE2 pattern") |
| 47 | var fields multiFlag |
| 48 | fs.Var(&fields, "field", "only structured lines with this k=v field (repeatable)") |
| 49 | n := fs.Int("n", 0, "at most this many lines (default 1000, max 5000)") |
| 50 | follow := fs.Bool("f", false, "follow: poll for new lines until interrupted") |
| 51 | consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)") |
| 52 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 53 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 54 | fs.Usage = flagUsage(fs, logsUsage) |
| 55 | fs.Parse(args) |
| 56 | |
| 57 | q := url.Values{} |
| 58 | for k, v := range map[string]string{ |
| 59 | "since": *since, "until": *until, "stream": *stream, |
| 60 | "host": *host, "level": *level, "grep": *grep, |
| 61 | } { |
| 62 | if v != "" { |
| 63 | q.Set(k, v) |
| 64 | } |
| 65 | } |
| 66 | for _, f := range fields { |
| 67 | q.Add("field", f) |
| 68 | } |
| 69 | if *n > 0 { |
| 70 | q.Set("limit", strconv.Itoa(*n)) |
| 71 | } |
| 72 | |
| 73 | sess := requireAPISession("logs", *consoleFlag) |
| 74 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 75 | app2 := resolvedApp(sess, acct, *app, logsUsage) |
| 76 | ctx := context.Background() |
| 77 | res, err := sess.logsGet(ctx, acct, app2, q) |
| 78 | if err != nil { |
| 79 | log.Fatalf("logs: %v", err) |
| 80 | } |
| 81 | printLogRecords(stdout, res.Records) |
| 82 | if len(res.Records) == 0 && !*follow { |
| 83 | mutedf(stdout, "no log lines in this window — platform events appear on wakes and restarts; try --since 24h") |
| 84 | } |
| 85 | if res.Truncated { |
| 86 | mutedf(stdout, "(showing the newest %d lines of this window — narrow it, or raise -n)", len(res.Records)) |
| 87 | } |
| 88 | if !*follow { |
| 89 | return |
| 90 | } |
| 91 | |
| 92 | // Follow: the same endpoint, strictly-after the cursor, forever. The |
| 93 | // window params are dropped on purpose — a follow reads the PRESENT, |
| 94 | // and the server treats a cursor request as unbounded-forward. |
| 95 | fq := url.Values{} |
| 96 | for _, k := range []string{"stream", "host", "level", "grep"} { |
| 97 | if v := q.Get(k); v != "" { |
| 98 | fq.Set(k, v) |
| 99 | } |
| 100 | } |
| 101 | for _, f := range fields { |
| 102 | fq.Add("field", f) |
| 103 | } |
| 104 | cursor := res.Cursor |
| 105 | for polls := 0; logsMaxPolls == 0 || polls < logsMaxPolls; polls++ { |
| 106 | time.Sleep(logsFollowInterval) |
| 107 | if cursor != "" { |
| 108 | fq.Set("cursor", cursor) |
| 109 | } |
| 110 | res, err := sess.logsGet(ctx, acct, app2, fq) |
| 111 | if err != nil { |
| 112 | // A follow rides through a blip; the next poll answers or the |
| 113 | // member interrupts. Say it once per failure, quietly. |
| 114 | mutedf(stdout, "(logs: %v — still following)", err) |
| 115 | continue |
| 116 | } |
| 117 | printLogRecords(stdout, res.Records) |
| 118 | if res.Cursor != "" { |
| 119 | cursor = res.Cursor |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // printLogRecords renders records one line each: UTC time, stream, host, |
| 125 | // message (error lines red, warn yellow), then any structured fields dimmed. |
| 126 | func printLogRecords(w io.Writer, recs []applog.Record) { |
| 127 | c := colorFor(w) |
| 128 | for _, r := range recs { |
| 129 | msg := r.Msg |
| 130 | switch r.Level { |
| 131 | case applog.LevelError: |
| 132 | msg = c.bad(msg) |
| 133 | case applog.LevelWarn: |
| 134 | msg = c.warn(msg) |
| 135 | } |
| 136 | line := c.muted(r.TS.UTC().Format("2006-01-02 15:04:05")) + " " + |
| 137 | c.head("["+r.Stream+"]") + " " + c.muted(r.Host) + " " + msg |
| 138 | keys := make([]string, 0, len(r.Fields)) |
| 139 | for k := range r.Fields { |
| 140 | keys = append(keys, k) |
| 141 | } |
| 142 | sort.Strings(keys) |
| 143 | for _, k := range keys { |
| 144 | line += " " + c.muted(k+"="+r.Fields[k]) |
| 145 | } |
| 146 | fmt.Fprintln(w, line) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // multiFlag collects a repeatable string flag. |
| 151 | type multiFlag []string |
| 152 | |
| 153 | func (m *multiFlag) String() string { return fmt.Sprint([]string(*m)) } |
| 154 | func (m *multiFlag) Set(v string) error { |
| 155 | *m = append(*m, v) |
| 156 | return nil |
| 157 | } |
| 158 | |