carlos / platform Public

console: Errors tab, errors API, and the log-level setting at both levels (spec §3.6)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8479b65e83b83fd3779c246e3530344225fd3065 parent 742404c
10 files changed, +687 −1
  • internal/console/apierrors.go +91 −0
  • internal/console/apierrors_test.go +150 −0
  • internal/console/authcli.go +10 −0
  • internal/console/errorsview.go +274 −0
  • internal/console/server.go +36 −0
  • internal/console/svcroutes_test.go +11 −1
  • internal/console/templates/account_settings.html +16 −0
  • internal/console/templates/app_errors.html +77 −0
  • internal/console/templates/app_settings.html +21 −0
  • internal/console/templates/appnav.html +1 −0
diff --git a/internal/console/apierrors.go b/internal/console/apierrors.go
new file mode 100644
index 000000000..a5d6c40b4
--- /dev/null
+++ b/internal/console/apierrors.go
@@ -0,0 +1,91 @@
+// The CLI's errors API — the read side of `carlos errors` and the write
+// side of the telemetry settings object (spec 2026-09-07 §3.6). It shares
+// the instance family's gate (instanceGate) exactly as the logs API does:
+// same principal proof, same byte-identical 404, same rate limiter — and an
+// app token may read its own app's errors, which is the trust the logs door
+// already extends it.
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/apperr"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+// errorsResponse is the wire shape `carlos errors` and the tab read.
+type errorsResponse struct {
+ Groups []apperr.Group `json:"groups"`
+ Records []apperr.Record `json:"records,omitempty"`
+ Truncated bool `json:"truncated"`
+ LogLevel string `json:"log_level"`
+ Sources []string `json:"sources"`
+}
+
+// handleAPIErrors answers `carlos errors`: the instance family's gate
+// (an app token may read its own errors), the shared filter, one read.
+func (s *Server) handleAPIErrors(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ f, level, bad := errorsFilterFromQuery(r.URL.Query(), time.Now())
+ if bad != "" {
+ jsonError(w, http.StatusBadRequest, bad)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ win, err := s.readErrors(ctx, acct.Sqid, app, f, level)
+ if err != nil {
+ log.Printf("console: api errors %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the errors")
+ return
+ }
+ resp := errorsResponse{Groups: win.Groups, Records: win.Records, Truncated: win.Truncated, LogLevel: win.LogLevel, Sources: win.Sources}
+ if resp.Groups == nil {
+ resp.Groups = []apperr.Group{} // "no errors" is [], not null
+ }
+ if resp.Sources == nil {
+ resp.Sources = []string{}
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// handleAPITelemetryPut sets the app-level telemetry object: today the
+// errors log level; the analytics keys arrive with step 2. Operate only —
+// an app's own token must not widen what its owner sees.
+func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var body telemetry.Settings
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
+ jsonError(w, http.StatusBadRequest, "body: want {\"errors\":{\"log_level\":\"error|warn\"}}")
+ return
+ }
+ if body.Errors == nil || !telemetry.ValidLogLevel(body.Errors.LogLevel) {
+ jsonError(w, http.StatusBadRequest, "errors.log_level: want error or warn")
+ return
+ }
+ if body.Analytics != nil {
+ jsonError(w, http.StatusBadRequest, "analytics: not settable yet")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ if err := telemetry.Update(ctx, s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), func(st *telemetry.Settings) {
+ st.Errors = &telemetry.Errors{LogLevel: body.Errors.LogLevel}
+ }); err != nil {
+ log.Printf("console: api telemetry %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not write the setting")
+ return
+ }
+ s.audit(acct, app, principal, "errors-level", "set the Errors log level to "+body.Errors.LogLevel)
+ writeJSON(w, http.StatusOK, map[string]string{"log_level": body.Errors.LogLevel})
+}
diff --git a/internal/console/apierrors_test.go b/internal/console/apierrors_test.go
new file mode 100644
index 000000000..ccfa41a32
--- /dev/null
+++ b/internal/console/apierrors_test.go
@@ -0,0 +1,150 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/apperr"
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+func errorsURL(fx *consoleFixture, acct, app, query string) string {
+ u := fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/errors"
+ if query != "" {
+ u += "?" + query
+ }
+ return u
+}
+
+// seedErrors ships one coalesced edge error and, via seedLogChunks, one
+// error-level platform line and one warn-level app line.
+func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) {
+ t.Helper()
+ seedLogChunks(t, fx, now)
+ warn := []applog.Record{{TS: now.Add(-7 * time.Minute), Box: "box1", Host: "titogo.example.com",
+ Stream: applog.StreamApp, Level: applog.LevelWarn, Msg: "slow query 4123ms", Seq: 150}}
+ body, _ := applog.EncodeChunk(warn)
+ if _, err := fx.rel.Put(context.Background(), applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(warn[0].TS), 150, len(body)), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ e := []apperr.Record{{TS: now.Add(-3 * time.Minute), Box: "box1", Host: "titogo.example.com",
+ Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 POST /checkout", Path: "/checkout", Status: 500,
+ Release: "v3", FP: "edge500", FPV: 1, Count: 12, First: now.Add(-9 * time.Minute), Last: now.Add(-3 * time.Minute), Seq: 200}}
+ eb, _ := apperr.EncodeChunk(e)
+ if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(e[0].TS), 200, len(eb)), eb, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestAPIErrorsGroupsBothFamilies(t *testing.T) {
+ fx, token := instanceFixture(t, "code-errors-1")
+ now := time.Now().UTC()
+ seedErrors(t, fx, now)
+ get := func(query string) errorsResponse {
+ t.Helper()
+ status, _, body := rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", query), token, "")
+ if status != http.StatusOK {
+ t.Fatalf("GET ?%s = %d: %s", query, status, body)
+ }
+ var resp errorsResponse
+ if err := json.Unmarshal([]byte(body), &resp); err != nil {
+ t.Fatal(err)
+ }
+ return resp
+ }
+ resp := get("")
+ if len(resp.Groups) != 2 {
+ t.Fatalf("groups = %+v", resp.Groups)
+ }
+ if resp.Groups[0].FP != "edge500" || resp.Groups[0].Count != 12 || resp.Groups[0].Releases[0] != "v3" {
+ t.Fatalf("newest first, coalesced count: %+v", resp.Groups[0])
+ }
+ if resp.Groups[1].Source != apperr.SourceLog || !strings.Contains(resp.Groups[1].Name, "wake failed") {
+ t.Fatalf("log-derived group: %+v", resp.Groups[1])
+ }
+ if resp.LogLevel != "error" || len(resp.Sources) != 2 {
+ t.Fatalf("meta = %q %v", resp.LogLevel, resp.Sources)
+ }
+ // A warn read widens without changing the setting.
+ if resp = get("level=warn"); len(resp.Groups) != 3 {
+ t.Fatalf("level=warn groups = %d", len(resp.Groups))
+ }
+ // Source and fp filters.
+ if resp = get("source=edge"); len(resp.Groups) != 1 || resp.Groups[0].FP != "edge500" {
+ t.Fatalf("source filter = %+v", resp.Groups)
+ }
+ if resp = get("fp=edge500"); len(resp.Records) != 1 || resp.Records[0].Status != 500 {
+ t.Fatalf("fp expand = %+v", resp.Records)
+ }
+}
+
+func TestAPITelemetryPutSetsLogLevel(t *testing.T) {
+ fx, token := instanceFixture(t, "code-errors-2")
+ now := time.Now().UTC()
+ seedErrors(t, fx, now)
+ // The instance token may read but not write settings: Operate only.
+ inst := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance)
+ status, _, body := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", inst, `{"errors":{"log_level":"warn"}}`)
+ if status != http.StatusForbidden && status != http.StatusNotFound {
+ t.Fatalf("instance token PUT = %d %s", status, body)
+ }
+ op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
+ status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, `{"errors":{"log_level":"warn"}}`)
+ if status != http.StatusOK {
+ t.Fatalf("PUT = %d %s", status, body)
+ }
+ r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
+ if r.LogLevel != "warn" || r.LogLevelFrom != "app" {
+ t.Fatalf("resolved = %+v", r)
+ }
+ status, _, body = rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", ""), token, "")
+ var resp errorsResponse
+ json.Unmarshal([]byte(body), &resp)
+ if status != 200 || resp.LogLevel != "warn" || len(resp.Groups) != 3 {
+ t.Fatalf("after set: %d level=%q groups=%d", status, resp.LogLevel, len(resp.Groups))
+ }
+ status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, `{"errors":{"log_level":"loud"}}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("bad level = %d %s", status, body)
+ }
+}
+
+func TestAppErrorsPage(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-errors-page")
+ now := time.Now().UTC()
+ seedErrors(t, fx, now)
+ fx.setUser("paul@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-errors-signin")
+ status, _, body := get(t, c, fx.u("/apps/titogo/errors"))
+ if status != http.StatusOK {
+ t.Fatalf("errors page: %d %s", status, body)
+ }
+ for _, want := range []string{"500 POST /checkout", "wake failed", "carlos errors --app titogo", "Sources live: log, edge", "to the nearest flush"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("page missing %q", want)
+ }
+ }
+ if strings.Contains(body, "slow query") {
+ t.Error("warn lines must not show at the default level")
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/errors?level=warn"))
+ if status != 200 || !strings.Contains(body, "slow query") {
+ t.Fatalf("level=warn page: %d", status)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/errors?fp=edge500"))
+ if status != 200 || !strings.Contains(body, "/checkout") || !strings.Contains(body, "v3") {
+ t.Fatalf("expanded group: %d", status)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/errors?since=1ns"))
+ if status != 200 || !strings.Contains(body, "No errors in this window") {
+ t.Fatalf("empty window: %d", status)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index a64d3d01f..88e10b135 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -593,6 +593,16 @@ func (s *Server) registerCLIRoutes() {
// The logs read (`carlos logs`, apilogs.go) — same gate, same 404
// discipline, and an app token may read its own app's logs.
s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs)
+ // The errors read (`carlos errors`, apierrors.go) — the logs door's twin,
+ // over the same window from the other end: the same gate, the same roles,
+ // and an app token may read its own app's errors for the same reason it
+ // may read its own logs.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/errors", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIErrors)
+ // The telemetry SETTINGS write, Operate only and deliberately narrower
+ // than the read beside it: this decides what counts as an error for the
+ // app's owner, and a credential the app itself holds must not be able to
+ // widen or narrow what its owner is shown.
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/telemetry", svcrole.Allow(svcrole.Operate), s.handleAPITelemetryPut)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceCreate)
s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceGet)
// PATCH is the multi-upstream repoint (issue #152): the two fields of a
diff --git a/internal/console/errorsview.go b/internal/console/errorsview.go
new file mode 100644
index 000000000..e9dac20f7
--- /dev/null
+++ b/internal/console/errorsview.go
@@ -0,0 +1,274 @@
+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
+}
+
+// readErrors assembles the 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) {
+ 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
+ }
+ stored, trimmed, err := apperr.ReadWindow(ctx, s.cfg.Releases, sqid, app, f, logsCollectCap)
+ if err != nil {
+ return out, err
+ }
+ out.Truncated = trimmed
+ 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}
+ 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 {
+ r := apperr.FromLog(l)
+ if (f.FP == "" || r.FP == f.FP) && (f.Release == "" || r.Release == f.Release) {
+ derived = append(derived, r)
+ }
+ }
+ }
+ all := apperr.MergeRecords(stored, derived)
+ if f.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"
+
+// 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) {
+ p, acct, ok := s.appPage(w, r, "errors")
+ 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)
+ }
+ 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.readErrors(ctx, acct.Sqid, p.AppName, f, level)
+ if err != nil {
+ log.Printf("console: app errors %s/%s: %v", acct.Sqid, p.AppName, err)
+ p.ErrsErr = "The errors could not be read just now — try again in a moment."
+ }
+ 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, "app_errors", 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 := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ // 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 := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), 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
+ }
+ s.audit(acct, "", 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)
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index c5ce9c38f..fd911b6f9 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -43,6 +43,7 @@ import (
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
"github.com/carlosframework/platform/internal/svcrole"
+ "github.com/carlosframework/platform/internal/telemetry"
"github.com/carlosframework/platform/internal/usage"
"github.com/carlosframework/platform/internal/version"
)
@@ -1037,6 +1038,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/fleet/byo", s.handleFleetBYO)
s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)
s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings)
+ s.mux.HandleFunc("POST /a/{sqid}/errors/level", s.handleAccountErrorLevel)
// Go 1.22's mux prefers this literal over apps/{app} below — exactly why
// "new" must be a reserved app name (claimApp): an app so named could
// never be viewed.
@@ -1046,6 +1048,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
+ // The Errors tab and its two setting doors (errorsview.go): the tab is
+ // member-readable like every other app tab; the level is the owner's,
+ // at app and account level, and both POSTs go through the console gate
+ // (CSRF, then owner, then the byte-identical 404).
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/errors", s.handleAppErrors)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/errors/level", s.handleAppErrorLevel)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)
// The pipeline settings page (pipelinepage.go, release pipelines v2 §5):
@@ -1373,6 +1381,22 @@ type page struct {
LogTruncated bool
LogsErr string
+ // App Errors (errorsview.go's page, apierrors.go's sibling): the
+ // assembled window plus the filter state the form re-renders with. Empty
+ // ErrWin with ErrsErr "" is the normal nothing-failed-yet state, never an
+ // error.
+ ErrWin errorsWindow
+ ErrSince string // "1h" | "24h" | "168h", or any duration a URL named
+ ErrSource string
+ ErrLevel string // "" = the setting
+ ErrHost string
+ ErrFP string // expanded group
+ ErrsErr string
+ // ErrLevelSetting is the resolved log level for the two SETTINGS pages
+ // (app and account), which is where the level is changed rather than
+ // read past. Zero everywhere else.
+ ErrLevelSetting telemetry.Resolved
+
// Action confirm interstitial (confirm.go): what is about to happen,
// carried fields and all. Nil everywhere except the confirm page.
Confirm *ConfirmView
@@ -2359,6 +2383,12 @@ func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) {
if role == RoleOwner {
s.servicePane(&p, acct)
}
+ // The account's own Errors log level. Resolved with an EMPTY app name on
+ // purpose: AppKey(sqid, "") names a key no app ever writes, so the app
+ // layer contributes nothing and what comes back is the account setting
+ // or the default — which is exactly what this page sets. Best-effort,
+ // like the pane above.
+ p.ErrLevelSetting, _ = telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, "")
s.render(w, http.StatusOK, "account_settings", p)
}
@@ -2745,6 +2775,12 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
return
}
p.Settings = s.assembleSettings(r.Context(), &p, acct)
+ // The Errors log level this app resolves to, and where it comes from —
+ // the one setting the tab itself cannot change. Best-effort: an
+ // unreadable telemetry object leaves the zero Resolved, and the section
+ // says nothing rather than failing a page that is mostly about
+ // something else.
+ p.ErrLevelSetting, _ = telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName)
s.render(w, http.StatusOK, "app_settings", p)
}
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index f9b65855d..b11f70e9a 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -284,8 +284,18 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) {
// account and app and refuses a control-plane app outright, so an instance
// token can only ever publish its OWN app's map.
// TestAPIDomainPathsReachesPublishAndInstance pins the contract itself.
+//
+// GET .../errors is the eighth (spec 2026-09-07 §3.6). It did not exist
+// before service principals, so listing it in `want` would make the legacy
+// map lie. The instance role reaches it DELIBERATELY, to match the logs door
+// pinned in `want` below: the errors window is the same records read from the
+// other end, and a credential that may read EVERY line of its own app's logs
+// gains nothing by reading the error-level ones grouped. The telemetry PUT
+// registered beside it is Operate-only and no instance credential reaches it
+// — TestAPITelemetryPutSetsLogLevel pins that refusal.
var exemptFromLegacyPin = map[string]bool{
- "GET /api/cli/apps": true,
+ "GET /api/cli/apps": true,
+ "GET /api/cli/apps/{account}/{app}/errors": true,
"GET /api/cli/apps/{account}/{app}/domains/{host}": true,
"PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
"GET /api/cli/apps/{account}/{app}/features": true,
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html
index da5fbf3e2..250589ed5 100644
--- a/internal/console/templates/account_settings.html
+++ b/internal/console/templates/account_settings.html
@@ -132,6 +132,22 @@
<p class="small">The secret is shown once, on the next screen, and is never recoverable from here.
Minting and rotating both ask for your passkey; revoking does not, so it stays one step in an
emergency.</p>
+<h2>Errors</h2>
+<form class="signin" method="post" action="{{.Base}}/errors/level">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label>Log lines counted as errors:
+ <select name="level">
+ <option value="error"{{if eq .ErrLevelSetting.LogLevel "error"}} selected{{end}}>error and above</option>
+ <option value="warn"{{if eq .ErrLevelSetting.LogLevel "warn"}} selected{{end}}>warn and above</option>
+ </select>
+ </label>
+ <button type="submit">Save</button>
+</form>
+<p class="small">Currently {{.ErrLevelSetting.LogLevel}}, from the {{.ErrLevelSetting.LogLevelFrom}}
+&mdash; the level for every app without its own setting, which an app&rsquo;s own Settings tab
+overrides. Widening one look to warn is always available on an app&rsquo;s Errors tab and changes
+nothing here.</p>
+
<h2>Delete this account</h2>
{{if .HasApps}}
<p class="small">This account has apps, so it can't be deleted. Delete or move the apps first;
diff --git a/internal/console/templates/app_errors.html b/internal/console/templates/app_errors.html
new file mode 100644
index 000000000..70cb307e6
--- /dev/null
+++ b/internal/console/templates/app_errors.html
@@ -0,0 +1,77 @@
+{{define "app_errors"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+<h2>Errors</h2>
+{{if .ErrsErr}}<p class="banner error">{{.ErrsErr}}</p>{{end}}
+<form method="get" class="small">
+ <label>from the last
+ <select name="since">
+ <option value="1h"{{if eq .ErrSince "1h"}} selected{{end}}>hour</option>
+ <option value="24h"{{if eq .ErrSince "24h"}} selected{{end}}>day</option>
+ <option value="168h"{{if eq .ErrSince "168h"}} selected{{end}}>week</option>
+ </select>
+ </label>
+ <label>source
+ <select name="source">
+ <option value=""{{if eq .ErrSource ""}} selected{{end}}>all</option>
+ <option value="log"{{if eq .ErrSource "log"}} selected{{end}}>log</option>
+ <option value="edge"{{if eq .ErrSource "edge"}} selected{{end}}>edge</option>
+ <option value="server"{{if eq .ErrSource "server"}} selected{{end}}>server</option>
+ <option value="browser"{{if eq .ErrSource "browser"}} selected{{end}}>browser</option>
+ </select>
+ </label>
+ <label>log level
+ <select name="level">
+ <option value=""{{if eq .ErrLevel ""}} selected{{end}}>setting ({{.ErrWin.LogLevel}})</option>
+ <option value="error"{{if eq .ErrLevel "error"}} selected{{end}}>error</option>
+ <option value="warn"{{if eq .ErrLevel "warn"}} selected{{end}}>warn and above</option>
+ </select>
+ </label>
+ <label>host <input name="host" value="{{.ErrHost}}" size="24"></label>
+ <button type="submit">Update</button>
+</form>
+<p class="small">Sources live: {{if .ErrWin.Sources}}{{range $i, $s := .ErrWin.Sources}}{{if $i}}, {{end}}{{$s}}{{end}}{{else}}none yet{{end}}.
+Counts are to the nearest flush. Messages are redacted for addresses and credentials before they are stored; treat that as a floor, not a guarantee.</p>
+
+{{if .ErrFP}}
+{{with .ErrWin.Records}}
+<h3>Occurrences of <code>{{$.ErrFP}}</code></h3>
+<p class="small"><a href="?since={{$.ErrSince}}">All groups</a></p>
+{{range .}}
+<div class="tablewrap"><table>
+ <tr><th>Last</th><td>{{.Last.UTC.Format "2006-01-02 15:04:05"}}</td></tr>
+ <tr><th>Count</th><td>{{.Count}}</td></tr>
+ <tr><th>Source</th><td>{{.Source}}{{if .Kind}} / {{.Kind}}{{end}}</td></tr>
+ <tr><th>Host</th><td>{{.Host}}</td></tr>
+ {{if .Release}}<tr><th>Release</th><td>{{.Release}}</td></tr>{{end}}
+ {{if .Path}}<tr><th>Path</th><td><code>{{.Path}}</code>{{if .Status}} &rarr; {{.Status}}{{end}}</td></tr>{{end}}
+ <tr><th>Message</th><td>{{.Name}}</td></tr>
+ {{if .Stack}}<tr><th>Stack</th><td><pre>{{.Stack}}</pre></td></tr>{{end}}
+ {{if .Fields}}<tr><th>Fields</th><td>{{range $k, $v := .Fields}}<code>{{$k}}={{$v}}</code> {{end}}</td></tr>{{end}}
+ <tr><th>Logs</th><td><a href="{{$.Nav}}/apps/{{$.AppName}}/logs?since=1h&amp;host={{.Host}}">around this time</a></td></tr>
+</table></div>
+{{end}}
+{{else}}<p>No occurrences of this group in the window.</p>{{end}}
+{{else if .ErrWin.Groups}}
+{{if .ErrWin.Truncated}}<p class="small">Showing the most recent 50,000 records of a larger window — counts are within what was read.</p>{{end}}
+<div class="tablewrap">
+<table>
+ <tr><th>Last seen</th><th>Count</th><th>Source</th><th>Error</th><th>Hosts</th><th>Releases</th></tr>
+ {{range .ErrWin.Groups}}<tr>
+ <td>{{.Last.UTC.Format "2006-01-02 15:04"}}</td>
+ <td>{{.Count}}{{if eq .FP "_overflow"}} <span class="small" title="more kinds than the hourly cap; occurrences">(overflow)</span>{{end}}</td>
+ <td>{{.Source}}{{if .Level}} <span class="small">{{.Level}}</span>{{end}}</td>
+ <td><a href="?since={{$.ErrSince}}&amp;fp={{.FP}}{{if $.ErrLevel}}&amp;level={{$.ErrLevel}}{{end}}">{{.Name}}</a></td>
+ <td>{{range $i, $h := .Hosts}}{{if $i}}, {{end}}{{$h}}{{end}}</td>
+ <td>{{range $i, $r := .Releases}}{{if $i}}, {{end}}<code>{{$r}}</code>{{end}}</td>
+ </tr>{{end}}
+</table>
+</div>
+<p class="small">Times are UTC. The same view from a terminal: <code>carlos errors --app {{.AppName}} --since {{.ErrSince}}</code>.</p>
+{{else if not .ErrsErr}}
+<p>No errors in this window.</p>
+<p class="small">Log lines at <code>{{.ErrWin.LogLevel}}</code> and above and responses of 500 or higher appear here on their own; a JavaScript or server-side error needs the hook (coming in a later step). The level is set on the Settings tab. The same view from a terminal: <code>carlos errors --app {{.AppName}}</code>.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 5dafd1603..e5eda0cf6 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -165,6 +165,27 @@ credentials — an operator of this deployment has to tear it down.</p>
and delivers its credentials into this app's config.</p>
{{end}}
{{end}}{{end}}
+<h2>Errors</h2>
+{{if .IsOwner}}
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/errors/level" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label>Log lines counted as errors:
+ <select name="level">
+ <option value="error"{{if eq .ErrLevelSetting.LogLevel "error"}} selected{{end}}>error and above</option>
+ <option value="warn"{{if eq .ErrLevelSetting.LogLevel "warn"}} selected{{end}}>warn and above</option>
+ </select>
+ </label>
+ <button type="submit" class="btn-sm">Save</button>
+ <span class="small">currently {{.ErrLevelSetting.LogLevel}}, from the {{.ErrLevelSetting.LogLevelFrom}}</span>
+</form>
+{{else}}
+<p class="small">Log lines at <code>{{.ErrLevelSetting.LogLevel}}</code> and above are counted as
+errors for this app, from the {{.ErrLevelSetting.LogLevelFrom}}. Changing it is the account
+owner&rsquo;s.</p>
+{{end}}
+<p class="small">The <a href="{{.Base}}/apps/{{.Detail.App}}/errors">Errors tab</a> always lets you
+widen one look to warn without changing this.</p>
+
{{if .Settings}}{{with .Settings.Features}}
<h2>Feature flags</h2>
{{if .Flags}}
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html
index c4b4a3a11..c19afaaf7 100644
--- a/internal/console/templates/appnav.html
+++ b/internal/console/templates/appnav.html
@@ -8,6 +8,7 @@
<a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Versions</a>
<a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a>
<a href="{{.Nav}}/apps/{{.AppName}}/logs"{{if eq .ActiveTab "logs"}} aria-current="page"{{end}}>Logs</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/errors"{{if eq .ActiveTab "errors"}} aria-current="page"{{end}}>Errors</a>
<a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
<a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
</nav>