carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The Errors tab's read (spec 2026-09-07 §3.2, §3.6): errors/ records
4// plus log lines at or above the resolved level, derived on read and
5// never stored twice, grouped by fingerprint. The tab and the CLI's API
6// share this function — the API is not an afterthought.
7
8import (
9 "context"
10 "crypto/subtle"
11 "log"
12 "net/http"
13 "net/url"
14 "regexp"
15 "sort"
16 "time"
17
18 "github.com/carlosframework/platform/internal/apperr"
19 "github.com/carlosframework/platform/internal/applog"
20 "github.com/carlosframework/platform/internal/telemetry"
21)
22
23// errorsWindow is one assembled read.
24type errorsWindow struct {
25 Groups []apperr.Group
26 Records []apperr.Record // when f.FP is set: that group's records, oldest first
27 Truncated bool
28 LogLevel string // resolved setting
29 LogLevelFrom string
30 Sources []string // sources with at least one record in the window
31}
32
33// errView picks which half of the edge's and platform's failures a read
34// keeps (spec 2026-09-07 §3.2, amendment 2026-09-24): the app's own errors,
35// or Availability — whether an instance could answer at all.
36type errView int
37
38const (
39 viewErrors errView = iota
40 viewAvailability
41)
42
43// availabilityLogLevel is the platform-event threshold Availability reads
44// at, whatever the app's errors setting: an instance that exited on a
45// signal is filed at warn (backend_exec.go), and it is exactly the event
46// an outage row needs beside it.
47const availabilityLogLevel = "warn"
48
49// readErrors assembles the Errors window. level overrides the resolved
50// setting for THIS read only ("" = use the setting); it is what lets a
51// member on error widen one look to warn without changing anything (§8
52// ruling 3).
53func (s *Server) readErrors(ctx context.Context, sqid, app string, f apperr.Filter, level string) (errorsWindow, error) {
54 return s.readErrorView(ctx, sqid, app, f, level, viewErrors)
55}
56
57// readAvailability assembles the Availability window: edge 502/503/504
58// grouped by cause, and platform events about the instance's lifecycle.
59func (s *Server) readAvailability(ctx context.Context, sqid, app string, f apperr.Filter) (errorsWindow, error) {
60 return s.readErrorView(ctx, sqid, app, f, "", viewAvailability)
61}
62
63func (s *Server) readErrorView(ctx context.Context, sqid, app string, f apperr.Filter, level string, view errView) (errorsWindow, error) {
64 var out errorsWindow
65 res, err := telemetry.Resolve(ctx, s.cfg.Releases, sqid, app)
66 if err != nil {
67 return out, err
68 }
69 out.LogLevel, out.LogLevelFrom = res.LogLevel, res.LogLevelFrom
70 if level == "" {
71 level = res.LogLevel
72 }
73 // A record the edge wrote before the amendment is regrouped on read
74 // (apperr.AsAvailability), so its stored FP is not the one the tab
75 // shows: the FP filter applies after the regroup, never in the read.
76 // Every stored Availability record is the edge's, so the stored read
77 // takes only the edge's: server and browser errors must not spend the
78 // collect cap an expanded cause needs. (The edge's own 500s still pass
79 // the read and are dropped by keep below.)
80 rf, fp := f, f.FP
81 if view == viewAvailability {
82 rf.FP, rf.Source = "", apperr.SourceEdge
83 level = availabilityLogLevel
84 }
85 stored, trimmed, err := apperr.ReadWindow(ctx, s.cfg.Releases, sqid, app, rf, logsCollectCap)
86 if err != nil {
87 return out, err
88 }
89 out.Truncated = trimmed
90 keep := func(r apperr.Record) bool { return apperr.IsAvailability(r) == (view == viewAvailability) }
91 var kept []apperr.Record
92 for _, r := range stored {
93 if !keep(r) {
94 continue
95 }
96 if view == viewAvailability {
97 if r = apperr.AsAvailability(r); fp != "" && r.FP != fp {
98 continue
99 }
100 }
101 kept = append(kept, r)
102 }
103 var derived []apperr.Record
104 if f.Source == "" || f.Source == apperr.SourceLog {
105 lf := applog.Filter{Since: f.Since, Until: f.Until, Host: f.Host, Level: level}
106 if view == viewAvailability {
107 lf.Stream = applog.StreamPlatform
108 }
109 lines, ltrim, err := applog.ReadWindow(ctx, s.cfg.Releases, sqid, app, lf, logsCollectCap)
110 if err != nil {
111 return out, err
112 }
113 out.Truncated = out.Truncated || ltrim
114 for _, l := range lines {
115 if applog.IsDropNotice(l) {
116 continue
117 }
118 r := apperr.FromLog(l)
119 if keep(r) && (fp == "" || r.FP == fp) && (f.Release == "" || r.Release == f.Release) {
120 derived = append(derived, r)
121 }
122 }
123 }
124 all := apperr.MergeRecords(kept, derived)
125 if fp != "" {
126 out.Records = all
127 }
128 out.Groups = apperr.GroupRecords(all)
129 seen := map[string]bool{}
130 for _, r := range all {
131 if !seen[r.Source] {
132 seen[r.Source] = true
133 out.Sources = append(out.Sources, r.Source)
134 }
135 }
136 sortSources(out.Sources)
137 return out, nil
138}
139
140// sourceOrder is the vocabulary's own order (apperr's Source* constants):
141// log first, because it is the source every app has without doing
142// anything, then the ones a deployment adds. Sorting the names
143// alphabetically would put "browser" — the source that needs a hook
144// nobody has installed yet — at the head of the list.
145var sourceOrder = map[string]int{
146 apperr.SourceLog: 0, apperr.SourceEdge: 1, apperr.SourceServer: 2, apperr.SourceBrowser: 3,
147}
148
149// sortSources orders in place, alphabetically among any names the
150// vocabulary does not know (a record from a newer shipper).
151func sortSources(ss []string) {
152 rank := func(s string) int {
153 if n, ok := sourceOrder[s]; ok {
154 return n
155 }
156 return len(sourceOrder)
157 }
158 sort.SliceStable(ss, func(i, j int) bool {
159 if a, b := rank(ss[i]), rank(ss[j]); a != b {
160 return a < b
161 }
162 return ss[i] < ss[j]
163 })
164}
165
166// errorsFilterFromQuery reads the tab's and the API's shared params:
167// since/until/source/host/release/fp/level. The empty badParam means they
168// parsed.
169func errorsFilterFromQuery(q map[string][]string, now time.Time) (f apperr.Filter, level string, badParam string) {
170 get := func(k string) string {
171 if v := q[k]; len(v) > 0 {
172 return v[0]
173 }
174 return ""
175 }
176 var err error
177 if f.Since, err = applog.ParseTimeArg(get("since"), now); err != nil {
178 return f, "", "since: " + err.Error()
179 }
180 if f.Until, err = applog.ParseTimeArg(get("until"), now); err != nil {
181 return f, "", "until: " + err.Error()
182 }
183 if f.Since.IsZero() {
184 f.Since = now.Add(-24 * time.Hour)
185 }
186 switch v := get("source"); v {
187 case "", apperr.SourceLog, apperr.SourceEdge, apperr.SourceServer, apperr.SourceBrowser:
188 f.Source = v
189 default:
190 return f, "", "source: want log, edge, server or browser"
191 }
192 f.Host, f.Release, f.FP = get("host"), get("release"), get("fp")
193 if !validFP.MatchString(f.FP) {
194 return f, "", "fp: want a fingerprint id"
195 }
196 if level = get("level"); level != "" && !telemetry.ValidLogLevel(level) {
197 return f, "", "level: want error or warn"
198 }
199 return f, level, ""
200}
201
202var validFP = regexp.MustCompile(`^[A-Za-z0-9_.-]{0,64}$`)
203
204// errorsDefaultSince is the tab's window when the caller named none: a day,
205// where the Logs tab's is an hour. An error is rarer than a log line and
206// worth looking back further for.
207const errorsDefaultSince = "24h"
208
209// accountAuditSubject is the app column an ACCOUNT-level setting change is
210// filed under. An empty column reads as "no subject" in the activity feed,
211// which is not what an account-wide decision is; this follows
212// serviceAuditKey's convention of naming the subject instead.
213const accountAuditSubject = "account"
214
215// handleAppErrors renders the Errors tab: the same read the API answers,
216// with the filters as GET params so a view is a URL.
217func (s *Server) handleAppErrors(w http.ResponseWriter, r *http.Request) {
218 s.serveErrorView(w, r, viewErrors)
219}
220
221// handleAppAvailability renders the Availability tab: the Errors tab's
222// read and page shape over the other half of the records — no source or
223// level to choose, since what it holds is fixed (errView).
224func (s *Server) handleAppAvailability(w http.ResponseWriter, r *http.Request) {
225 s.serveErrorView(w, r, viewAvailability)
226}
227
228func (s *Server) serveErrorView(w http.ResponseWriter, r *http.Request, view errView) {
229 tab, tmpl := "errors", "app_errors"
230 if view == viewAvailability {
231 tab, tmpl = "availability", "app_availability"
232 }
233 p, acct, ok := s.appPage(w, r, tab)
234 if !ok {
235 return
236 }
237 now := time.Now()
238 q := r.URL.Query()
239 // The select offers three windows, but any duration the CLI accepts is
240 // honoured — a hand-written URL is a legitimate way to ask (the Logs tab
241 // reads its since the same way). Only an unparseable one falls back.
242 p.ErrSince = q.Get("since")
243 if _, err := applog.ParseTimeArg(p.ErrSince, now); err != nil || p.ErrSince == "" {
244 p.ErrSince = errorsDefaultSince
245 }
246 q.Set("since", p.ErrSince)
247 f, level, bad := errorsFilterFromQuery(q, now)
248 if bad != "" {
249 // A page never 400s over its own query string: the bad parameter is
250 // dropped and the default window rendered, which is what a member
251 // who mistyped a URL wants to see.
252 f, level, _ = errorsFilterFromQuery(map[string][]string{"since": {p.ErrSince}}, now)
253 }
254 if view == viewAvailability {
255 f.Source, level = "", ""
256 }
257 p.ErrSource, p.ErrLevel, p.ErrHost, p.ErrFP = f.Source, level, f.Host, f.FP
258 if s.cfg.Releases != nil {
259 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
260 defer cancel()
261 win, err := s.readErrorView(ctx, acct.Sqid, p.AppName, f, level, view)
262 if err != nil {
263 log.Printf("console: app %s %s/%s: %v", tab, acct.Sqid, p.AppName, err)
264 p.ErrsErr = "The errors could not be read just now — try again in a moment."
265 if view == viewAvailability {
266 p.ErrsErr = "Availability could not be read just now — try again in a moment."
267 }
268 // A failed read leaves no resolved setting, and the filter's
269 // "setting (…)" option must still name a level rather than an
270 // empty pair of brackets.
271 win.LogLevel, win.LogLevelFrom = telemetry.LogLevelError, "default"
272 }
273 // The page shows a page's worth. Groups come newest first, so the
274 // head of that list is the one to keep; occurrences come oldest
275 // first, so it is the tail. Either trim is the same truncation the
276 // window itself reports.
277 if len(win.Groups) > appErrorsPageCap {
278 win.Groups = win.Groups[:appErrorsPageCap]
279 win.Truncated = true
280 }
281 if len(win.Records) > appErrorsPageCap {
282 win.Records = win.Records[len(win.Records)-appErrorsPageCap:]
283 win.Truncated = true
284 }
285 p.ErrWin = win
286 } else {
287 // No deployment bucket on this host: there is nothing to read and
288 // nothing to resolve, so the tab says the default rather than
289 // rendering "the setting ()" over an empty window.
290 p.ErrWin.LogLevel, p.ErrWin.LogLevelFrom = telemetry.LogLevelError, "default"
291 }
292 s.render(w, http.StatusOK, tmpl, p)
293}
294
295// handleAppErrorLevel is the Settings tab's form: owner, CSRF, one key.
296func (s *Server) handleAppErrorLevel(w http.ResponseWriter, r *http.Request) {
297 addr, raw, acct, role, app, ok := s.accountApp(w, r)
298 if !ok {
299 return
300 }
301 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
302 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
303 return
304 }
305 if role != RoleOwner {
306 http.NotFound(w, r)
307 return
308 }
309 level := r.FormValue("level")
310 back := appSettingsURL(acct.Sqid, app, "general")
311 // The setting lives in the deployment bucket, so a console without one
312 // has nowhere to put it. Refused before the write rather than panicking
313 // inside it, the way the rename door refuses its own bucket check.
314 if s.cfg.Releases == nil {
315 s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.")
316 http.Redirect(w, r, back, http.StatusSeeOther)
317 return
318 }
319 if !telemetry.ValidLogLevel(level) {
320 s.setFlash(w, "err", "Choose error or warn.")
321 http.Redirect(w, r, back, http.StatusSeeOther)
322 return
323 }
324 if err := s.updateAppTelemetry(r.Context(), acct, app, "errors-level", func(st *telemetry.Settings) {
325 st.Errors = &telemetry.Errors{LogLevel: level}
326 }); err != nil {
327 log.Printf("console: errors level %s/%s: %v", acct.Sqid, app, err)
328 s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
329 http.Redirect(w, r, back, http.StatusSeeOther)
330 return
331 }
332 s.audit(acct, app, addr, "errors-level", "set the Errors log level to "+level)
333 s.setFlash(w, "ok", "Errors now include "+level+" lines and above for this app.")
334 http.Redirect(w, r, back, http.StatusSeeOther)
335}
336
337// handleAccountErrorLevel is the account settings form: the default every
338// app without its own setting inherits.
339func (s *Server) handleAccountErrorLevel(w http.ResponseWriter, r *http.Request) {
340 addr, raw, acct, role, ok := s.account(w, r)
341 if !ok {
342 return
343 }
344 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
345 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
346 return
347 }
348 if role != RoleOwner {
349 http.NotFound(w, r)
350 return
351 }
352 level := r.FormValue("level")
353 back := "/a/" + url.PathEscape(acct.Sqid) + "/settings"
354 if s.cfg.Releases == nil {
355 s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.")
356 http.Redirect(w, r, back, http.StatusSeeOther)
357 return
358 }
359 if !telemetry.ValidLogLevel(level) {
360 s.setFlash(w, "err", "Choose error or warn.")
361 http.Redirect(w, r, back, http.StatusSeeOther)
362 return
363 }
364 if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AccountKey(acct.Sqid), func(st *telemetry.Settings) {
365 st.Errors = &telemetry.Errors{LogLevel: level}
366 }); err != nil {
367 log.Printf("console: account errors level %s: %v", acct.Sqid, err)
368 s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
369 http.Redirect(w, r, back, http.StatusSeeOther)
370 return
371 }
372 // Filed under a stable SUBJECT rather than an empty app column, the way
373 // serviceAuditKey files an account-scoped credential under its own name
374 // (apiservices.go): the subject of this row is the account itself, and
375 // "account" is what the activity feed shows for it. An app's own level
376 // change is filed under that app, above.
377 s.audit(acct, accountAuditSubject, addr, "errors-level", "set the account's Errors log level to "+level)
378 s.setFlash(w, "ok", "Errors now include "+level+" lines and above for every app without its own setting.")
379 http.Redirect(w, r, back, http.StatusSeeOther)
380}
381