| 1 | package 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 | |
| 8 | import ( |
| 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. |
| 24 | type 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. |
| 36 | type errView int |
| 37 | |
| 38 | const ( |
| 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. |
| 47 | const 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). |
| 53 | func (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. |
| 59 | func (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 | |
| 63 | func (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. |
| 145 | var 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). |
| 151 | func 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. |
| 169 | func 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 | |
| 202 | var 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. |
| 207 | const 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. |
| 213 | const 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. |
| 217 | func (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). |
| 224 | func (s *Server) handleAppAvailability(w http.ResponseWriter, r *http.Request) { |
| 225 | s.serveErrorView(w, r, viewAvailability) |
| 226 | } |
| 227 | |
| 228 | func (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. |
| 296 | func (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. |
| 339 | func (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 | |