// `carlos logs` — an app's merged log timeline with no box access (spec // 2026-08-10-carlos-logs-design.md §7, issues #131/#78): the app's own // output plus the platform's events about it (wakes, restarts, failures), // read through the console API. API mode ONLY, deliberately: the journal on // a box was never the member surface, and the box-local form is exactly the // gap this verb closes. package main import ( "context" "flag" "fmt" "io" "log" "net/url" "sort" "strconv" "time" "github.com/carlosframework/platform/internal/applog" ) const logsUsage = `usage: carlos logs --app [flags] Shows the newest lines first-to-last: the app's own stdout/stderr ("app") merged with the platform's events about it ("platform" — wakes, restarts, failures with the reason). A site served from files alone has no app stream; platform events still appear. ` // logsFollowInterval is the -f poll cadence — the serial poll's own 2s, the // deployment's one convergence heartbeat. const logsFollowInterval = 2 * time.Second // logsMaxPolls bounds -f for the tests' benefit: 0 = forever (production). var logsMaxPolls = 0 func runLogs(args []string) { fs := flag.NewFlagSet("logs", flag.ExitOnError) app := appFlag(fs, "app whose logs to read") since := fs.String("since", "1h", "window start: duration back from now, or RFC3339") until := fs.String("until", "", "window end (default: now)") stream := fs.String("stream", "", "only one stream: app | platform | edge | release") host := fs.String("host", "", "only one instance (route host)") level := fs.String("level", "", "minimum level: debug | info | warn | error") grep := fs.String("grep", "", "only messages matching this RE2 pattern") var fields multiFlag fs.Var(&fields, "field", "only structured lines with this k=v field (repeatable)") n := fs.Int("n", 0, "at most this many lines (default 1000, max 5000)") follow := fs.Bool("f", false, "follow: poll for new lines until interrupted") 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, logsUsage) fs.Parse(args) q := url.Values{} for k, v := range map[string]string{ "since": *since, "until": *until, "stream": *stream, "host": *host, "level": *level, "grep": *grep, } { if v != "" { q.Set(k, v) } } for _, f := range fields { q.Add("field", f) } if *n > 0 { q.Set("limit", strconv.Itoa(*n)) } sess := requireAPISession("logs", *consoleFlag) acct := resolvedAccount(sess, acctRaw, acctIDRaw) app2 := resolvedApp(sess, acct, *app, logsUsage) ctx := context.Background() res, err := sess.logsGet(ctx, acct, app2, q) if err != nil { log.Fatalf("logs: %v", err) } printLogRecords(stdout, res.Records) if len(res.Records) == 0 && !*follow { mutedf(stdout, "no log lines in this window — platform events appear on wakes and restarts; try --since 24h") } if res.Truncated { mutedf(stdout, "(showing the newest %d lines of this window — narrow it, or raise -n)", len(res.Records)) } if !*follow { return } // Follow: the same endpoint, strictly-after the cursor, forever. The // window params are dropped on purpose — a follow reads the PRESENT, // and the server treats a cursor request as unbounded-forward. fq := url.Values{} for _, k := range []string{"stream", "host", "level", "grep"} { if v := q.Get(k); v != "" { fq.Set(k, v) } } for _, f := range fields { fq.Add("field", f) } cursor := res.Cursor for polls := 0; logsMaxPolls == 0 || polls < logsMaxPolls; polls++ { time.Sleep(logsFollowInterval) if cursor != "" { fq.Set("cursor", cursor) } res, err := sess.logsGet(ctx, acct, app2, fq) if err != nil { // A follow rides through a blip; the next poll answers or the // member interrupts. Say it once per failure, quietly. mutedf(stdout, "(logs: %v — still following)", err) continue } printLogRecords(stdout, res.Records) if res.Cursor != "" { cursor = res.Cursor } } } // printLogRecords renders records one line each: UTC time, stream, host, // message (error lines red, warn yellow), then any structured fields dimmed. func printLogRecords(w io.Writer, recs []applog.Record) { c := colorFor(w) for _, r := range recs { msg := r.Msg switch r.Level { case applog.LevelError: msg = c.bad(msg) case applog.LevelWarn: msg = c.warn(msg) } line := c.muted(r.TS.UTC().Format("2006-01-02 15:04:05")) + " " + c.head("["+r.Stream+"]") + " " + c.muted(r.Host) + " " + msg keys := make([]string, 0, len(r.Fields)) for k := range r.Fields { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { line += " " + c.muted(k+"="+r.Fields[k]) } fmt.Fprintln(w, line) } } // multiFlag collects a repeatable string flag. type multiFlag []string func (m *multiFlag) String() string { return fmt.Sprint([]string(*m)) } func (m *multiFlag) Set(v string) error { *m = append(*m, v) return nil }