// The CLI's logs API — the read side of `carlos logs` (spec // 2026-08-10-carlos-logs-design.md §6): one owner-gated GET that merges the // app's shipped chunks across boxes, filters, and pages. It shares the // instance family's gate (instanceGate) deliberately: same principal proof, // same byte-identical 404, same rate limiter — and an APP token may read // its own app's logs, which is the same trust the instance verbs already // extend it. package console import ( "context" "log" "net/http" "regexp" "strconv" "time" "github.com/carlosframework/platform/internal/applog" ) const ( // logsDefaultLimit / logsMaxLimit bound one answer (spec §6). The // response's `truncated` says when the window held more. logsDefaultLimit = 1000 logsMaxLimit = 5000 // logsCollectCap bounds what one read will hold in memory while // assembling the window — the tail survives, and `truncated` reports it. logsCollectCap = 50000 // logsDefaultWindow applies when the caller sends no `since`: an // unbounded read would walk every hour of retention for no stated // reason; a caller that wants more says so. logsDefaultWindow = time.Hour ) // logsResponse is the wire shape both the CLI and the console tab read. type logsResponse struct { Records []applog.Record `json:"records"` Truncated bool `json:"truncated"` Cursor string `json:"cursor,omitempty"` } // handleAPILogs answers `carlos logs`: GET with since/until/stream/host/ // level/grep/field/limit/cursor query params. Without a cursor the answer // is the TAIL of the window (the last `limit` records — what a log reader // came for); with one it is the records strictly after that position, which // is what makes `-f` a plain 2s poll of this same endpoint. func (s *Server) handleAPILogs(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.instanceGate(w, r) if !ok { return } f, limit, badParam := logsFilterFromQuery(r, time.Now()) if badParam != "" { jsonError(w, http.StatusBadRequest, badParam) return } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap) if err != nil { log.Printf("console: api logs %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the logs") return } resp := logsResponse{Truncated: trimmed} if cur, ok := applog.DecodeCursor(r.URL.Query().Get("cursor")); ok { // Follow: everything strictly after the cursor, oldest first. var after []applog.Record for _, rec := range recs { if cur.Before(rec) { after = append(after, rec) } } if len(after) > limit { after = after[:limit] resp.Truncated = true } resp.Records = after } else { // Tail: the last `limit` records of the window. if len(recs) > limit { recs = recs[len(recs)-limit:] resp.Truncated = true } resp.Records = recs } if n := len(resp.Records); n > 0 { resp.Cursor = applog.EncodeCursor(resp.Records[n-1]) } else if c := r.URL.Query().Get("cursor"); c != "" { resp.Cursor = c // nothing new — the caller keeps polling from here } if resp.Records == nil { resp.Records = []applog.Record{} // "no logs" is [], not null } writeJSON(w, http.StatusOK, resp) } // logsFilterFromQuery builds the applog.Filter (plus record limit) from the // request. The empty badParam means the params parsed; anything else is the // 400 text, naming the parameter in the member's own terms. func logsFilterFromQuery(r *http.Request, now time.Time) (f applog.Filter, limit int, badParam string) { q := r.URL.Query() var err error if f.Since, err = applog.ParseTimeArg(q.Get("since"), now); err != nil { return f, 0, "since: " + err.Error() } if f.Until, err = applog.ParseTimeArg(q.Get("until"), now); err != nil { return f, 0, "until: " + err.Error() } if f.Since.IsZero() && q.Get("cursor") == "" { f.Since = now.Add(-logsDefaultWindow) } if v := q.Get("stream"); v != "" { if !applog.ValidStream(v) { return f, 0, "stream: want app, platform, edge or release" } f.Stream = v } f.Host = q.Get("host") if v := q.Get("level"); v != "" { if !applog.ValidLevel(v) { return f, 0, "level: want debug, info, warn or error" } f.Level = v } if v := q.Get("grep"); v != "" { re, err := regexp.Compile(v) if err != nil { return f, 0, "grep: " + err.Error() } f.Grep = re } for _, kv := range q["field"] { k, v, err := applog.ParseFieldArg(kv) if err != nil { return f, 0, err.Error() } if f.Fields == nil { f.Fields = map[string]string{} } f.Fields[k] = v } limit = logsDefaultLimit if v := q.Get("limit"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 { return f, 0, "limit: want a positive integer" } limit = min(n, logsMaxLimit) } return f, limit, "" }