carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev fa557c05546c7ae9556a98155eef7842b686e5bc parent 8479b65
7 files changed, +160 −18
  • internal/console/apierrors.go +8 −0
  • internal/console/apierrors_test.go +73 −0
  • internal/console/errorsview.go +28 −1
  • internal/console/server.go +32 −8
  • internal/console/templates/account_settings.html +5 −0
  • internal/console/templates/app_errors.html +8 −6
  • internal/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&rsquo;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 @@
&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>
+{{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}}&amp;source={{$.ErrSource}}{{end}}{{if $.ErrHost}}&amp;host={{$.ErrHost}}{{end}}{{if $.ErrLevel}}&amp;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}}&amp;fp={{.FP}}{{if $.ErrLevel}}&amp;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}}&amp;fp={{.FP}}{{if $.ErrSource}}&amp;source={{$.ErrSource}}{{end}}{{if $.ErrHost}}&amp;host={{$.ErrHost}}{{end}}{{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}}
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&rsquo;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&rsquo;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>