console: owner-only telemetry PUT for persons; cap the Errors page
- apierrors.go: handleAPITelemetryPut now requires a PERSON principal to be
an account owner (jsonError 403 otherwise), matching the owner-only
browser form. Service credentials remain Operate-only via cliRoute.
- apierrors_test.go: TestAPITelemetryPutSetsLogLevel exercises a member's
PUT (403, nothing written) and an owner's CLI token PUT (200); new
TestAppErrorsPageCaps covers the Errors page's group/occurrence caps.
- server.go: appErrorsPageCap = 200 beside appLogsPageCap; handleAppSettings
and handleAccountSettings report a failed telemetry.Resolve via the new
ErrLevelUnread flag instead of silently defaulting.
- errorsview.go: handleAppErrors caps win.Groups to the newest 200 and
tails win.Records to the newest 200, marking ErrWin.Truncated; on a
readErrors error it sets ErrWin.LogLevel/LogLevelFrom to the safe
default so the level select still renders; account-level audit rows
are filed under a stable "account" subject (accountAuditSubject),
following serviceAuditKey's convention in apiservices.go:362-378.
- templates/app_errors.html: truncation sentences for both the groups
table and the expanded-occurrence view use {{len ...}} instead of a
hard-coded 50,000; group links carry source and host as well as level;
overflow title reworded to "occurrences beyond the hourly cap on
distinct errors"; groups table gains a First seen column and shows
Kind as a small span after Source.
- templates/app_settings.html, account_settings.html: a failed
telemetry.Resolve (ErrLevelUnread) shows a one-line "could not be
read" notice instead of the settings form; the member-facing sentence
now reads "Only the account owner can change it."
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+160
−18
internal/console/apierrors.go+8 −0internal/console/apierrors_test.go+73 −0internal/console/errorsview.go+28 −1internal/console/server.go+32 −8internal/console/templates/account_settings.html+5 −0internal/console/templates/app_errors.html+8 −6internal/console/templates/app_settings.html+6 −3
diff --git a/internal/console/apierrors.go b/internal/console/apierrors.go| index a5d6c40b4..66332fa38 100644 |
| --- a/internal/console/apierrors.go |
| +++ b/internal/console/apierrors.go |
| @@ -64,6 +64,14 @@ func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + // The browser form for this setting is owner-only, so the person path |
| + // here is too. A SERVICE holds no account role (AccountByID does not |
| + // populate Role), which is why this reads the kind first. |
| + p, _ := principalFrom(r.Context()) |
| + if p.Kind == PrincipalUser && acct.Role != RoleOwner { |
| + jsonError(w, http.StatusForbidden, "only an account owner can change the Errors log level") |
| + 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\"}}") |
diff --git a/internal/console/apierrors_test.go b/internal/console/apierrors_test.go| index ccfa41a32..41ef56a3e 100644 |
| --- a/internal/console/apierrors_test.go |
| +++ b/internal/console/apierrors_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "encoding/json" |
| "net/http" |
| + "strconv" |
| "strings" |
| "testing" |
| "time" |
| @@ -95,6 +96,22 @@ func TestAPITelemetryPutSetsLogLevel(t *testing.T) { |
| if status != http.StatusForbidden && status != http.StatusNotFound { |
| t.Fatalf("instance token PUT = %d %s", status, body) |
| } |
| + // A PERSON must be an owner: the browser form for this setting 404s a |
| + // member, so the API must not be the way around it. alice (the |
| + // fixture's token) is a member. |
| + status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", token, `{"errors":{"log_level":"warn"}}`) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("member PUT = %d %s", status, body) |
| + } |
| + if r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo"); r.LogLevel != "error" { |
| + t.Fatalf("a refused PUT wrote anyway: %+v", r) |
| + } |
| + // paul owns the ops account in the fixture. |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-errors-owner") |
| + status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", owner, `{"errors":{"log_level":"error"}}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("owner 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 { |
| @@ -116,6 +133,62 @@ func TestAPITelemetryPutSetsLogLevel(t *testing.T) { |
| } |
| } |
| +// TestAppErrorsPageCaps: the tab renders a page's worth of both of its |
| +// shapes — appErrorsPageCap groups in the table, appErrorsPageCap |
| +// occurrences in an expanded group — and says so. readErrors collects up to |
| +// logsCollectCap records, so without the cap a busy app's tab is a render |
| +// nobody asked for. |
| +func TestAppErrorsPageCaps(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-errors-cap") |
| + now := time.Now().UTC() |
| + var recs []apperr.Record |
| + for i := 0; i < appErrorsPageCap+50; i++ { |
| + ts := now.Add(-10 * time.Minute).Add(time.Duration(i) * time.Second) |
| + recs = append(recs, |
| + // One group each: the table's cap. |
| + apperr.Record{TS: ts, Box: "box1", Host: "titogo.example.com", |
| + Source: apperr.SourceEdge, Kind: apperr.KindHTTP, |
| + Name: "500 GET /bulk" + strconv.Itoa(i), Path: "/bulk" + strconv.Itoa(i), Status: 500, |
| + FP: "bulk" + strconv.Itoa(i), FPV: 1, Count: 1, First: ts, Last: ts, Seq: uint64(1000 + i)}, |
| + // One group of many: the expanded view's cap. |
| + apperr.Record{TS: ts, Box: "box1", Host: "titogo.example.com", |
| + Source: apperr.SourceEdge, Kind: apperr.KindHTTP, |
| + Name: "500 POST /one", Path: "/one", Status: 500, |
| + FP: "bulkone", FPV: 1, Count: 1, First: ts, Last: ts, Seq: uint64(9000 + i)}) |
| + } |
| + body, err := apperr.EncodeChunk(recs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(recs[0].TS), 1000, len(body)), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-errors-cap-signin") |
| + |
| + status, _, page := get(t, c, fx.u("/apps/titogo/errors")) |
| + if status != http.StatusOK { |
| + t.Fatalf("errors page: %d", status) |
| + } |
| + if n := strings.Count(page, "fp=bulk"); n != appErrorsPageCap { |
| + t.Errorf("group rows = %d, want %d", n, appErrorsPageCap) |
| + } |
| + if !strings.Contains(page, "groups of a larger window") { |
| + t.Error("a capped table must say it was capped") |
| + } |
| + status, _, page = get(t, c, fx.u("/apps/titogo/errors?fp=bulkone")) |
| + if status != http.StatusOK { |
| + t.Fatalf("expanded page: %d", status) |
| + } |
| + if n := strings.Count(page, "<th>Path</th>"); n != appErrorsPageCap { |
| + t.Errorf("occurrence cards = %d, want %d", n, appErrorsPageCap) |
| + } |
| + if !strings.Contains(page, "occurrences of a larger window") { |
| + t.Error("a capped occurrence list must say it was capped") |
| + } |
| +} |
| + |
| func TestAppErrorsPage(t *testing.T) { |
| fx, _ := instanceFixture(t, "code-errors-page") |
| now := time.Now().UTC() |
diff --git a/internal/console/errorsview.go b/internal/console/errorsview.go| index e9dac20f7..7eb57ed32 100644 |
| --- a/internal/console/errorsview.go |
| +++ b/internal/console/errorsview.go |
| @@ -148,6 +148,12 @@ var validFP = regexp.MustCompile(`^[A-Za-z0-9_.-]{0,64}$`) |
| // worth looking back further for. |
| const errorsDefaultSince = "24h" |
| +// accountAuditSubject is the app column an ACCOUNT-level setting change is |
| +// filed under. An empty column reads as "no subject" in the activity feed, |
| +// which is not what an account-wide decision is; this follows |
| +// serviceAuditKey's convention of naming the subject instead. |
| +const accountAuditSubject = "account" |
| + |
| // handleAppErrors renders the Errors tab: the same read the API answers, |
| // with the filters as GET params so a view is a URL. |
| func (s *Server) handleAppErrors(w http.ResponseWriter, r *http.Request) { |
| @@ -180,6 +186,22 @@ func (s *Server) handleAppErrors(w http.ResponseWriter, r *http.Request) { |
| 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." |
| + // A failed read leaves no resolved setting, and the filter's |
| + // "setting (…)" option must still name a level rather than an |
| + // empty pair of brackets. |
| + win.LogLevel, win.LogLevelFrom = telemetry.LogLevelError, "default" |
| + } |
| + // The page shows a page's worth. Groups come newest first, so the |
| + // head of that list is the one to keep; occurrences come oldest |
| + // first, so it is the tail. Either trim is the same truncation the |
| + // window itself reports. |
| + if len(win.Groups) > appErrorsPageCap { |
| + win.Groups = win.Groups[:appErrorsPageCap] |
| + win.Truncated = true |
| + } |
| + if len(win.Records) > appErrorsPageCap { |
| + win.Records = win.Records[len(win.Records)-appErrorsPageCap:] |
| + win.Truncated = true |
| } |
| p.ErrWin = win |
| } else { |
| @@ -268,7 +290,12 @@ func (s *Server) handleAccountErrorLevel(w http.ResponseWriter, r *http.Request) |
| http.Redirect(w, r, back, http.StatusSeeOther) |
| return |
| } |
| - s.audit(acct, "", addr, "errors-level", "set the account's Errors log level to "+level) |
| + // Filed under a stable SUBJECT rather than an empty app column, the way |
| + // serviceAuditKey files an account-scoped credential under its own name |
| + // (apiservices.go): the subject of this row is the account itself, and |
| + // "account" is what the activity feed shows for it. An app's own level |
| + // change is filed under that app, above. |
| + s.audit(acct, accountAuditSubject, addr, "errors-level", "set the account's Errors log level to "+level) |
| s.setFlash(w, "ok", "Errors now include "+level+" lines and above for every app without its own setting.") |
| http.Redirect(w, r, back, http.StatusSeeOther) |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index fd911b6f9..e30629be7 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1395,7 +1395,14 @@ type page struct { |
| // 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. |
| + // |
| + // ErrLevelUnread says the resolve FAILED, so the zero Resolved beside it |
| + // must not be shown as if the defaults were the setting: a form |
| + // pre-selecting "error" over a level that is really "warn" invites an |
| + // owner to save a change they did not mean to make. The section then |
| + // says it could not be read, and offers no form at all. |
| ErrLevelSetting telemetry.Resolved |
| + ErrLevelUnread bool |
| // Action confirm interstitial (confirm.go): what is about to happen, |
| // carried fields and all. Nil everywhere except the confirm page. |
| @@ -2386,9 +2393,15 @@ func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) { |
| // 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, "") |
| + // or the default — which is exactly what this page sets. A failed read |
| + // is said rather than defaulted (see ErrLevelUnread), and never fails |
| + // the page. |
| + if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, ""); err != nil { |
| + log.Printf("console: account errors level %s: %v", acct.Sqid, err) |
| + p.ErrLevelUnread = true |
| + } else { |
| + p.ErrLevelSetting = res |
| + } |
| s.render(w, http.StatusOK, "account_settings", p) |
| } |
| @@ -2713,6 +2726,13 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| // CLI (`carlos logs`) is the door for more, and the page says so. |
| const appLogsPageCap = 200 |
| +// appErrorsPageCap bounds the Errors tab the same way, on both of its |
| +// shapes: the number of GROUPS in the table, and the number of OCCURRENCE |
| +// cards in an expanded group. readErrors collects up to logsCollectCap |
| +// records per family, so without this a busy app's tab is a page nobody can |
| +// read and a render nobody asked for. `carlos errors` is the door for more. |
| +const appErrorsPageCap = 200 |
| + |
| // handleAppLogs renders the app's merged log timeline — the same window the |
| // CLI reads (apilogs.go), tailed to the newest appLogsPageCap records, with |
| // stream and window filters as plain GET params so a view is a URL. |
| @@ -2776,11 +2796,15 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) { |
| } |
| 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) |
| + // the one setting the tab itself cannot change. A failed read is said |
| + // rather than defaulted (see ErrLevelUnread), and never fails a page |
| + // that is mostly about something else. |
| + if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil { |
| + log.Printf("console: errors level %s/%s: %v", acct.Sqid, p.AppName, err) |
| + p.ErrLevelUnread = true |
| + } else { |
| + p.ErrLevelSetting = res |
| + } |
| s.render(w, http.StatusOK, "app_settings", p) |
| } |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| index 250589ed5..55237a630 100644 |
| --- a/internal/console/templates/account_settings.html |
| +++ b/internal/console/templates/account_settings.html |
| @@ -133,6 +133,10 @@ |
| Minting and rotating both ask for your passkey; revoking does not, so it stays one step in an |
| emergency.</p> |
| <h2>Errors</h2> |
| +{{if .ErrLevelUnread}} |
| +<p class="small">This account’s Errors log level could not be read just now, so it is not |
| +shown and cannot be changed from here. Reload before concluding anything.</p> |
| +{{else}} |
| <form class="signin" method="post" action="{{.Base}}/errors/level"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| <label>Log lines counted as errors: |
| @@ -147,6 +151,7 @@ |
| — 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> |
| +{{end}} |
| <h2>Delete this account</h2> |
| {{if .HasApps}} |
diff --git a/internal/console/templates/app_errors.html b/internal/console/templates/app_errors.html| index 70cb307e6..d3cc17284 100644 |
| --- a/internal/console/templates/app_errors.html |
| +++ b/internal/console/templates/app_errors.html |
| @@ -38,7 +38,8 @@ Counts are to the nearest flush. Messages are redacted for addresses and credent |
| {{if .ErrFP}} |
| {{with .ErrWin.Records}} |
| <h3>Occurrences of <code>{{$.ErrFP}}</code></h3> |
| -<p class="small"><a href="?since={{$.ErrSince}}">All groups</a></p> |
| +<p class="small"><a href="?since={{$.ErrSince}}{{if $.ErrSource}}&source={{$.ErrSource}}{{end}}{{if $.ErrHost}}&host={{$.ErrHost}}{{end}}{{if $.ErrLevel}}&level={{$.ErrLevel}}{{end}}">All groups</a></p> |
| +{{if $.ErrWin.Truncated}}<p class="small">Showing the newest {{len $.ErrWin.Records}} occurrences of a larger window — narrow the window, or use <code>carlos errors</code> for all of them.</p>{{end}} |
| {{range .}} |
| <div class="tablewrap"><table> |
| <tr><th>Last</th><td>{{.Last.UTC.Format "2006-01-02 15:04:05"}}</td></tr> |
| @@ -55,15 +56,16 @@ Counts are to the nearest flush. Messages are redacted for addresses and credent |
| {{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}} |
| +{{if .ErrWin.Truncated}}<p class="small">Showing {{len .ErrWin.Groups}} groups of a larger window — counts are within what was read. Narrow the window, or use <code>carlos errors</code> for all of them.</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> |
| + <tr><th>Last seen</th><th>First 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>{{.First.UTC.Format "2006-01-02 15:04"}}</td> |
| + <td>{{.Count}}{{if eq .FP "_overflow"}} <span class="small" title="occurrences beyond the hourly cap on distinct errors">(overflow)</span>{{end}}</td> |
| + <td>{{.Source}}{{if .Kind}} <span class="small">{{.Kind}}</span>{{end}}{{if .Level}} <span class="small">{{.Level}}</span>{{end}}</td> |
| + <td><a href="?since={{$.ErrSince}}&fp={{.FP}}{{if $.ErrSource}}&source={{$.ErrSource}}{{end}}{{if $.ErrHost}}&host={{$.ErrHost}}{{end}}{{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}} |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index e5eda0cf6..ce8245a86 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -166,7 +166,10 @@ and delivers its credentials into this app's config.</p> |
| {{end}} |
| {{end}}{{end}} |
| <h2>Errors</h2> |
| -{{if .IsOwner}} |
| +{{if .ErrLevelUnread}} |
| +<p class="small">This app’s Errors log level could not be read just now, so it is not shown |
| +and cannot be changed from here. Reload before concluding anything.</p> |
| +{{else 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: |
| @@ -180,8 +183,8 @@ and delivers its credentials into this app's config.</p> |
| </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> |
| +errors for this app, from the {{.ErrLevelSetting.LogLevelFrom}}. Only the account owner can |
| +change it.</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> |