package console // The Errors tab's read (spec 2026-09-07 §3.2, §3.6): errors/ records // plus log lines at or above the resolved level, derived on read and // never stored twice, grouped by fingerprint. The tab and the CLI's API // share this function — the API is not an afterthought. import ( "context" "crypto/subtle" "log" "net/http" "net/url" "regexp" "sort" "time" "github.com/carlosframework/platform/internal/apperr" "github.com/carlosframework/platform/internal/applog" "github.com/carlosframework/platform/internal/telemetry" ) // errorsWindow is one assembled read. type errorsWindow struct { Groups []apperr.Group Records []apperr.Record // when f.FP is set: that group's records, oldest first Truncated bool LogLevel string // resolved setting LogLevelFrom string Sources []string // sources with at least one record in the window } // errView picks which half of the edge's and platform's failures a read // keeps (spec 2026-09-07 §3.2, amendment 2026-09-24): the app's own errors, // or Availability — whether an instance could answer at all. type errView int const ( viewErrors errView = iota viewAvailability ) // availabilityLogLevel is the platform-event threshold Availability reads // at, whatever the app's errors setting: an instance that exited on a // signal is filed at warn (backend_exec.go), and it is exactly the event // an outage row needs beside it. const availabilityLogLevel = "warn" // readErrors assembles the Errors window. level overrides the resolved // setting for THIS read only ("" = use the setting); it is what lets a // member on error widen one look to warn without changing anything (§8 // ruling 3). func (s *Server) readErrors(ctx context.Context, sqid, app string, f apperr.Filter, level string) (errorsWindow, error) { return s.readErrorView(ctx, sqid, app, f, level, viewErrors) } // readAvailability assembles the Availability window: edge 502/503/504 // grouped by cause, and platform events about the instance's lifecycle. func (s *Server) readAvailability(ctx context.Context, sqid, app string, f apperr.Filter) (errorsWindow, error) { return s.readErrorView(ctx, sqid, app, f, "", viewAvailability) } func (s *Server) readErrorView(ctx context.Context, sqid, app string, f apperr.Filter, level string, view errView) (errorsWindow, error) { var out errorsWindow res, err := telemetry.Resolve(ctx, s.cfg.Releases, sqid, app) if err != nil { return out, err } out.LogLevel, out.LogLevelFrom = res.LogLevel, res.LogLevelFrom if level == "" { level = res.LogLevel } // A record the edge wrote before the amendment is regrouped on read // (apperr.AsAvailability), so its stored FP is not the one the tab // shows: the FP filter applies after the regroup, never in the read. // Every stored Availability record is the edge's, so the stored read // takes only the edge's: server and browser errors must not spend the // collect cap an expanded cause needs. (The edge's own 500s still pass // the read and are dropped by keep below.) rf, fp := f, f.FP if view == viewAvailability { rf.FP, rf.Source = "", apperr.SourceEdge level = availabilityLogLevel } stored, trimmed, err := apperr.ReadWindow(ctx, s.cfg.Releases, sqid, app, rf, logsCollectCap) if err != nil { return out, err } out.Truncated = trimmed keep := func(r apperr.Record) bool { return apperr.IsAvailability(r) == (view == viewAvailability) } var kept []apperr.Record for _, r := range stored { if !keep(r) { continue } if view == viewAvailability { if r = apperr.AsAvailability(r); fp != "" && r.FP != fp { continue } } kept = append(kept, r) } var derived []apperr.Record if f.Source == "" || f.Source == apperr.SourceLog { lf := applog.Filter{Since: f.Since, Until: f.Until, Host: f.Host, Level: level} if view == viewAvailability { lf.Stream = applog.StreamPlatform } lines, ltrim, err := applog.ReadWindow(ctx, s.cfg.Releases, sqid, app, lf, logsCollectCap) if err != nil { return out, err } out.Truncated = out.Truncated || ltrim for _, l := range lines { if applog.IsDropNotice(l) { continue } r := apperr.FromLog(l) if keep(r) && (fp == "" || r.FP == fp) && (f.Release == "" || r.Release == f.Release) { derived = append(derived, r) } } } all := apperr.MergeRecords(kept, derived) if fp != "" { out.Records = all } out.Groups = apperr.GroupRecords(all) seen := map[string]bool{} for _, r := range all { if !seen[r.Source] { seen[r.Source] = true out.Sources = append(out.Sources, r.Source) } } sortSources(out.Sources) return out, nil } // sourceOrder is the vocabulary's own order (apperr's Source* constants): // log first, because it is the source every app has without doing // anything, then the ones a deployment adds. Sorting the names // alphabetically would put "browser" — the source that needs a hook // nobody has installed yet — at the head of the list. var sourceOrder = map[string]int{ apperr.SourceLog: 0, apperr.SourceEdge: 1, apperr.SourceServer: 2, apperr.SourceBrowser: 3, } // sortSources orders in place, alphabetically among any names the // vocabulary does not know (a record from a newer shipper). func sortSources(ss []string) { rank := func(s string) int { if n, ok := sourceOrder[s]; ok { return n } return len(sourceOrder) } sort.SliceStable(ss, func(i, j int) bool { if a, b := rank(ss[i]), rank(ss[j]); a != b { return a < b } return ss[i] < ss[j] }) } // errorsFilterFromQuery reads the tab's and the API's shared params: // since/until/source/host/release/fp/level. The empty badParam means they // parsed. func errorsFilterFromQuery(q map[string][]string, now time.Time) (f apperr.Filter, level string, badParam string) { get := func(k string) string { if v := q[k]; len(v) > 0 { return v[0] } return "" } var err error if f.Since, err = applog.ParseTimeArg(get("since"), now); err != nil { return f, "", "since: " + err.Error() } if f.Until, err = applog.ParseTimeArg(get("until"), now); err != nil { return f, "", "until: " + err.Error() } if f.Since.IsZero() { f.Since = now.Add(-24 * time.Hour) } switch v := get("source"); v { case "", apperr.SourceLog, apperr.SourceEdge, apperr.SourceServer, apperr.SourceBrowser: f.Source = v default: return f, "", "source: want log, edge, server or browser" } f.Host, f.Release, f.FP = get("host"), get("release"), get("fp") if !validFP.MatchString(f.FP) { return f, "", "fp: want a fingerprint id" } if level = get("level"); level != "" && !telemetry.ValidLogLevel(level) { return f, "", "level: want error or warn" } return f, level, "" } var validFP = regexp.MustCompile(`^[A-Za-z0-9_.-]{0,64}$`) // errorsDefaultSince is the tab's window when the caller named none: a day, // where the Logs tab's is an hour. An error is rarer than a log line and // worth looking back further for. const errorsDefaultSince = "24h" // accountAuditSubject is the app column an ACCOUNT-level setting change is // filed under. An empty column reads as "no subject" in the activity feed, // which is not what an account-wide decision is; this follows // serviceAuditKey's convention of naming the subject instead. const accountAuditSubject = "account" // handleAppErrors renders the Errors tab: the same read the API answers, // with the filters as GET params so a view is a URL. func (s *Server) handleAppErrors(w http.ResponseWriter, r *http.Request) { s.serveErrorView(w, r, viewErrors) } // handleAppAvailability renders the Availability tab: the Errors tab's // read and page shape over the other half of the records — no source or // level to choose, since what it holds is fixed (errView). func (s *Server) handleAppAvailability(w http.ResponseWriter, r *http.Request) { s.serveErrorView(w, r, viewAvailability) } func (s *Server) serveErrorView(w http.ResponseWriter, r *http.Request, view errView) { tab, tmpl := "errors", "app_errors" if view == viewAvailability { tab, tmpl = "availability", "app_availability" } p, acct, ok := s.appPage(w, r, tab) if !ok { return } now := time.Now() q := r.URL.Query() // The select offers three windows, but any duration the CLI accepts is // honoured — a hand-written URL is a legitimate way to ask (the Logs tab // reads its since the same way). Only an unparseable one falls back. p.ErrSince = q.Get("since") if _, err := applog.ParseTimeArg(p.ErrSince, now); err != nil || p.ErrSince == "" { p.ErrSince = errorsDefaultSince } q.Set("since", p.ErrSince) f, level, bad := errorsFilterFromQuery(q, now) if bad != "" { // A page never 400s over its own query string: the bad parameter is // dropped and the default window rendered, which is what a member // who mistyped a URL wants to see. f, level, _ = errorsFilterFromQuery(map[string][]string{"since": {p.ErrSince}}, now) } if view == viewAvailability { f.Source, level = "", "" } p.ErrSource, p.ErrLevel, p.ErrHost, p.ErrFP = f.Source, level, f.Host, f.FP if s.cfg.Releases != nil { ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() win, err := s.readErrorView(ctx, acct.Sqid, p.AppName, f, level, view) if err != nil { log.Printf("console: app %s %s/%s: %v", tab, acct.Sqid, p.AppName, err) p.ErrsErr = "The errors could not be read just now — try again in a moment." if view == viewAvailability { p.ErrsErr = "Availability could not be read just now — try again in a moment." } // A failed read leaves no resolved setting, and the filter's // "setting (…)" option must still name a level rather than an // empty pair of brackets. win.LogLevel, win.LogLevelFrom = telemetry.LogLevelError, "default" } // The page shows a page's worth. Groups come newest first, so the // head of that list is the one to keep; occurrences come oldest // first, so it is the tail. Either trim is the same truncation the // window itself reports. if len(win.Groups) > appErrorsPageCap { win.Groups = win.Groups[:appErrorsPageCap] win.Truncated = true } if len(win.Records) > appErrorsPageCap { win.Records = win.Records[len(win.Records)-appErrorsPageCap:] win.Truncated = true } p.ErrWin = win } else { // No deployment bucket on this host: there is nothing to read and // nothing to resolve, so the tab says the default rather than // rendering "the setting ()" over an empty window. p.ErrWin.LogLevel, p.ErrWin.LogLevelFrom = telemetry.LogLevelError, "default" } s.render(w, http.StatusOK, tmpl, p) } // handleAppErrorLevel is the Settings tab's form: owner, CSRF, one key. func (s *Server) handleAppErrorLevel(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } level := r.FormValue("level") back := appSettingsURL(acct.Sqid, app, "general") // The setting lives in the deployment bucket, so a console without one // has nowhere to put it. Refused before the write rather than panicking // inside it, the way the rename door refuses its own bucket check. if s.cfg.Releases == nil { s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.") http.Redirect(w, r, back, http.StatusSeeOther) return } if !telemetry.ValidLogLevel(level) { s.setFlash(w, "err", "Choose error or warn.") http.Redirect(w, r, back, http.StatusSeeOther) return } if err := s.updateAppTelemetry(r.Context(), acct, app, "errors-level", func(st *telemetry.Settings) { st.Errors = &telemetry.Errors{LogLevel: level} }); err != nil { log.Printf("console: errors level %s/%s: %v", acct.Sqid, app, err) s.setFlash(w, "err", "The setting could not be saved — try again in a moment.") http.Redirect(w, r, back, http.StatusSeeOther) return } s.audit(acct, app, addr, "errors-level", "set the Errors log level to "+level) s.setFlash(w, "ok", "Errors now include "+level+" lines and above for this app.") http.Redirect(w, r, back, http.StatusSeeOther) } // handleAccountErrorLevel is the account settings form: the default every // app without its own setting inherits. func (s *Server) handleAccountErrorLevel(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, ok := s.account(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } level := r.FormValue("level") back := "/a/" + url.PathEscape(acct.Sqid) + "/settings" if s.cfg.Releases == nil { s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.") http.Redirect(w, r, back, http.StatusSeeOther) return } if !telemetry.ValidLogLevel(level) { s.setFlash(w, "err", "Choose error or warn.") http.Redirect(w, r, back, http.StatusSeeOther) return } if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AccountKey(acct.Sqid), func(st *telemetry.Settings) { st.Errors = &telemetry.Errors{LogLevel: level} }); err != nil { log.Printf("console: account errors level %s: %v", acct.Sqid, err) s.setFlash(w, "err", "The setting could not be saved — try again in a moment.") http.Redirect(w, r, back, http.StatusSeeOther) return } // Filed under a stable SUBJECT rather than an empty app column, the way // serviceAuditKey files an account-scoped credential under its own name // (apiservices.go): the subject of this row is the account itself, and // "account" is what the activity feed shows for it. An app's own level // change is filed under that app, above. s.audit(acct, accountAuditSubject, addr, "errors-level", "set the account's Errors log level to "+level) s.setFlash(w, "ok", "Errors now include "+level+" lines and above for every app without its own setting.") http.Redirect(w, r, back, http.StatusSeeOther) }