carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
7package main
8
9import (
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
23const logsUsage = `usage: carlos logs --app <a> [flags]
24
25Shows the newest lines first-to-last: the app's own stdout/stderr ("app")
26merged with the platform's events about it ("platform" — wakes, restarts,
27failures with the reason). A site served from files alone has no app
28stream; 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.
33const logsFollowInterval = 2 * time.Second
34
35// logsMaxPolls bounds -f for the tests' benefit: 0 = forever (production).
36var logsMaxPolls = 0
37
38func 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.
126func 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.
151type multiFlag []string
152
153func (m *multiFlag) String() string { return fmt.Sprint([]string(*m)) }
154func (m *multiFlag) Set(v string) error {
155 *m = append(*m, v)
156 return nil
157}
158