carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's logs API — the read side of `carlos logs` (spec
2// 2026-08-10-carlos-logs-design.md §6): one owner-gated GET that merges the
3// app's shipped chunks across boxes, filters, and pages. It shares the
4// instance family's gate (instanceGate) deliberately: same principal proof,
5// same byte-identical 404, same rate limiter — and an APP token may read
6// its own app's logs, which is the same trust the instance verbs already
7// extend it.
8package console
9
10import (
11 "context"
12 "log"
13 "net/http"
14 "regexp"
15 "strconv"
16 "time"
17
18 "github.com/carlosframework/platform/internal/applog"
19)
20
21const (
22 // logsDefaultLimit / logsMaxLimit bound one answer (spec §6). The
23 // response's `truncated` says when the window held more.
24 logsDefaultLimit = 1000
25 logsMaxLimit = 5000
26 // logsCollectCap bounds what one read will hold in memory while
27 // assembling the window — the tail survives, and `truncated` reports it.
28 logsCollectCap = 50000
29 // logsDefaultWindow applies when the caller sends no `since`: an
30 // unbounded read would walk every hour of retention for no stated
31 // reason; a caller that wants more says so.
32 logsDefaultWindow = time.Hour
33)
34
35// logsResponse is the wire shape both the CLI and the console tab read.
36type logsResponse struct {
37 Records []applog.Record `json:"records"`
38 Truncated bool `json:"truncated"`
39 Cursor string `json:"cursor,omitempty"`
40}
41
42// handleAPILogs answers `carlos logs`: GET with since/until/stream/host/
43// level/grep/field/limit/cursor query params. Without a cursor the answer
44// is the TAIL of the window (the last `limit` records — what a log reader
45// came for); with one it is the records strictly after that position, which
46// is what makes `-f` a plain 2s poll of this same endpoint.
47func (s *Server) handleAPILogs(w http.ResponseWriter, r *http.Request) {
48 _, acct, app, ok := s.instanceGate(w, r)
49 if !ok {
50 return
51 }
52 f, limit, badParam := logsFilterFromQuery(r, time.Now())
53 if badParam != "" {
54 jsonError(w, http.StatusBadRequest, badParam)
55 return
56 }
57 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
58 defer cancel()
59 recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap)
60 if err != nil {
61 log.Printf("console: api logs %s/%s: %v", acct.Sqid, app, err)
62 jsonError(w, http.StatusBadGateway, "could not read the logs")
63 return
64 }
65
66 resp := logsResponse{Truncated: trimmed}
67 if cur, ok := applog.DecodeCursor(r.URL.Query().Get("cursor")); ok {
68 // Follow: everything strictly after the cursor, oldest first.
69 var after []applog.Record
70 for _, rec := range recs {
71 if cur.Before(rec) {
72 after = append(after, rec)
73 }
74 }
75 if len(after) > limit {
76 after = after[:limit]
77 resp.Truncated = true
78 }
79 resp.Records = after
80 } else {
81 // Tail: the last `limit` records of the window.
82 if len(recs) > limit {
83 recs = recs[len(recs)-limit:]
84 resp.Truncated = true
85 }
86 resp.Records = recs
87 }
88 if n := len(resp.Records); n > 0 {
89 resp.Cursor = applog.EncodeCursor(resp.Records[n-1])
90 } else if c := r.URL.Query().Get("cursor"); c != "" {
91 resp.Cursor = c // nothing new — the caller keeps polling from here
92 }
93 if resp.Records == nil {
94 resp.Records = []applog.Record{} // "no logs" is [], not null
95 }
96 writeJSON(w, http.StatusOK, resp)
97}
98
99// logsFilterFromQuery builds the applog.Filter (plus record limit) from the
100// request. The empty badParam means the params parsed; anything else is the
101// 400 text, naming the parameter in the member's own terms.
102func logsFilterFromQuery(r *http.Request, now time.Time) (f applog.Filter, limit int, badParam string) {
103 q := r.URL.Query()
104 var err error
105 if f.Since, err = applog.ParseTimeArg(q.Get("since"), now); err != nil {
106 return f, 0, "since: " + err.Error()
107 }
108 if f.Until, err = applog.ParseTimeArg(q.Get("until"), now); err != nil {
109 return f, 0, "until: " + err.Error()
110 }
111 if f.Since.IsZero() && q.Get("cursor") == "" {
112 f.Since = now.Add(-logsDefaultWindow)
113 }
114 if v := q.Get("stream"); v != "" {
115 if !applog.ValidStream(v) {
116 return f, 0, "stream: want app, platform, edge or release"
117 }
118 f.Stream = v
119 }
120 f.Host = q.Get("host")
121 if v := q.Get("level"); v != "" {
122 if !applog.ValidLevel(v) {
123 return f, 0, "level: want debug, info, warn or error"
124 }
125 f.Level = v
126 }
127 if v := q.Get("grep"); v != "" {
128 re, err := regexp.Compile(v)
129 if err != nil {
130 return f, 0, "grep: " + err.Error()
131 }
132 f.Grep = re
133 }
134 for _, kv := range q["field"] {
135 k, v, err := applog.ParseFieldArg(kv)
136 if err != nil {
137 return f, 0, err.Error()
138 }
139 if f.Fields == nil {
140 f.Fields = map[string]string{}
141 }
142 f.Fields[k] = v
143 }
144 limit = logsDefaultLimit
145 if v := q.Get("limit"); v != "" {
146 n, err := strconv.Atoi(v)
147 if err != nil || n < 1 {
148 return f, 0, "limit: want a positive integer"
149 }
150 limit = min(n, logsMaxLimit)
151 }
152 return f, limit, ""
153}
154