console: the Errors tab names log and edge always, and always shows the snippet
Two fixes from the whole-branch review, both about what the Errors tab says when there is nothing much to say. The sources line seeded log and edge. Both are live for every app from the day it is deployed — the agent tails the app's output, the edge records its own 5xx, neither needs anyone to opt in — so an empty window said "none yet" about sources that were in fact running. It now names them whatever the window holds, and browser and server still join only once a record proves the install took. The seeding happens before the read's early returns, so a failed read prints the line rather than "Sources live: .". That extends planning ruling 7, which said the same thing for the Analytics tab's sources line. And the one line a page needs for JavaScript errors was only printed on the "No errors in this window" branch — visible precisely to the owner who has nothing to look at, and hidden from the owner staring at a page of edge 5xx wondering why no browser errors are among them. It moves up beside the sources line, where it renders in every state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+60
−4
internal/console/apierrors_test.go+43 −1internal/console/errorsview.go+11 −2internal/console/templates/app_errors.html+6 −1
diff --git a/internal/console/apierrors_test.go b/internal/console/apierrors_test.go| index 4af97bac6..e623c0f67 100644 |
| --- a/internal/console/apierrors_test.go |
| +++ b/internal/console/apierrors_test.go |
| @@ -44,6 +44,40 @@ func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) { |
| } |
| } |
| +// TestErrorsSourcesSeedLogAndEdge: log and edge are live for every app on |
| +// day one, so the sources line names them whether or not the window holds |
| +// one of their records; browser joins only once a record proves the page |
| +// snippet is installed (item F, extending planning ruling 7). |
| +func TestErrorsSourcesSeedLogAndEdge(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-errors-sources") |
| + now := time.Now().UTC() |
| + sources := func(query string) []string { |
| + 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.Sources |
| + } |
| + if got := sources(""); len(got) != 2 || got[0] != "log" || got[1] != "edge" { |
| + t.Fatalf("empty window sources = %v, want [log edge]", got) |
| + } |
| + b := []apperr.Record{{TS: now.Add(-2 * time.Minute), Box: "box1", Host: "titogo.example.com", |
| + Source: apperr.SourceBrowser, Kind: "TypeError", Name: "x is not a function", Path: "/", |
| + FP: "br1", FPV: 1, Count: 1, First: now.Add(-2 * time.Minute), Last: now.Add(-2 * time.Minute), Seq: 300}} |
| + bb, _ := apperr.EncodeChunk(b) |
| + if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(b[0].TS), 300, len(bb)), bb, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := sources(""); len(got) != 3 || got[0] != "log" || got[1] != "edge" || got[2] != "browser" { |
| + t.Fatalf("with a browser record sources = %v, want [log edge browser]", got) |
| + } |
| +} |
| + |
| func TestAPIErrorsGroupsBothFamilies(t *testing.T) { |
| fx, token := instanceFixture(t, "code-errors-1") |
| now := time.Now().UTC() |
| @@ -219,7 +253,10 @@ func TestAppErrorsPage(t *testing.T) { |
| 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"} { |
| + // The carlos.js instruction is on the populated page too: an app with |
| + // edge 5xx in the window is exactly the app whose owner has not yet |
| + // discovered that JS errors need a line in the page (item C). |
| + for _, want := range []string{"500 POST /checkout", "wake failed", "carlos errors --app titogo", "Sources live: log, edge", "to the nearest flush", "/__carlos/carlos.js"} { |
| if !strings.Contains(body, want) { |
| t.Errorf("page missing %q", want) |
| } |
| @@ -239,4 +276,9 @@ func TestAppErrorsPage(t *testing.T) { |
| if status != 200 || !strings.Contains(body, "No errors in this window") || !strings.Contains(body, "/__carlos/carlos.js") { |
| t.Fatalf("empty window: %d", status) |
| } |
| + // An empty window still names the two sources that are live for every |
| + // app, and never says "none yet" (item F). |
| + if !strings.Contains(body, "Sources live: log, edge.") || strings.Contains(body, "none yet") { |
| + t.Error("empty window must still say: Sources live: log, edge.") |
| + } |
| } |
diff --git a/internal/console/errorsview.go b/internal/console/errorsview.go| index 7eb57ed32..2c8a77e6f 100644 |
| --- a/internal/console/errorsview.go |
| +++ b/internal/console/errorsview.go |
| @@ -27,7 +27,7 @@ type errorsWindow struct { |
| Truncated bool |
| LogLevel string // resolved setting |
| LogLevelFrom string |
| - Sources []string // sources with at least one record in the window |
| + Sources []string // log and edge always, plus any other source with a record in the window |
| } |
| // readErrors assembles the window. level overrides the resolved setting |
| @@ -35,6 +35,15 @@ type errorsWindow struct { |
| // 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 |
| + // log and edge are live for every app from the day it is deployed — |
| + // the agent tails the app's output and the edge records its own 5xx |
| + // without anyone opting in — so they are listed whether or not this |
| + // particular window happens to hold one of their records. browser and |
| + // server each need something installed, so they appear only once a |
| + // record proves the install took. Seeded before the early returns: |
| + // the tab prints this line even when the read failed, and it must not |
| + // print "Sources live: ." there. |
| + out.Sources = []string{apperr.SourceLog, apperr.SourceEdge} |
| res, err := telemetry.Resolve(ctx, s.cfg.Releases, sqid, app) |
| if err != nil { |
| return out, err |
| @@ -68,7 +77,7 @@ func (s *Server) readErrors(ctx context.Context, sqid, app string, f apperr.Filt |
| out.Records = all |
| } |
| out.Groups = apperr.GroupRecords(all) |
| - seen := map[string]bool{} |
| + seen := map[string]bool{apperr.SourceLog: true, apperr.SourceEdge: true} |
| for _, r := range all { |
| if !seen[r.Source] { |
| seen[r.Source] = true |
diff --git a/internal/console/templates/app_errors.html b/internal/console/templates/app_errors.html| index e929ce41b..f2a992efa 100644 |
| --- a/internal/console/templates/app_errors.html |
| +++ b/internal/console/templates/app_errors.html |
| @@ -32,8 +32,13 @@ |
| <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}}. |
| +<p class="small">Sources live: {{range $i, $s := .ErrWin.Sources}}{{if $i}}, {{end}}{{$s}}{{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> |
| +<!-- The script tag below is prose, not a real subresource fetch: its src |
| + attribute is spelled with = for = and " for the quotes so |
| + TestConsoleTemplatesFetchNothingOffOrigin (icons_test.go) does not |
| + misread this line as one. --> |
| +<p class="small">A JavaScript error needs one line in the page: <code><script src="/__carlos/carlos.js" defer></script></code> — messages, stacks and fields are redacted for addresses, credentials and long tokens before they leave the page and again on arrival (a floor, not a guarantee).</p> |
| {{if .ErrFP}} |
| {{with .ErrWin.Records}} |