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>
10 files changed,
+687
−1
internal/console/apierrors.go+91 −0internal/console/apierrors_test.go+150 −0internal/console/authcli.go+10 −0internal/console/errorsview.go+274 −0internal/console/server.go+36 −0internal/console/svcroutes_test.go+11 −1internal/console/templates/account_settings.html+16 −0internal/console/templates/app_errors.html+77 −0internal/console/templates/app_settings.html+21 −0internal/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}} |
| +— the level for every app without its own setting, which an app’s own Settings tab |
| +overrides. Widening one look to warn is always available on an app’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}} → {{.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&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}}&fp={{.FP}}{{if $.ErrLevel}}&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’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> |