carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Errors tab, step 1: log and edge sources — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Every app in the fleet gets a populated Errors tab, API and CLI with zero integration: error-level log lines and edge-observed 5xx responses, grouped by fingerprint and coalesced on the box.
6
7**Architecture:** A new leaf package `internal/apperr` owns the error record, sanitisation, fingerprinting, the coalescing box sink, grouping, and the bucket read. It stores chunks in a sibling family of `logs/` (`apps/<sqid>/<app>/errors/<box>/<hour>/…jsonl.gz`) through family-parameterised helpers added to `internal/applog`. The edge's `countingWriter` learns to observe status and emits an edge error for upstream 5xx; the agent ships the sink like it ships logs. The console reads two families (`errors/` plus `logs/` filtered to a level from a new `internal/telemetry` settings object) and serves a tab, a `cliRoute` API and `carlos errors`.
8
9**Tech Stack:** Go 1.22+ (`net/http` patterns, generics), `html/template`, sqlite registry, `release.Store` bucket, no JS framework.
10
11**Spec:** `docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md` — §3.1 (shape, sanitisation), §3.2 (log source read-derived, edge errors, fingerprint, coalescing, storage, store door), §3.3 (observing writer, settings object), §3.6 (tab, API, CLI, settings, retention), §4, §6, §7 step 1, §8 rulings.
12
13## Global Constraints
14
15- Wire shapes are additive: `logs/` chunks must be byte-identical after the refactor; existing `internal/applog` tests pass unchanged.
16- Edge wrapper order is load-bearing: recorder above compressor, counter below; nothing moves, one wrapper learns to look (`internal/edge/edge.go:1241`).
17- Nothing here may enter `activator.Service.ServeHTTP` or stamp `lastActive`.
18- Store door write grant becomes a family allow-list: `logs`, `errors`, `analytics` (spec §3.2), each segment-exact.
19- Coalescing cap: 1,000 records per app per UTC hour counting `(fp, host, release)`; overflow folds into one `_overflow` row.
20- Fingerprint: `sha256(len-delimited(source, kind, norm(name), norm(top frame)))[:16]` hex, `fpv: 1`; 1–3 digit tokens survive normalisation, 4+ digits, hex 8+, UUIDs and quoted strings become placeholders.
21- Sanitisation before buffering on every source: query and fragment off paths; emails → `<email>`; `Bearer`/`Basic` credentials and `token=`/`key=`/`secret=`/`password=` values → `<redacted>`; hex or base64 runs of 32+ → `<hex>`.
22- Defaults (ruling): `errors.log_level` = `error`. Settings object: `accounts/<sqid>/telemetry.json` and `apps/<sqid>/<app>/telemetry.json`, resolved app → account → default.
23- API: `GET /api/cli/apps/{account}/{app}/errors` with `svcrole.Allow(svcrole.Instance, svcrole.Operate)`; pages go through `appPage` (session → CSRF → owner → fresh auth).
24- New `carlos errors` needs a `### carlos errors` heading in `docs/site/cli.md` or `internal/docsite` fails the gate.
25- Go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; sandbox may need disabling on "read-only file system". Before push: `GOOS=darwin GOARCH=arm64 go build ./...` and `GOOS=windows GOARCH=amd64 go build ./...`.
26- Every commit: `go build ./... && go vet ./... && gofmt -l .` clean for the packages touched; full `go test ./... -count=1` before the final push.
27- Commit trailer on every commit: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
28- Branch: `spec-errors-analytics` (already exists, carries the spec). Push to `origin` (the amadan forge). Never `gh`.
29
30## File structure
31
32| File | Responsibility |
33|---|---|
34| `internal/applog/chunk.go` | + `Family`, `FamilyPrefix/BoxPrefix/HourPrefix/ChunkKey`, generic `EncodeJSONL`/`DecodeJSONL`; existing funcs become thin wrappers |
35| `internal/applog/read.go` | + generic `ReadChunks[T]`; `ReadWindow` re-based on it |
36| `internal/edge/storedoor.go` | `aclWriteAppLogs` → `aclWriteAppFamily` allow-list |
37| `internal/apperr/record.go` | `Record`, source/kind constants, `FromLog` |
38| `internal/apperr/sanitize.go` | `Sanitize`, `SanitizePath` |
39| `internal/apperr/fingerprint.go` | `Normalize`, `TopFrame`, `Fingerprint`, `FPVersion` |
40| `internal/apperr/sink.go` | coalescing `Sink` |
41| `internal/apperr/read.go` | `Filter`, `ReadWindow`, chunk key helpers |
42| `internal/apperr/group.go` | `Group`, `GroupRecords` |
43| `internal/activator/answered.go` | `WithAnswerFlag`, `MarkAnswered` |
44| `internal/activator/activator.go` | mark the three wake-path 503s |
45| `internal/edge/usagemeter.go` | `countingWriter` observes status and content type |
46| `internal/edge/edge.go` | `ErrorSink`, `EnableErrors`, unconditional writer, edge error emission |
47| `internal/host/logship.go` | `logshipPrune` → `chunkPrune(fam, …)` |
48| `internal/host/errship.go` | error sink ship loop |
49| `internal/host/agent.go` | construct `apperr.Sink`, wire edge + loop |
50| `internal/telemetry/telemetry.go` | settings object, `Resolve`, `Update` |
51| `internal/console/errorsview.go` | window read merging both families; page fold |
52| `internal/console/apierrors.go` | `GET …/errors`, `PUT …/telemetry` |
53| `internal/console/server.go` | routes, page fields, `handleAppErrors`, settings handlers |
54| `internal/console/templates/app_errors.html`, `appnav.html`, `app_settings.html`, `account_settings.html` | tab, nav link, settings forms |
55| `errors_cmd.go`, `apimode.go`, `main.go` | `carlos errors` |
56| `docs/site/cli.md` | `### carlos errors` |
57
58---
59
60### Task 1: applog chunk families and a generic codec
61
62**Files:**
63- Modify: `internal/applog/chunk.go`
64- Modify: `internal/applog/read.go`
65- Test: `internal/applog/chunk_test.go` (append), `internal/applog/read_test.go` (append)
66
67**Interfaces:**
68- Produces: `type Family string`; `FamilyLogs`, `FamilyErrors`, `FamilyAnalytics`; `FamilyPrefix(fam Family, acct, app string) string`; `FamilyBoxPrefix(fam, acct, app, box)`; `FamilyHourPrefix(fam, acct, app, box, hour)`; `FamilyChunkKey(fam, acct, app, box, hour string, seq uint64, size int) string`; `EncodeJSONL[T any](recs []T) ([]byte, error)`; `DecodeJSONL[T any](b []byte) ([]T, error)`; `ReadChunks[T any](ctx, s release.Store, prefix string, since, until time.Time, keep func(T) bool, collectCap int) (perBox [][]T, trimmed bool, err error)`; `HourNameOfDir(dir string) string` (exported rename of `hourNameOfDir`).
69- Existing `Prefix`, `BoxPrefix`, `HourPrefix`, `ChunkKey`, `EncodeChunk`, `DecodeChunk`, `ReadWindow` keep their signatures and behaviour.
70
71- [ ] **Step 1: Write the failing tests**
72
73Append to `internal/applog/chunk_test.go`:
74
75```go
76func TestFamilyKeysMirrorLogs(t *testing.T) {
77 if got, want := FamilyPrefix(FamilyLogs, "bab", "web"), Prefix("bab", "web"); got != want {
78 t.Fatalf("FamilyPrefix(logs) = %q, Prefix = %q", got, want)
79 }
80 if got, want := FamilyChunkKey(FamilyLogs, "bab", "web", "box1", "2026-09-07-10", 42, 7), ChunkKey("bab", "web", "box1", "2026-09-07-10", 42, 7); got != want {
81 t.Fatalf("FamilyChunkKey(logs) = %q, ChunkKey = %q", got, want)
82 }
83 got := FamilyChunkKey(FamilyErrors, "bab", "web", "box1", "2026-09-07-10", 42, 7)
84 want := "apps/bab/web/errors/box1/2026-09-07-10/0000000000000042-7.jsonl.gz"
85 if got != want {
86 t.Fatalf("errors key = %q, want %q", got, want)
87 }
88 if n, ok := ChunkKeySize(got); !ok || n != 7 {
89 t.Fatalf("ChunkKeySize on an errors key = %d,%v", n, ok)
90 }
91}
92
93func TestEncodeJSONLRoundTripsAnyShape(t *testing.T) {
94 type rec struct {
95 A string `json:"a"`
96 N int `json:"n,omitempty"`
97 }
98 b, err := EncodeJSONL([]rec{{A: "x", N: 1}, {A: "y"}})
99 if err != nil {
100 t.Fatal(err)
101 }
102 out, err := DecodeJSONL[rec](b)
103 if err != nil {
104 t.Fatal(err)
105 }
106 if len(out) != 2 || out[0].A != "x" || out[0].N != 1 || out[1].A != "y" || out[1].N != 0 {
107 t.Fatalf("round trip = %+v", out)
108 }
109 // EncodeChunk must be byte-identical to EncodeJSONL over the same
110 // records: the logs family's wire shape does not move.
111 recs := []Record{{TS: time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC), Msg: "hi", Seq: 1}}
112 a, _ := EncodeChunk(recs)
113 c, _ := EncodeJSONL(recs)
114 da, _ := DecodeChunk(a)
115 dc, _ := DecodeJSONL[Record](c)
116 if len(da) != 1 || len(dc) != 1 || da[0].Msg != dc[0].Msg || !da[0].TS.Equal(dc[0].TS) {
117 t.Fatalf("EncodeChunk and EncodeJSONL disagree: %+v vs %+v", da, dc)
118 }
119}
120```
121
122Append to `internal/applog/read_test.go` (use the same store construction the file already uses — `release.NewDirStore(t.TempDir())` — and its existing helper for writing a chunk if one exists; otherwise `s.Put(ctx, ChunkKey(...), body, "")`):
123
124```go
125func TestReadChunksWalksAnyFamily(t *testing.T) {
126 ctx := context.Background()
127 s := release.NewDirStore(t.TempDir())
128 type ev struct {
129 TS time.Time `json:"ts"`
130 Name string `json:"name"`
131 }
132 now := time.Date(2026, 9, 7, 10, 30, 0, 0, time.UTC)
133 body, _ := EncodeJSONL([]ev{{TS: now, Name: "boom"}, {TS: now.Add(time.Minute), Name: "skip"}})
134 key := FamilyChunkKey(FamilyErrors, "bab", "web", "box1", HourOf(now), 1, len(body))
135 if _, err := s.Put(ctx, key, body, ""); err != nil {
136 t.Fatal(err)
137 }
138 perBox, trimmed, err := ReadChunks[ev](ctx, s, FamilyPrefix(FamilyErrors, "bab", "web"),
139 now.Add(-time.Hour), now.Add(time.Hour), func(e ev) bool { return e.Name != "skip" }, 100)
140 if err != nil || trimmed {
141 t.Fatalf("ReadChunks: %v trimmed=%v", err, trimmed)
142 }
143 if len(perBox) != 1 || len(perBox[0]) != 1 || perBox[0][0].Name != "boom" {
144 t.Fatalf("perBox = %+v", perBox)
145 }
146 // An hour outside the window is never listed.
147 perBox, _, err = ReadChunks[ev](ctx, s, FamilyPrefix(FamilyErrors, "bab", "web"),
148 now.Add(2*time.Hour), time.Time{}, func(ev) bool { return true }, 100)
149 if err != nil || len(perBox) != 1 || len(perBox[0]) != 0 {
150 t.Fatalf("out-of-window read = %+v, %v", perBox, err)
151 }
152}
153```
154
155- [ ] **Step 2: Run tests to verify they fail**
156
157Run: `go test ./internal/applog/ -run 'TestFamilyKeys|TestEncodeJSONL|TestReadChunks' -count=1`
158Expected: FAIL to compile (`undefined: FamilyPrefix` etc.).
159
160- [ ] **Step 3: Implement families and the generic codec in `chunk.go`**
161
162Replace the `Prefix`/`BoxPrefix`/`HourPrefix`/`ChunkKey` block and the codec with:
163
164```go
165// Family names one chunk family under an app — the path segment after
166// apps/<sqid>/<app>/. logs/ is the original; errors/ and analytics/ are
167// the siblings the errors-and-analytics spec adds (§3.2). The key shape
168// below the family segment is identical for every family, which is what
169// lets one prune loop, one store-door grant and one reader serve all three.
170type Family string
171
172const (
173 FamilyLogs Family = "logs"
174 FamilyErrors Family = "errors"
175 FamilyAnalytics Family = "analytics"
176)
177
178// FamilyPrefix is the app's whole subtree for one family — under
179// release.AppPrefix on purpose (spec §3): it is the app's data, so the
180// owner gate maps onto the prefix and app trash/purge sweeps it for free.
181func FamilyPrefix(fam Family, acct, app string) string {
182 return release.AppPrefix(acct, app) + string(fam) + "/"
183}
184
185// FamilyBoxPrefix is one box's subtree within a family — the unit of
186// write-side ownership: each box prunes only what it wrote.
187func FamilyBoxPrefix(fam Family, acct, app, box string) string {
188 return FamilyPrefix(fam, acct, app) + box + "/"
189}
190
191// FamilyHourPrefix is one UTC hour within a box's subtree.
192func FamilyHourPrefix(fam Family, acct, app, box, hour string) string {
193 return FamilyBoxPrefix(fam, acct, app, box) + hour + "/"
194}
195
196// FamilyChunkKey names one flushed chunk in any family. See ChunkKey for
197// why seq and size live in the key.
198func FamilyChunkKey(fam Family, acct, app, box, hour string, seq uint64, size int) string {
199 return fmt.Sprintf("%s%016d-%d.jsonl.gz", FamilyHourPrefix(fam, acct, app, box, hour), seq, size)
200}
201
202// Prefix is the app's whole log subtree: FamilyPrefix for logs.
203func Prefix(acct, app string) string { return FamilyPrefix(FamilyLogs, acct, app) }
204
205// BoxPrefix is one box's subtree within an app's logs.
206func BoxPrefix(acct, app, box string) string { return FamilyBoxPrefix(FamilyLogs, acct, app, box) }
207
208// HourPrefix is one UTC hour within a box's log subtree.
209func HourPrefix(acct, app, box, hour string) string {
210 return FamilyHourPrefix(FamilyLogs, acct, app, box, hour)
211}
212
213// ChunkKey names one flushed log chunk: seq is the first record's per-box
214// sequence number, zero-padded (16 digits — seq is milliseconds-based, see
215// NewSink) so lexical listing order is numeric order, and size is the
216// object's own byte length. Size lives IN the key on purpose:
217// release.Store's List returns keys only, and retention's byte cap needs
218// sizes — embedding them costs a suffix, extending the Store interface
219// costs every implementation. Chunks are written once and never appended
220// or rewritten (spec §3).
221func ChunkKey(acct, app, box, hour string, seq uint64, size int) string {
222 return FamilyChunkKey(FamilyLogs, acct, app, box, hour, seq, size)
223}
224```
225
226Keep `ChunkKeySize` as is. Replace `EncodeChunk`/`DecodeChunk` with:
227
228```go
229// EncodeJSONL renders any record shape as gzipped JSONL, one per line —
230// the chunk codec every family shares.
231func EncodeJSONL[T any](recs []T) ([]byte, error) {
232 var buf bytes.Buffer
233 zw := gzip.NewWriter(&buf)
234 enc := json.NewEncoder(zw)
235 for _, r := range recs {
236 if err := enc.Encode(r); err != nil {
237 return nil, err
238 }
239 }
240 if err := zw.Close(); err != nil {
241 return nil, err
242 }
243 return buf.Bytes(), nil
244}
245
246// DecodeJSONL parses a chunk, skipping malformed lines rather than failing
247// the whole object — one corrupt line must not cost the member the rest of
248// the hour. Corrupt gzip is an error: that is a broken object, not a line.
249func DecodeJSONL[T any](b []byte) ([]T, error) {
250 zr, err := gzip.NewReader(bytes.NewReader(b))
251 if err != nil {
252 return nil, err
253 }
254 defer zr.Close()
255 var out []T
256 sc := bufio.NewScanner(zr)
257 sc.Buffer(nil, 1<<20) // lines are capped well below this at the tee
258 for sc.Scan() {
259 var r T
260 if json.Unmarshal(sc.Bytes(), &r) != nil {
261 continue
262 }
263 out = append(out, r)
264 }
265 if err := sc.Err(); err != nil && err != io.EOF {
266 return out, err
267 }
268 return out, nil
269}
270
271// EncodeChunk renders log records: EncodeJSONL over Record.
272func EncodeChunk(recs []Record) ([]byte, error) { return EncodeJSONL(recs) }
273
274// DecodeChunk parses a log chunk: DecodeJSONL over Record.
275func DecodeChunk(b []byte) ([]Record, error) { return DecodeJSONL[Record](b) }
276```
277
278- [ ] **Step 4: Implement `ReadChunks` in `read.go` and re-base `ReadWindow` on it**
279
280```go
281// ReadChunks walks every box's hour directories under prefix (a
282// FamilyPrefix) whose hours intersect [since, until], decodes each chunk as
283// T, keeps what passes keep, and returns one slice per box in listing
284// order. Hour pruning happens at the PREFIX level — an hour outside the
285// window is never listed, which is what makes a 1h read over 7d of history
286// cheap (spec §3). collectCap bounds memory per box: past it the OLDEST
287// records go and trimmed says so. Store errors fail the read — a partial
288// answer that looks whole is the one thing this must never produce.
289func ReadChunks[T any](ctx context.Context, s release.Store, prefix string, since, until time.Time, keep func(T) bool, collectCap int) ([][]T, bool, error) {
290 boxDirs, err := s.ListDirs(ctx, prefix)
291 if err != nil {
292 return nil, false, err
293 }
294 trimmed := false
295 perBox := make([][]T, 0, len(boxDirs))
296 for _, boxDir := range boxDirs {
297 hourDirs, err := s.ListDirs(ctx, boxDir)
298 if err != nil {
299 return nil, false, err
300 }
301 var recs []T
302 for _, hourDir := range hourDirs { // lexically sorted = chronological
303 if !hourIntersects(HourNameOfDir(hourDir), since, until) {
304 continue
305 }
306 keys, err := s.List(ctx, hourDir)
307 if err != nil {
308 return nil, false, err
309 }
310 for _, key := range keys { // lexically sorted = seq order
311 body, _, err := s.Get(ctx, key)
312 if err != nil {
313 return nil, false, err
314 }
315 chunk, err := DecodeJSONL[T](body)
316 if err != nil {
317 return nil, false, err
318 }
319 for _, r := range chunk {
320 if keep(r) {
321 recs = append(recs, r)
322 }
323 }
324 if over := len(recs) - collectCap; over > 0 {
325 recs = recs[over:]
326 trimmed = true
327 }
328 }
329 }
330 perBox = append(perBox, recs)
331 }
332 return perBox, trimmed, nil
333}
334```
335
336Then `ReadWindow` becomes:
337
338```go
339func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) {
340 perBox, trimmed, err := ReadChunks[Record](ctx, s, Prefix(acct, app), f.Since, f.Until, f.Match, collectCap)
341 if err != nil {
342 return nil, false, err
343 }
344 merged := MergeRecords(perBox...)
345 if over := len(merged) - collectCap; over > 0 {
346 merged = merged[over:]
347 trimmed = true
348 }
349 return merged, trimmed, nil
350}
351```
352
353Rename `hourNameOfDir` to `HourNameOfDir` (exported; the host package's `hourNameOf` duplicate goes away in Task 6) and update its one caller.
354
355- [ ] **Step 5: Run the whole package**
356
357Run: `go test ./internal/applog/ -count=1`
358Expected: PASS, every pre-existing test included.
359
360- [ ] **Step 6: Commit**
361
362```bash
363git add internal/applog
364git commit -m "applog: chunk families and a generic JSONL codec, logs/ unchanged
365
366Prefix/ChunkKey/EncodeChunk/ReadWindow keep their signatures and bytes;
367FamilyPrefix, FamilyChunkKey, EncodeJSONL/DecodeJSONL and ReadChunks are
368the seams the errors/ family (spec 2026-09-07 §3.2) builds on.
369
370Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
371```
372
373---
374
375### Task 2: Store door family allow-list
376
377**Files:**
378- Modify: `internal/edge/storedoor.go:503-518` (`aclWriteAppLogs`)
379- Test: `internal/edge/storedoor_test.go` (rows in `TestStoreDoorACLMatrix`)
380
381**Interfaces:**
382- Produces: `aclWriteAppFamily(sqid, key string) bool` replacing `aclWriteAppLogs`; `aclWrite` calls it.
383
384- [ ] **Step 1: Add matrix rows**
385
386In `TestStoreDoorACLMatrix`, after the existing logs rows (around line 138), add:
387
388```go
389 // --- sibling chunk families (errors-and-analytics spec §3.2) ---
390 {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opWrite, true, "own app's error chunk"},
391 {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opDelete, true, "errship prunes its own subtree hourly"},
392 {"apps/ab12/web/analytics/box-a/days/2026-09-07.json", opWrite, true, "own app's analytics day file"},
393 {"apps/zz99/web/errors/box-a/x.jsonl.gz", opWrite, false, "another account's error chunks"},
394 {"apps/ab12/errors/box-a/x.jsonl.gz", opWrite, false, "errors must sit under an app segment"},
395 {"apps/ab12/web/errors", opWrite, false, "the bare family segment is not a chunk"},
396 {"apps/ab12/web/errors/", opWrite, false, "nor with a slash"},
397 {"apps/ab12/web/other/box-a/x.jsonl.gz", opWrite, false, "the family list is exact: logs, errors, analytics"},
398 {"apps/ab12/web/channels/errors/stable.json", opWrite, false, "\"errors\" deeper in the key grants nothing"},
399 {"apps/ab12/web/errorsx/box-a/x.jsonl.gz", opWrite, false, "the segment is errors/, not a prefix of a longer name"},
400```
401
402- [ ] **Step 2: Run to verify the new rows fail**
403
404Run: `go test ./internal/edge/ -run TestStoreDoorACLMatrix -count=1`
405Expected: FAIL on the `errors` and `analytics` write rows (refused today).
406
407- [ ] **Step 3: Implement**
408
409Replace `aclWriteAppLogs` with:
410
411```go
412// chunkFamilies is the allow-list of app subtrees a box may write: the
413// chunk and day-file families it produces about its own tenant's apps
414// (applog.Family). Exact segment names — "logs" is not "logsx".
415var chunkFamilies = map[string]bool{"logs": true, "errors": true, "analytics": true}
416
417// aclWriteAppFamily implements aclWrite's second scope: exactly
418// apps/<sqid>/<app>/<family>/<something>, with <app> a single non-empty
419// segment, <family> one of chunkFamilies, and at least one key component
420// below the family. Anything else — a shorter key, a second path shape,
421// a family name as the app segment — is refused, so the grant can never be
422// widened by a key that merely contains the word.
423func aclWriteAppFamily(sqid, key string) bool {
424 if sqid == "" {
425 return false
426 }
427 rest, ok := strings.CutPrefix(key, "apps/"+sqid+"/")
428 if !ok {
429 return false
430 }
431 app, tail, ok := strings.Cut(rest, "/")
432 if !ok || app == "" {
433 return false
434 }
435 fam, below, ok := strings.Cut(tail, "/")
436 return ok && chunkFamilies[fam] && below != ""
437}
438```
439
440Update `aclWrite` to call `aclWriteAppFamily`, and update the doc comment above `aclWrite` (the "logs scope is SEGMENT-EXACT" paragraph) to say "family scope" and name the three families.
441
442- [ ] **Step 4: Run the matrix and the package**
443
444Run: `go test ./internal/edge/ -run TestStoreDoor -count=1`
445Expected: PASS.
446
447- [ ] **Step 5: Commit**
448
449```bash
450git add internal/edge/storedoor.go internal/edge/storedoor_test.go
451git commit -m "store door: the app write grant is a family allow-list (logs, errors, analytics)
452
453Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
454```
455
456---
457
458### Task 3: `internal/apperr` record, sanitisation, fingerprint
459
460**Files:**
461- Create: `internal/apperr/record.go`, `internal/apperr/sanitize.go`, `internal/apperr/fingerprint.go`
462- Test: `internal/apperr/sanitize_test.go`, `internal/apperr/fingerprint_test.go`
463
464**Interfaces:**
465- Produces:
466 ```go
467 const (SourceLog = "log"; SourceEdge = "edge"; SourceServer = "server"; SourceBrowser = "browser")
468 const (KindHTTP = "http"; KindLog = "log")
469 const FPVersion = 1
470 type Record struct { TS time.Time; Box, Host, Source, Kind, Name, Level, Path string; Status int; Release, Stack string; Fields map[string]string; FP string; FPV int; Count int; First, Last time.Time; Seq uint64 }
471 func FromLog(r applog.Record) Record
472 func Sanitize(s string) string
473 func SanitizePath(p string) string
474 func Normalize(s string) string
475 func TopFrame(stack string) string
476 func Fingerprint(source, kind, name, stack string) string
477 ```
478
479- [ ] **Step 1: Write failing tests**
480
481`internal/apperr/sanitize_test.go`:
482
483```go
484package apperr
485
486import "testing"
487
488func TestSanitize(t *testing.T) {
489 cases := map[string]string{
490 "user paul@tito.io not found": "user <email> not found",
491 "Authorization: Bearer abc.def-ghi": "Authorization: Bearer <redacted>",
492 "basic dXNlcjpwYXNz failed": "basic <redacted> failed",
493 "GET /x?token=abc123&y=1": "GET /x?token=<redacted>&y=1",
494 `{"password": "hunter2", "n": 3}`: `{"password": "<redacted>", "n": 3}`,
495 "api_key=sk-live-1234 ok": "api_key=<redacted> ok",
496 "digest 0123456789abcdef0123456789abcdef done": "digest <hex> done",
497 "b64 QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo0MTIzNA== ok": "b64 <hex> ok",
498 "short 4123 stays": "short 4123 stays",
499 "nothing here": "nothing here",
500 }
501 for in, want := range cases {
502 if got := Sanitize(in); got != want {
503 t.Errorf("Sanitize(%q) = %q, want %q", in, got, want)
504 }
505 }
506}
507
508func TestSanitizePath(t *testing.T) {
509 cases := map[string]string{
510 "/reset/8f3a9c1e2b4d6f708a9b0c1d2e3f4a5b?x=1#frag": "/reset/:token",
511 "/users/paul@tito.io/edit": "/users/:token/edit",
512 "/items/550e8400-e29b-41d4-a716-446655440000": "/items/:token",
513 "/s/QUJDREVGR0hJSktMTU5PUFFSU1RV": "/s/:token",
514 "/blog/2024-review": "/blog/2024-review",
515 "/api/v2/orders/42": "/api/v2/orders/42",
516 "/": "/",
517 "": "/",
518 }
519 for in, want := range cases {
520 if got := SanitizePath(in); got != want {
521 t.Errorf("SanitizePath(%q) = %q, want %q", in, got, want)
522 }
523 }
524}
525```
526
527`internal/apperr/fingerprint_test.go`:
528
529```go
530package apperr
531
532import "testing"
533
534func TestNormalize(t *testing.T) {
535 cases := map[string]string{
536 "user 4123 not found": "user <n> not found",
537 "user 9911 not found": "user <n> not found",
538 "HTTP 503 from upstream": "HTTP 503 from upstream",
539 "retry 3 failed": "retry 3 failed",
540 "v2 handler panicked": "v2 handler panicked",
541 "E404 for id 550e8400-e29b-41d4-a716-446655440000": "E404 for id <uuid>",
542 `missing key "stripe_secret"`: `missing key "?"`,
543 "trace 0123456789abcdef bad": "trace <hex> bad",
544 "deadbeef cafe": "<hex> cafe",
545 }
546 for in, want := range cases {
547 if got := Normalize(in); got != want {
548 t.Errorf("Normalize(%q) = %q, want %q", in, got, want)
549 }
550 }
551}
552
553func TestTopFrame(t *testing.T) {
554 cases := map[string]string{
555 "TypeError: x\n at render (https://app.example/assets/main.3f2a9c1b.js:12:34)\n at y": "at render (https://app.example/assets/main.js)",
556 "\n app/models/user.rb:42:in `find'\n app/x.rb:1": "app/models/user.rb:in `find'",
557 "": "",
558 "single line only": "single line only",
559 }
560 for in, want := range cases {
561 if got := TopFrame(in); got != want {
562 t.Errorf("TopFrame(%q) = %q, want %q", in, got, want)
563 }
564 }
565}
566
567func TestFingerprint(t *testing.T) {
568 a := Fingerprint(SourceLog, KindLog, "user 4123 not found", "")
569 b := Fingerprint(SourceLog, KindLog, "user 9911 not found", "")
570 c := Fingerprint(SourceLog, KindLog, "user gone", "")
571 d := Fingerprint(SourceEdge, KindLog, "user 4123 not found", "")
572 if a != b {
573 t.Fatalf("digit runs must group: %s != %s", a, b)
574 }
575 if a == c || a == d {
576 t.Fatalf("different names or sources must not group: %s %s %s", a, c, d)
577 }
578 if len(a) != 16 {
579 t.Fatalf("fingerprint length %d, want 16 hex", len(a))
580 }
581 // Length-delimited: ("ab","c") and ("a","bc") differ.
582 if Fingerprint("ab", "c", "n", "") == Fingerprint("a", "bc", "n", "") {
583 t.Fatal("components must be length-delimited")
584 }
585 // A redeploy's bundle hash and line numbers do not split a browser group.
586 s1 := "TypeError: x\n at render (https://a/main.3f2a9c1b.js:12:34)"
587 s2 := "TypeError: x\n at render (https://a/main.9b8c7d6e.js:99:1)"
588 if Fingerprint(SourceBrowser, "TypeError", "x", s1) != Fingerprint(SourceBrowser, "TypeError", "x", s2) {
589 t.Fatal("frame normalisation must drop bundle hash and line:col")
590 }
591}
592```
593
594- [ ] **Step 2: Run to verify failure**
595
596Run: `go test ./internal/apperr/ -count=1`
597Expected: FAIL to compile (package does not exist).
598
599- [ ] **Step 3: Write `record.go`**
600
601```go
602// Package apperr is the pure half of the Errors tab (spec
603// 2026-09-07-errors-and-analytics-design.md §3.1–3.2): the wire record,
604// sanitisation, fingerprinting, the box-side coalescing sink, the chunk
605// read and the grouping fold. No I/O and no clock of its own — the same
606// split internal/applog keeps: internal/host ships, internal/edge and the
607// activator produce, internal/console reads.
608package apperr
609
610import (
611 "time"
612
613 "github.com/carlosframework/platform/internal/applog"
614)
615
616// Sources an error can come from (spec §3.1). log is never stored in the
617// errors/ family — it is derived on read from logs/ (§3.2).
618const (
619 SourceLog = "log"
620 SourceEdge = "edge"
621 SourceServer = "server"
622 SourceBrowser = "browser"
623)
624
625// Kinds the platform itself produces. A server or browser caller sets its
626// own kind (an exception class, typically).
627const (
628 KindHTTP = "http" // an edge-observed 5xx
629 KindLog = "log" // a log line at or above the configured level
630)
631
632// FPVersion stamps the fingerprint algorithm so it can change without
633// merging old groups into new ones.
634const FPVersion = 1
635
636// Record is one coalesced error on the wire — one JSONL line inside an
637// errors/ chunk. Additive shape (AGENTS.md rule): every field but TS,
638// Source, Name and FP is omitempty, unknown keys are ignored on read.
639// Count, First and Last are the coalescing state: one record stands for
640// Count occurrences of FP on Host under Release between First and Last;
641// the other fields are the first occurrence's sample.
642type Record struct {
643 TS time.Time `json:"ts"`
644 Box string `json:"box,omitempty"`
645 Host string `json:"host,omitempty"`
646 Source string `json:"source"`
647 Kind string `json:"kind,omitempty"`
648 Name string `json:"name"`
649 Level string `json:"level,omitempty"` // log source: warn | error
650 Path string `json:"path,omitempty"`
651 Status int `json:"status,omitempty"`
652 Release string `json:"release,omitempty"`
653 Stack string `json:"stack,omitempty"`
654 Fields map[string]string `json:"fields,omitempty"`
655 FP string `json:"fp"`
656 FPV int `json:"fpv,omitempty"`
657 Count int `json:"count,omitempty"`
658 First time.Time `json:"first,omitempty"`
659 Last time.Time `json:"last,omitempty"`
660 Seq uint64 `json:"seq,omitempty"`
661}
662
663// MaxStack bounds a stored stack (spec §3.1).
664const MaxStack = 8 << 10
665
666// FromLog derives the log-source record for one applog line (spec §3.2:
667// read-derived, never stored twice). Fingerprinted on the normalised
668// message alone — a log line has no frame.
669func FromLog(r applog.Record) Record {
670 name := Sanitize(r.Msg)
671 return Record{
672 TS: r.TS, Box: r.Box, Host: r.Host,
673 Source: SourceLog, Kind: KindLog, Name: name, Level: r.Level,
674 Fields: r.Fields,
675 FP: Fingerprint(SourceLog, KindLog, name, ""), FPV: FPVersion,
676 Count: 1, First: r.TS, Last: r.TS, Seq: r.Seq,
677 }
678}
679```
680
681- [ ] **Step 4: Write `sanitize.go`**
682
683```go
684package apperr
685
686import (
687 "regexp"
688 "strings"
689)
690
691// Sanitisation is a floor, not a guarantee (spec §3.1): the patterns below
692// catch what secrets look like in practice — addresses, bearer tokens,
693// key=value credentials, long opaque runs — before anything is buffered.
694var (
695 reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
696 reAuth = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+`)
697 // key=value and "key": "value" shapes, value up to the next delimiter.
698 reKV = regexp.MustCompile(`(?i)\b(token|key|secret|password|passwd|pwd|api_key|apikey|access_token|auth)("?\s*[=:]\s*"?)[^\s&"',;]+`)
699 // A hex or base64 run of 32+ characters: a digest, a session id, a key.
700 reLong = regexp.MustCompile(`\b[A-Za-z0-9+/_-]{32,}={0,2}`)
701)
702
703// Sanitize redacts secrets in free text: messages, stacks, field values.
704func Sanitize(s string) string {
705 s = reEmail.ReplaceAllString(s, "<email>")
706 s = reAuth.ReplaceAllStringFunc(s, func(m string) string {
707 word := m[:strings.IndexAny(m, " \t")]
708 return word + " <redacted>"
709 })
710 s = reKV.ReplaceAllString(s, "${1}${2}<redacted>")
711 s = reLong.ReplaceAllString(s, "<hex>")
712 return s
713}
714
715var (
716 reUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
717 reHex16 = regexp.MustCompile(`^[0-9a-fA-F]{16,}$`)
718 reB64ish = regexp.MustCompile(`^[A-Za-z0-9+/_-]{20,}={0,2}$`)
719 reEmailNW = regexp.MustCompile(`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$`)
720)
721
722// SanitizePath strips query and fragment and collapses token-shaped
723// segments to ":token" (spec §3.3): a UUID, a hex run of 16+, a base64-
724// looking run of 20+, or an email. "/blog/2024-review" and "/orders/42"
725// survive; "/reset/8f3a…" does not. Empty is "/".
726func SanitizePath(p string) string {
727 if i := strings.IndexAny(p, "?#"); i >= 0 {
728 p = p[:i]
729 }
730 if p == "" {
731 return "/"
732 }
733 segs := strings.Split(p, "/")
734 for i, s := range segs {
735 if s == "" {
736 continue
737 }
738 if reUUID.MatchString(s) || reHex16.MatchString(s) || reEmailNW.MatchString(s) ||
739 (reB64ish.MatchString(s) && strings.ContainsAny(s, "0123456789") && strings.ContainsAny(s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")) {
740 segs[i] = ":token"
741 }
742 }
743 return strings.Join(segs, "/")
744}
745```
746
747The base64 segment rule requires both a digit and an upper-case letter so that ordinary long slugs (`some-long-page-name-here`) survive; the tests pin `/s/QUJD…` as a token and `/blog/2024-review` as not.
748
749- [ ] **Step 5: Write `fingerprint.go`**
750
751```go
752package apperr
753
754import (
755 "crypto/sha256"
756 "encoding/hex"
757 "regexp"
758 "strconv"
759 "strings"
760)
761
762var (
763 reQuoted = regexp.MustCompile(`"[^"]*"|'[^']*'`)
764 reUUIDAny = regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`)
765 reHexRun = regexp.MustCompile(`\b[0-9a-fA-F]{8,}\b`)
766 reDigits4 = regexp.MustCompile(`\b\d{4,}\b`)
767 reLineCol = regexp.MustCompile(`:\d+(:\d+)?`)
768 reBundleJS = regexp.MustCompile(`\.[0-9a-fA-F]{8,}\.js`)
769)
770
771// Normalize folds the parts of a message that vary per occurrence into
772// placeholders on token boundaries (spec §3.2): quoted strings, UUIDs, hex
773// runs of 8+, digit runs of 4+. Tokens of 1–3 digits and mixed tokens
774// (v2, E404) survive, so "HTTP 503" and "retry 3" still separate groups
775// while "user 4123" and "user 9911" do not. Small ids under 1,000 will
776// split groups; that is the stated trade-off, and a caller-supplied FP
777// overrides it.
778func Normalize(s string) string {
779 s = reQuoted.ReplaceAllString(s, `"?"`)
780 s = reUUIDAny.ReplaceAllString(s, "<uuid>")
781 s = reHexRun.ReplaceAllStringFunc(s, func(m string) string {
782 if _, err := strconv.ParseUint(m, 10, 64); err == nil {
783 return "<n>" // all digits: a digit run, not a hex run
784 }
785 return "<hex>"
786 })
787 s = reDigits4.ReplaceAllString(s, "<n>")
788 return s
789}
790
791// TopFrame is the first non-empty line of a stack with its line, column
792// and bundle hash removed, so a redeploy does not split every group.
793func TopFrame(stack string) string {
794 for _, line := range strings.Split(stack, "\n") {
795 line = strings.TrimSpace(line)
796 if line == "" {
797 continue
798 }
799 line = reBundleJS.ReplaceAllString(line, ".js")
800 line = reLineCol.ReplaceAllString(line, "")
801 return line
802 }
803 return ""
804}
805
806// Fingerprint groups occurrences: sha256 over length-delimited (source,
807// kind, Normalize(name), Normalize(TopFrame(stack)))[:16] as hex. For a
808// stack with a message line first (JS "TypeError: x\n at …"), the top
809// frame is the second line; callers pass the message as name and the
810// whole stack here.
811func Fingerprint(source, kind, name, stack string) string {
812 h := sha256.New()
813 frame := TopFrame(stack)
814 if strings.HasPrefix(frame, name) || strings.Contains(frame, ": "+name) {
815 // The stack's first line restates the message; take the next.
816 rest := strings.TrimSpace(stack)
817 if i := strings.IndexByte(rest, '\n'); i >= 0 {
818 frame = TopFrame(rest[i+1:])
819 }
820 }
821 for _, part := range []string{source, kind, Normalize(name), Normalize(frame)} {
822 h.Write([]byte(strconv.Itoa(len(part))))
823 h.Write([]byte{':'})
824 h.Write([]byte(part))
825 }
826 return hex.EncodeToString(h.Sum(nil))[:16]
827}
828```
829
830Note for `TestTopFrame`'s first case: the expected value is the message line (`TypeError: x`) is NOT the frame — adjust the test's first case to expect `TypeError: x` from `TopFrame` (it is the first non-empty line), and rely on `TestFingerprint`'s last assertion for the message-skipping behaviour. Concretely, change that case's expected string to `"TypeError: x"`.
831
832- [ ] **Step 6: Run tests, iterate until green**
833
834Run: `go test ./internal/apperr/ -count=1 -v`
835Expected: PASS. If a regex case disagrees with a test, fix the regex, not the test, unless the test contradicts the spec's stated rule.
836
837- [ ] **Step 7: Commit**
838
839```bash
840git add internal/apperr
841git commit -m "apperr: error record, sanitisation and fingerprinting (spec §3.1–3.2)
842
843Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
844```
845
846---
847
848### Task 4: `internal/apperr` coalescing sink and bucket read
849
850**Files:**
851- Create: `internal/apperr/sink.go`, `internal/apperr/read.go`
852- Test: `internal/apperr/sink_test.go`, `internal/apperr/read_test.go`
853
854**Interfaces:**
855- Produces:
856 ```go
857 const MaxGroupsPerAppHour = 1000
858 const OverflowFP = "_overflow"
859 func NewSink(box string, now func() time.Time) *Sink
860 func (s *Sink) Add(acct, app string, r Record) // sanitises, fingerprints if FP == "", coalesces
861 func (s *Sink) Drain() map[applog.AppKey][]Record
862 func (s *Sink) Known() []applog.AppKey
863 func Prefix(acct, app string) string // applog.FamilyPrefix(FamilyErrors, …)
864 func ChunkKey(acct, app, box, hour string, seq uint64, size int) string
865 func EncodeChunk(recs []Record) ([]byte, error)
866 func DecodeChunk(b []byte) ([]Record, error)
867 type Filter struct { Since, Until time.Time; Host, Source, Release, FP string }
868 func (f Filter) Match(r Record) bool // [First,Last] overlaps [Since,Until]
869 func ReadWindow(ctx, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error)
870 func MergeRecords(slices ...[]Record) []Record // by (Last, Box, Seq)
871 ```
872
873- [ ] **Step 1: Write failing tests**
874
875`internal/apperr/sink_test.go`:
876
877```go
878package apperr
879
880import (
881 "fmt"
882 "testing"
883 "time"
884
885 "github.com/carlosframework/platform/internal/applog"
886)
887
888func TestSinkCoalescesByFingerprintHostRelease(t *testing.T) {
889 now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
890 clock := now
891 s := NewSink("box1", func() time.Time { return clock })
892 for i := 0; i < 10000; i++ {
893 clock = now.Add(time.Duration(i) * time.Millisecond)
894 s.Add("bab", "web", Record{Host: "h1", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Release: "v1"})
895 }
896 s.Add("bab", "web", Record{Host: "h2", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Status: 500, Release: "v1"})
897 out := s.Drain()
898 recs := out[applog.AppKey{Acct: "bab", App: "web"}]
899 if len(recs) != 2 {
900 t.Fatalf("records = %d, want 2 (one per host)", len(recs))
901 }
902 var h1 Record
903 for _, r := range recs {
904 if r.Host == "h1" {
905 h1 = r
906 }
907 }
908 if h1.Count != 10000 || !h1.First.Equal(now) || !h1.Last.Equal(now.Add(9999*time.Millisecond)) {
909 t.Fatalf("h1 = count %d first %v last %v", h1.Count, h1.First, h1.Last)
910 }
911 if h1.FP == "" || h1.FPV != FPVersion || h1.Box != "box1" || h1.Seq == 0 {
912 t.Fatalf("stamping: %+v", h1)
913 }
914 if len(s.Drain()) != 0 {
915 t.Fatal("second drain must be empty")
916 }
917}
918
919func TestSinkCapsGroupsPerAppPerHour(t *testing.T) {
920 now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
921 s := NewSink("box1", func() time.Time { return now })
922 for i := 0; i < MaxGroupsPerAppHour+5; i++ {
923 s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: fmt.Sprintf("500 GET /p%d-x", i), FP: fmt.Sprintf("fp%d", i)})
924 }
925 s.Add("bab", "other", Record{Source: SourceEdge, Kind: KindHTTP, Name: "unaffected", FP: "z"})
926 out := s.Drain()
927 web := out[applog.AppKey{Acct: "bab", App: "web"}]
928 if len(web) != MaxGroupsPerAppHour+1 {
929 t.Fatalf("web records = %d, want cap+1 (the overflow row)", len(web))
930 }
931 var over *Record
932 for i := range web {
933 if web[i].FP == OverflowFP {
934 over = &web[i]
935 }
936 }
937 if over == nil || over.Count != 5 || over.Name != "500 GET /p1000-x" {
938 t.Fatalf("overflow = %+v", over)
939 }
940 if len(out[applog.AppKey{Acct: "bab", App: "other"}]) != 1 {
941 t.Fatal("a neighbour app must not be suppressed")
942 }
943 // The cap counts per hour: the next hour admits new groups again.
944 now = now.Add(time.Hour)
945 s.Add("bab", "web", Record{Source: SourceEdge, Kind: KindHTTP, Name: "fresh", FP: "fresh"})
946 if recs := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}]; len(recs) != 1 || recs[0].FP != "fresh" {
947 t.Fatalf("next hour = %+v", recs)
948 }
949}
950
951func TestSinkSanitisesAndFingerprintsOnAdd(t *testing.T) {
952 now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
953 s := NewSink("box1", func() time.Time { return now })
954 long := make([]byte, MaxStack+100)
955 for i := range long {
956 long[i] = 'x'
957 }
958 s.Add("bab", "web", Record{Source: SourceServer, Kind: "RuntimeError", Name: "mail to paul@tito.io bounced",
959 Path: "/reset/8f3a9c1e2b4d6f708a9b0c1d2e3f4a5b?t=1", Stack: string(long),
960 Fields: map[string]string{"user": "paul@tito.io", "k": "v"}})
961 r := s.Drain()[applog.AppKey{Acct: "bab", App: "web"}][0]
962 if r.Name != "mail to <email> bounced" || r.Path != "/reset/:token" || r.Fields["user"] != "<email>" || r.Fields["k"] != "v" {
963 t.Fatalf("sanitised = %+v", r)
964 }
965 if len(r.Stack) != MaxStack {
966 t.Fatalf("stack len %d, want %d", len(r.Stack), MaxStack)
967 }
968 if r.FP != Fingerprint(SourceServer, "RuntimeError", "mail to <email> bounced", r.Stack) || r.FPV != FPVersion {
969 t.Fatalf("fp = %q", r.FP)
970 }
971 if r.Count != 1 || !r.First.Equal(now) || !r.Last.Equal(now) || !r.TS.Equal(now) {
972 t.Fatalf("stamps = %+v", r)
973 }
974}
975```
976
977`internal/apperr/read_test.go`:
978
979```go
980package apperr
981
982import (
983 "context"
984 "testing"
985 "time"
986
987 "github.com/carlosframework/platform/internal/release"
988)
989
990func TestReadWindowMergesAndFilters(t *testing.T) {
991 ctx := context.Background()
992 s := release.NewDirStore(t.TempDir())
993 now := time.Date(2026, 9, 7, 10, 30, 0, 0, time.UTC)
994 put := func(box string, recs []Record) {
995 body, err := EncodeChunk(recs)
996 if err != nil {
997 t.Fatal(err)
998 }
999 if _, err := s.Put(ctx, ChunkKey("bab", "web", box, HourOf(recs[0].TS), recs[0].Seq, len(body)), body, ""); err != nil {
1000 t.Fatal(err)
1001 }
1002 }
1003 put("box1", []Record{
1004 {TS: now, Box: "box1", Host: "h1", Source: SourceEdge, Name: "a", FP: "fa", Count: 3, First: now.Add(-4 * time.Minute), Last: now, Seq: 1},
1005 {TS: now, Box: "box1", Host: "h1", Source: SourceServer, Name: "b", FP: "fb", Count: 1, First: now, Last: now, Seq: 2},
1006 })
1007 put("box2", []Record{
1008 {TS: now.Add(-3 * time.Hour), Box: "box2", Host: "h2", Source: SourceEdge, Name: "old", FP: "fo", Count: 1, First: now.Add(-3 * time.Hour), Last: now.Add(-3 * time.Hour), Seq: 1},
1009 })
1010 recs, trimmed, err := ReadWindow(ctx, s, "bab", "web", Filter{Since: now.Add(-time.Hour)}, 100)
1011 if err != nil || trimmed {
1012 t.Fatalf("read: %v %v", err, trimmed)
1013 }
1014 if len(recs) != 2 || recs[0].FP != "fa" || recs[1].FP != "fb" {
1015 t.Fatalf("window = %+v", recs)
1016 }
1017 // A record whose [First, Last] overlaps the window is in, whole.
1018 recs, _, _ = ReadWindow(ctx, s, "bab", "web", Filter{Since: now.Add(-2 * time.Minute), Until: now.Add(-time.Minute)}, 100)
1019 if len(recs) != 1 || recs[0].FP != "fa" || recs[0].Count != 3 {
1020 t.Fatalf("overlap = %+v", recs)
1021 }
1022 recs, _, _ = ReadWindow(ctx, s, "bab", "web", Filter{Since: now.Add(-time.Hour), Source: SourceServer}, 100)
1023 if len(recs) != 1 || recs[0].FP != "fb" {
1024 t.Fatalf("source filter = %+v", recs)
1025 }
1026 recs, _, _ = ReadWindow(ctx, s, "bab", "web", Filter{Since: now.Add(-4 * time.Hour), Host: "h2"}, 100)
1027 if len(recs) != 1 || recs[0].FP != "fo" {
1028 t.Fatalf("host filter = %+v", recs)
1029 }
1030}
1031```
1032
1033- [ ] **Step 2: Run to verify failure**
1034
1035Run: `go test ./internal/apperr/ -count=1`
1036Expected: FAIL to compile.
1037
1038- [ ] **Step 3: Write `sink.go`**
1039
1040```go
1041package apperr
1042
1043import (
1044 "sync"
1045 "time"
1046
1047 "github.com/carlosframework/platform/internal/applog"
1048)
1049
1050// MaxGroupsPerAppHour caps coalesced records per app per UTC hour, counting
1051// (fp, host, release) combinations (spec §3.2). Per app, not per box: one
1052// tenant's storm must not suppress a neighbour's.
1053const MaxGroupsPerAppHour = 1000
1054
1055// OverflowFP names the one row the excess beyond the cap folds into. Its
1056// Count is occurrences, and the tab labels it as such.
1057const OverflowFP = "_overflow"
1058
1059// Sink coalesces errors between ship-loop flushes: per app, one record per
1060// (fp, host, release) carrying count/first/last and the first sample. An
1061// error storm is the moment this must work — ten thousand identical panics
1062// in a minute become one record. Safe for concurrent use; the edge, the
1063// activator and the ship loop share one Sink per agent process.
1064type Sink struct {
1065 box string
1066 now func() time.Time
1067
1068 mu sync.Mutex
1069 apps map[applog.AppKey]*appBuf
1070 seq uint64
1071}
1072
1073type groupKey struct{ fp, host, release string }
1074
1075type appBuf struct {
1076 groups map[groupKey]*Record
1077 order []groupKey // insertion order, for a deterministic drain
1078 overflow *Record
1079 hour string // the UTC hour `admitted` counts for
1080 admitted int // distinct groups admitted this hour, cap counter
1081}
1082
1083// NewSink returns a Sink stamping records with box and timestamps from now.
1084// seq starts at UnixMilli for the reason applog.NewSink gives: chunk keys
1085// embed the first record's seq, and a restart must sort after last boot.
1086func NewSink(box string, now func() time.Time) *Sink {
1087 return &Sink{box: box, now: now, apps: make(map[applog.AppKey]*appBuf), seq: uint64(now().UnixMilli())}
1088}
1089
1090// Add buffers one occurrence for (acct, app). r is sanitised here (§3.1),
1091// fingerprinted when it carries no FP, and folded into its group.
1092func (s *Sink) Add(acct, app string, r Record) {
1093 now := s.now().UTC()
1094 r.Name = Sanitize(r.Name)
1095 r.Path = SanitizePath(r.Path)
1096 if r.Path == "/" && r.Status == 0 && r.Source != SourceEdge {
1097 r.Path = ""
1098 }
1099 if len(r.Stack) > MaxStack {
1100 r.Stack = r.Stack[:MaxStack]
1101 }
1102 r.Stack = Sanitize(r.Stack)
1103 if len(r.Fields) > 0 {
1104 f := make(map[string]string, len(r.Fields))
1105 for k, v := range r.Fields {
1106 f[k] = Sanitize(v)
1107 }
1108 r.Fields = f
1109 }
1110 if r.FP == "" {
1111 r.FP = Fingerprint(r.Source, r.Kind, r.Name, r.Stack)
1112 r.FPV = FPVersion
1113 }
1114 if len(r.FP) > 64 {
1115 r.FP = r.FP[:64]
1116 }
1117
1118 s.mu.Lock()
1119 defer s.mu.Unlock()
1120 b := s.apps[applog.AppKey{Acct: acct, App: app}]
1121 if b == nil {
1122 b = &appBuf{groups: make(map[groupKey]*Record)}
1123 s.apps[applog.AppKey{Acct: acct, App: app}] = b
1124 }
1125 if h := applog.HourOf(now); h != b.hour {
1126 b.hour, b.admitted = h, 0
1127 }
1128 k := groupKey{r.FP, r.Host, r.Release}
1129 if g := b.groups[k]; g != nil {
1130 g.Count++
1131 g.Last = now
1132 return
1133 }
1134 if b.admitted >= MaxGroupsPerAppHour {
1135 if b.overflow == nil {
1136 o := r
1137 o.FP = OverflowFP
1138 o.TS, o.First, o.Last, o.Count = now, now, now, 0
1139 o.Box = s.box
1140 s.seq++
1141 o.Seq = s.seq
1142 b.overflow = &o
1143 }
1144 b.overflow.Count++
1145 b.overflow.Last = now
1146 return
1147 }
1148 b.admitted++
1149 s.seq++
1150 r.TS, r.First, r.Last, r.Count = now, now, now, 1
1151 r.Box = s.box
1152 r.Seq = s.seq
1153 b.groups[k] = &r
1154 b.order = append(b.order, k)
1155}
1156
1157// Drain returns everything buffered, keyed by app, and resets the groups
1158// (the per-hour cap counter and seq persist — they are the hour's and the
1159// process's, not the batch's). Empty apps are omitted.
1160func (s *Sink) Drain() map[applog.AppKey][]Record {
1161 s.mu.Lock()
1162 defer s.mu.Unlock()
1163 out := make(map[applog.AppKey][]Record)
1164 for k, b := range s.apps {
1165 if len(b.order) == 0 && b.overflow == nil {
1166 continue
1167 }
1168 recs := make([]Record, 0, len(b.order)+1)
1169 for _, gk := range b.order {
1170 recs = append(recs, *b.groups[gk])
1171 }
1172 if b.overflow != nil {
1173 recs = append(recs, *b.overflow)
1174 }
1175 out[k] = recs
1176 b.groups = make(map[groupKey]*Record)
1177 b.order = nil
1178 b.overflow = nil
1179 }
1180 return out
1181}
1182
1183// Known lists every app this sink has ever buffered for — the prune
1184// worklist, as applog.Sink.Known is for logs.
1185func (s *Sink) Known() []applog.AppKey {
1186 s.mu.Lock()
1187 defer s.mu.Unlock()
1188 out := make([]applog.AppKey, 0, len(s.apps))
1189 for k := range s.apps {
1190 out = append(out, k)
1191 }
1192 return out
1193}
1194```
1195
1196- [ ] **Step 4: Write `read.go`**
1197
1198```go
1199package apperr
1200
1201import (
1202 "context"
1203 "sort"
1204 "time"
1205
1206 "github.com/carlosframework/platform/internal/applog"
1207 "github.com/carlosframework/platform/internal/release"
1208)
1209
1210// Prefix is the app's errors/ subtree (spec §3.2): a sibling family of
1211// logs/ with the same key shape below it.
1212func Prefix(acct, app string) string { return applog.FamilyPrefix(applog.FamilyErrors, acct, app) }
1213
1214// BoxPrefix is one box's subtree within an app's errors.
1215func BoxPrefix(acct, app, box string) string {
1216 return applog.FamilyBoxPrefix(applog.FamilyErrors, acct, app, box)
1217}
1218
1219// ChunkKey names one flushed errors chunk; see applog.ChunkKey.
1220func ChunkKey(acct, app, box, hour string, seq uint64, size int) string {
1221 return applog.FamilyChunkKey(applog.FamilyErrors, acct, app, box, hour, seq, size)
1222}
1223
1224// HourOf is applog.HourOf, re-exported so shippers need one import.
1225func HourOf(t time.Time) string { return applog.HourOf(t) }
1226
1227// EncodeChunk renders records as gzipped JSONL.
1228func EncodeChunk(recs []Record) ([]byte, error) { return applog.EncodeJSONL(recs) }
1229
1230// DecodeChunk parses a chunk, skipping malformed lines.
1231func DecodeChunk(b []byte) ([]Record, error) { return applog.DecodeJSONL[Record](b) }
1232
1233// Filter is the read-side match. A coalesced record belongs to a window
1234// when [First, Last] overlaps [Since, Until] and is counted whole (spec
1235// §3.2: a window edge is wrong by at most one flush interval).
1236type Filter struct {
1237 Since, Until time.Time
1238 Host string
1239 Source string
1240 Release string
1241 FP string
1242}
1243
1244// Match reports whether r survives f.
1245func (f Filter) Match(r Record) bool {
1246 last, first := r.Last, r.First
1247 if last.IsZero() {
1248 last = r.TS
1249 }
1250 if first.IsZero() {
1251 first = r.TS
1252 }
1253 if !f.Since.IsZero() && last.Before(f.Since) {
1254 return false
1255 }
1256 if !f.Until.IsZero() && first.After(f.Until) {
1257 return false
1258 }
1259 if f.Host != "" && r.Host != f.Host {
1260 return false
1261 }
1262 if f.Source != "" && r.Source != f.Source {
1263 return false
1264 }
1265 if f.Release != "" && r.Release != f.Release {
1266 return false
1267 }
1268 if f.FP != "" && r.FP != f.FP {
1269 return false
1270 }
1271 return true
1272}
1273
1274// ReadWindow assembles one app's errors/ records across boxes: every hour
1275// intersecting the window, filtered through f, merged by (Last, Box, Seq).
1276// collectCap bounds memory; past it the oldest go and the bool says so.
1277func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) {
1278 perBox, trimmed, err := applog.ReadChunks[Record](ctx, s, Prefix(acct, app), f.Since, f.Until, f.Match, collectCap)
1279 if err != nil {
1280 return nil, false, err
1281 }
1282 merged := MergeRecords(perBox...)
1283 if over := len(merged) - collectCap; over > 0 {
1284 merged = merged[over:]
1285 trimmed = true
1286 }
1287 return merged, trimmed, nil
1288}
1289
1290// MergeRecords folds per-source slices into one timeline ordered by
1291// (Last, Box, Seq), oldest first.
1292func MergeRecords(slices ...[]Record) []Record {
1293 var out []Record
1294 for _, s := range slices {
1295 out = append(out, s...)
1296 }
1297 sort.SliceStable(out, func(i, j int) bool {
1298 a, b := out[i], out[j]
1299 if !a.Last.Equal(b.Last) {
1300 return a.Last.Before(b.Last)
1301 }
1302 if a.Box != b.Box {
1303 return a.Box < b.Box
1304 }
1305 return a.Seq < b.Seq
1306 })
1307 return out
1308}
1309```
1310
1311Note on `ReadChunks` and the hour window: a record's chunk lives under the hour of its `TS` (the first occurrence); a window that starts after that hour but before `Last` will not list the hour. Since the flush interval is at most ten seconds plus the hour boundary, the reader widens `Since` by one hour when listing: in `ReadWindow`, pass `f.Since.Add(-time.Hour)` as the listing `since` (when non-zero) and keep `f.Match` as the exact test. Add that line and a comment saying why.
1312
1313- [ ] **Step 5: Run tests**
1314
1315Run: `go test ./internal/apperr/ -count=1 -v`
1316Expected: PASS.
1317
1318- [ ] **Step 6: Commit**
1319
1320```bash
1321git add internal/apperr
1322git commit -m "apperr: coalescing sink and errors/ chunk read (spec §3.2)
1323
1324Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
1325```
1326
1327---
1328
1329### Task 5: `internal/apperr` grouping for the tab
1330
1331**Files:**
1332- Create: `internal/apperr/group.go`
1333- Test: `internal/apperr/group_test.go`
1334
1335**Interfaces:**
1336- Produces:
1337 ```go
1338 type Group struct { FP, Source, Kind, Name, Level string; Count int; First, Last time.Time; Hosts, Releases []string; Sample Record }
1339 func GroupRecords(recs []Record) []Group // newest Last first
1340 ```
1341
1342- [ ] **Step 1: Failing test**
1343
1344```go
1345package apperr
1346
1347import (
1348 "testing"
1349 "time"
1350)
1351
1352func TestGroupRecords(t *testing.T) {
1353 now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
1354 recs := []Record{
1355 {FP: "a", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Host: "h1", Release: "v1", Count: 3, First: now.Add(-10 * time.Minute), Last: now.Add(-5 * time.Minute), Stack: "s1"},
1356 {FP: "a", Source: SourceEdge, Kind: KindHTTP, Name: "500 GET /x", Host: "h2", Release: "v2", Count: 2, First: now.Add(-20 * time.Minute), Last: now.Add(-1 * time.Minute)},
1357 {FP: "b", Source: SourceLog, Kind: KindLog, Name: "boom", Level: "error", Host: "h1", Count: 1, First: now.Add(-30 * time.Minute), Last: now.Add(-30 * time.Minute)},
1358 {FP: OverflowFP, Source: SourceEdge, Name: "500 GET /z", Host: "h1", Count: 40, First: now, Last: now},
1359 }
1360 gs := GroupRecords(recs)
1361 if len(gs) != 3 {
1362 t.Fatalf("groups = %d", len(gs))
1363 }
1364 if gs[0].FP != OverflowFP || gs[1].FP != "a" || gs[2].FP != "b" {
1365 t.Fatalf("order by Last desc: %s %s %s", gs[0].FP, gs[1].FP, gs[2].FP)
1366 }
1367 a := gs[1]
1368 if a.Count != 5 || !a.First.Equal(now.Add(-20*time.Minute)) || !a.Last.Equal(now.Add(-1*time.Minute)) {
1369 t.Fatalf("a = %+v", a)
1370 }
1371 if len(a.Hosts) != 2 || a.Hosts[0] != "h1" || a.Hosts[1] != "h2" || len(a.Releases) != 2 {
1372 t.Fatalf("a hosts/releases = %v %v", a.Hosts, a.Releases)
1373 }
1374 if a.Sample.Stack != "s1" {
1375 t.Fatal("sample must be the earliest record")
1376 }
1377}
1378```
1379
1380- [ ] **Step 2: Run, expect compile failure**
1381
1382- [ ] **Step 3: Implement**
1383
1384```go
1385package apperr
1386
1387import (
1388 "sort"
1389 "time"
1390)
1391
1392// Group is one row of the Errors tab: every record sharing a fingerprint
1393// in the window, summed (spec §3.6).
1394type Group struct {
1395 FP string `json:"fp"`
1396 Source string `json:"source"`
1397 Kind string `json:"kind,omitempty"`
1398 Name string `json:"name"`
1399 Level string `json:"level,omitempty"`
1400 Count int `json:"count"`
1401 First time.Time `json:"first"`
1402 Last time.Time `json:"last"`
1403 Hosts []string `json:"hosts"`
1404 Releases []string `json:"releases,omitempty"`
1405 Sample Record `json:"sample"`
1406}
1407
1408// GroupRecords folds records into groups by FP, newest Last first. The
1409// sample is the earliest record of the group (its stack and fields are
1410// the first occurrence's).
1411func GroupRecords(recs []Record) []Group {
1412 byFP := make(map[string]*Group)
1413 var order []string
1414 for _, r := range recs {
1415 first, last := r.First, r.Last
1416 if first.IsZero() {
1417 first = r.TS
1418 }
1419 if last.IsZero() {
1420 last = r.TS
1421 }
1422 count := r.Count
1423 if count == 0 {
1424 count = 1
1425 }
1426 g := byFP[r.FP]
1427 if g == nil {
1428 g = &Group{FP: r.FP, Source: r.Source, Kind: r.Kind, Name: r.Name, Level: r.Level, First: first, Last: last, Sample: r}
1429 byFP[r.FP] = g
1430 order = append(order, r.FP)
1431 }
1432 g.Count += count
1433 if first.Before(g.First) {
1434 g.First = first
1435 g.Sample = r
1436 }
1437 if last.After(g.Last) {
1438 g.Last = last
1439 }
1440 g.Hosts = addUnique(g.Hosts, r.Host)
1441 g.Releases = addUnique(g.Releases, r.Release)
1442 }
1443 out := make([]Group, 0, len(order))
1444 for _, fp := range order {
1445 g := byFP[fp]
1446 sort.Strings(g.Hosts)
1447 sort.Strings(g.Releases)
1448 out = append(out, *g)
1449 }
1450 sort.SliceStable(out, func(i, j int) bool { return out[i].Last.After(out[j].Last) })
1451 return out
1452}
1453
1454func addUnique(list []string, v string) []string {
1455 if v == "" {
1456 return list
1457 }
1458 for _, x := range list {
1459 if x == v {
1460 return list
1461 }
1462 }
1463 return append(list, v)
1464}
1465```
1466
1467- [ ] **Step 4: Run, expect PASS. Commit**
1468
1469```bash
1470git add internal/apperr/group.go internal/apperr/group_test.go
1471git commit -m "apperr: group records by fingerprint for the Errors tab
1472
1473Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
1474```
1475
1476---
1477
1478### Task 6: Ship the error sink from the agent; prune by family
1479
1480**Files:**
1481- Modify: `internal/host/logship.go` (`logshipPrune` → `chunkPrune`; remove `hourNameOf`)
1482- Create: `internal/host/errship.go`
1483- Modify: `internal/host/agent.go:234-237, 384`
1484- Test: `internal/host/logship_test.go` (update two callers), `internal/host/errship_test.go`
1485
1486**Interfaces:**
1487- Consumes: `apperr.Sink`, `apperr.EncodeChunk`, `apperr.ChunkKey`, `apperr.BoxPrefix`, `applog.FamilyBoxPrefix`, `applog.HourNameOfDir`.
1488- Produces: `chunkPrune(ctx, s release.Store, fam applog.Family, box string, k applog.AppKey, now time.Time)`; `errshipLoop(sink *apperr.Sink, box string)`; `errshipFlush(ctx, s, pending map[applog.AppKey][]apperr.Record)`; package-level `errSink *apperr.Sink` is NOT used — the sink is constructed in `runAgent` beside `logSink` and passed to the edge in the `RunWithActivatorReady` callback (Task 7 adds `EnableErrors`).
1489
1490- [ ] **Step 1: Refactor prune to take a family**
1491
1492In `logship.go`, rename `logshipPrune` to `chunkPrune` with signature `(ctx context.Context, s release.Store, fam applog.Family, box string, k applog.AppKey, now time.Time)`; inside, `prefix := applog.FamilyBoxPrefix(fam, k.Acct, k.App, box)` and `hourName := applog.HourNameOfDir(h.dir)`; delete `hourNameOf`. Update the call in `logshipLoop` to `chunkPrune(ctx, s, applog.FamilyLogs, box, k, time.Now())` and the two test callers in `logship_test.go` likewise. Update the doc comment: "for ONE app's ONE family on THIS box's subtree".
1493
1494Run: `go test ./internal/host/ -run Logship -count=1` — PASS.
1495
1496- [ ] **Step 2: Write the failing errship test**
1497
1498`internal/host/errship_test.go`:
1499
1500```go
1501package host
1502
1503import (
1504 "context"
1505 "testing"
1506 "time"
1507
1508 "github.com/carlosframework/platform/internal/apperr"
1509 "github.com/carlosframework/platform/internal/applog"
1510 "github.com/carlosframework/platform/internal/release"
1511)
1512
1513func TestErrshipFlushWritesOneChunkPerApp(t *testing.T) {
1514 s := release.NewDirStore(t.TempDir())
1515 now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
1516 sink := apperr.NewSink("box1", func() time.Time { return now })
1517 sink.Add("bab", "web", apperr.Record{Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 GET /x", Host: "h1"})
1518 sink.Add("bab", "web", apperr.Record{Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 GET /x", Host: "h1"})
1519 pending := map[applog.AppKey][]apperr.Record{}
1520 errshipGather(sink, pending)
1521 errshipFlush(context.Background(), s, pending)
1522 if len(pending) != 0 {
1523 t.Fatalf("pending after flush = %d", len(pending))
1524 }
1525 recs, _, err := apperr.ReadWindow(context.Background(), s, "bab", "web", apperr.Filter{Since: now.Add(-time.Minute)}, 100)
1526 if err != nil || len(recs) != 1 || recs[0].Count != 2 {
1527 t.Fatalf("read back = %+v, %v", recs, err)
1528 }
1529}
1530
1531func TestErrshipFlushHoldsOnFailure(t *testing.T) {
1532 pending := map[applog.AppKey][]apperr.Record{{Acct: "bab", App: "web"}: {{TS: time.Now(), Source: apperr.SourceEdge, Name: "x", FP: "f", Box: "box1", Seq: 1}}}
1533 errshipFlush(context.Background(), failingStore{}, pending)
1534 if len(pending) != 1 {
1535 t.Fatal("a failed flush must keep its records for the next round")
1536 }
1537}
1538```
1539
1540(`failingStore` already exists in `logship_test.go`.)
1541
1542- [ ] **Step 3: Write `errship.go`**
1543
1544```go
1545// The error shipping loop — the box-side pump of the Errors tab (spec
1546// 2026-09-07-errors-and-analytics-design.md §3.2), shaped exactly like
1547// logshipLoop: the edge and the activator coalesce into one apperr.Sink;
1548// every tick this drains it and writes one immutable chunk per app under
1549// apps/<sqid>/<app>/errors/<box>/<hour>/; on each hour boundary it prunes
1550// its own subtree with the log retention (§3.6: errors follow log
1551// retention, age and byte cap).
1552package host
1553
1554import (
1555 "context"
1556 "errors"
1557 "log"
1558 "os"
1559 "time"
1560
1561 "github.com/carlosframework/platform/internal/apperr"
1562 "github.com/carlosframework/platform/internal/applog"
1563 "github.com/carlosframework/platform/internal/release"
1564)
1565
1566const errshipTick = 10 * time.Second
1567
1568func errshipLoop(sink *apperr.Sink, box string) {
1569 defer func() {
1570 if r := recover(); r != nil {
1571 log.Printf("agent: errship loop panic, error shipping stopped for this process: %v", r)
1572 }
1573 }()
1574 ctx := context.Background()
1575 s, target, err := openStatusStore(ctx, os.Getenv)
1576 if err != nil {
1577 if errors.Is(err, errNoStatusStore) {
1578 log.Printf("agent: errship loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set")
1579 } else {
1580 log.Printf("agent: errship loop disabled: %v", err)
1581 }
1582 return
1583 }
1584 log.Printf("agent: errship loop armed (10s ticks), publishing to %s", target)
1585
1586 pending := make(map[applog.AppKey][]apperr.Record)
1587 pruned := ""
1588 ticker := time.NewTicker(errshipTick)
1589 defer ticker.Stop()
1590 for range ticker.C {
1591 errshipGather(sink, pending)
1592 errshipFlush(ctx, s, pending)
1593 if hour := applog.HourOf(time.Now()); hour != pruned {
1594 for _, k := range errshipKnownApps(pending, sink) {
1595 chunkPrune(ctx, s, applog.FamilyErrors, box, k, time.Now())
1596 }
1597 pruned = hour
1598 }
1599 }
1600}
1601
1602// errshipGather drains the sink into pending under the same per-app cap
1603// logship uses — oldest first.
1604func errshipGather(sink *apperr.Sink, pending map[applog.AppKey][]apperr.Record) {
1605 for k, recs := range sink.Drain() {
1606 merged := append(pending[k], recs...)
1607 if over := len(merged) - logshipMaxPending; over > 0 {
1608 merged = merged[over:]
1609 }
1610 pending[k] = merged
1611 }
1612}
1613
1614// errshipFlush writes one create-only chunk per pending app and forgets
1615// what landed; a failed app keeps its records for the next round.
1616func errshipFlush(ctx context.Context, s release.Store, pending map[applog.AppKey][]apperr.Record) {
1617 for k, recs := range pending {
1618 if len(recs) == 0 {
1619 delete(pending, k)
1620 continue
1621 }
1622 body, err := apperr.EncodeChunk(recs)
1623 if err != nil {
1624 log.Printf("agent: errship encode %s/%s: %v (dropping %d records)", k.Acct, k.App, err, len(recs))
1625 delete(pending, k)
1626 continue
1627 }
1628 key := apperr.ChunkKey(k.Acct, k.App, recs[0].Box, apperr.HourOf(recs[0].TS), recs[0].Seq, len(body))
1629 if _, err := s.Put(ctx, key, body, ""); err != nil {
1630 log.Printf("agent: errship flush %s/%s: %v (%d records held)", k.Acct, k.App, err, len(recs))
1631 continue
1632 }
1633 delete(pending, k)
1634 }
1635}
1636
1637func errshipKnownApps(pending map[applog.AppKey][]apperr.Record, sink *apperr.Sink) []applog.AppKey {
1638 seen := make(map[applog.AppKey]bool)
1639 var out []applog.AppKey
1640 for k := range pending {
1641 if !seen[k] {
1642 seen[k] = true
1643 out = append(out, k)
1644 }
1645 }
1646 for _, k := range sink.Known() {
1647 if !seen[k] {
1648 seen[k] = true
1649 out = append(out, k)
1650 }
1651 }
1652 return out
1653}
1654```
1655
1656- [ ] **Step 4: Wire in `agent.go`**
1657
1658Beside `logSink` (line 234-237):
1659
1660```go
1661 var logSink *applog.Sink
1662 var errSink *apperr.Sink
1663 if _, _, err := openStatusStore(context.Background(), os.Getenv); err == nil {
1664 logSink = applog.NewSink(status.Label(readMachineID()), time.Now)
1665 errSink = apperr.NewSink(status.Label(readMachineID()), time.Now)
1666 }
1667```
1668
1669Beside `go logshipLoop(...)` (line 384): `if errSink != nil { go errshipLoop(errSink, status.Label(readMachineID())) }` — match however `logshipLoop` is gated there (read the surrounding lines and mirror the condition exactly).
1670
1671In the `RunWithActivatorReady` callback after `e.EnableUsage(m)`: `if errSink != nil { e.EnableErrors(errSink) }` — `EnableErrors` arrives in Task 7; add this line in Task 7 instead if the build order matters. Order the tasks so Task 7 lands before this file compiles: do Task 7's edge change first if convenient, or add the `EnableErrors` call in Task 7. **Decision: add the `agent.go` callback line in Task 7**, and only the sink construction and loop here.
1672
1673- [ ] **Step 5: Test and commit**
1674
1675Run: `go build ./... && go test ./internal/host/ -count=1`
1676Expected: PASS.
1677
1678```bash
1679git add internal/host
1680git commit -m "host: ship coalesced errors like logs; prune by chunk family
1681
1682Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
1683```
1684
1685---
1686
1687### Task 7: The edge observes status and emits edge errors
1688
1689**Files:**
1690- Create: `internal/activator/answered.go`
1691- Modify: `internal/activator/activator.go:1298-1330` (three `http.Error` sites in `serve`)
1692- Modify: `internal/edge/usagemeter.go` (`countingWriter`)
1693- Modify: `internal/edge/edge.go` (`Edge` fields, `EnableErrors`, `ServeHTTP` at 1241-1250 and the hibernating branch)
1694- Modify: `internal/host/agent.go` (callback line)
1695- Test: `internal/activator/answered_test.go`, `internal/edge/usagemeter_test.go`, `internal/edge/edgeerrors_test.go`
1696
1697**Interfaces:**
1698- Produces: `activator.WithAnswerFlag(r *http.Request) (*http.Request, *bool)`; `activator.MarkAnswered(r *http.Request)`; `edge.ErrorSink interface{ Add(acct, app string, r apperr.Record) }`; `(*Edge).EnableErrors(ErrorSink)`; `countingWriter` gains `status int`, `ctype string`, `wroteHdr bool` and a `WriteHeader`.
1699
1700- [ ] **Step 1: Failing tests**
1701
1702`internal/activator/answered_test.go`:
1703
1704```go
1705package activator
1706
1707import (
1708 "net/http/httptest"
1709 "testing"
1710)
1711
1712func TestAnswerFlagRoundTrip(t *testing.T) {
1713 r := httptest.NewRequest("GET", "http://h/", nil)
1714 MarkAnswered(r) // no flag: a no-op, never a panic
1715 r, flag := WithAnswerFlag(r)
1716 if *flag {
1717 t.Fatal("flag must start false")
1718 }
1719 MarkAnswered(r)
1720 if !*flag {
1721 t.Fatal("MarkAnswered must set the flag the edge holds")
1722 }
1723}
1724```
1725
1726Append to `internal/edge/usagemeter_test.go`:
1727
1728```go
1729func TestCountingWriterObservesStatusAndType(t *testing.T) {
1730 var n int64
1731 rec := httptest.NewRecorder()
1732 w := &countingWriter{ResponseWriter: rec, n: &n}
1733 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1734 w.WriteHeader(103) // informational: forwarded, not latched
1735 w.WriteHeader(http.StatusBadGateway)
1736 w.WriteHeader(http.StatusOK) // superfluous second final status: ignored
1737 if w.status != http.StatusBadGateway || w.ctype != "text/html; charset=utf-8" {
1738 t.Fatalf("latched %d %q", w.status, w.ctype)
1739 }
1740 // Implicit 200 at first Write.
1741 rec2 := httptest.NewRecorder()
1742 w2 := &countingWriter{ResponseWriter: rec2, n: &n}
1743 w2.Write([]byte("x"))
1744 if w2.status != http.StatusOK {
1745 t.Fatalf("implicit status = %d", w2.status)
1746 }
1747 // Nothing written at all: status stays 0 (the caller treats it as a
1748 // bodiless 200 that is never a pageview and never an error).
1749 w3 := &countingWriter{ResponseWriter: httptest.NewRecorder(), n: &n}
1750 if w3.status != 0 {
1751 t.Fatal("untouched writer must report 0")
1752 }
1753}
1754```
1755
1756`internal/edge/edgeerrors_test.go`:
1757
1758```go
1759package edge
1760
1761import (
1762 "net/http"
1763 "net/http/httptest"
1764 "strings"
1765 "sync"
1766 "testing"
1767
1768 "github.com/carlosframework/platform/internal/activator"
1769 "github.com/carlosframework/platform/internal/apperr"
1770 "github.com/carlosframework/platform/internal/registry"
1771)
1772
1773type memErrSink struct {
1774 mu sync.Mutex
1775 recs []apperr.Record
1776 apps []string
1777}
1778
1779func (m *memErrSink) Add(acct, app string, r apperr.Record) {
1780 m.mu.Lock()
1781 defer m.mu.Unlock()
1782 m.recs = append(m.recs, r)
1783 m.apps = append(m.apps, acct+"/"+app)
1784}
1785
1786func TestEdgeEmitsErrorOnUpstream5xx(t *testing.T) {
1787 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1788 switch r.URL.Path {
1789 case "/boom":
1790 http.Error(w, "kaboom", http.StatusInternalServerError)
1791 case "/missing":
1792 http.NotFound(w, r)
1793 default:
1794 w.Header().Set("Content-Type", "text/html")
1795 w.Write([]byte("<p>ok</p>"))
1796 }
1797 }))
1798 t.Cleanup(backend.Close)
1799 reg := openReg(t)
1800 rt := registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web", Version: "v7"}
1801 if err := reg.Upsert(rt); err != nil {
1802 t.Fatal(err)
1803 }
1804 e, err := New(reg, nil)
1805 if err != nil {
1806 t.Fatal(err)
1807 }
1808 sink := &memErrSink{}
1809 e.EnableErrors(sink)
1810 srv := httptest.NewServer(e)
1811 t.Cleanup(srv.Close)
1812
1813 do := func(method, path string) int {
1814 req, _ := http.NewRequest(method, srv.URL+path, nil)
1815 req.Host = "app.example.com"
1816 resp, err := http.DefaultClient.Do(req)
1817 if err != nil {
1818 t.Fatal(err)
1819 }
1820 resp.Body.Close()
1821 return resp.StatusCode
1822 }
1823 if do("POST", "/boom?token=abc") != 500 || do("GET", "/missing") != 404 || do("GET", "/") != 200 {
1824 t.Fatal("upstream statuses must pass through unchanged")
1825 }
1826 if len(sink.recs) != 1 {
1827 t.Fatalf("errors emitted = %d, want 1 (the 500 only)", len(sink.recs))
1828 }
1829 r := sink.recs[0]
1830 if r.Source != apperr.SourceEdge || r.Kind != apperr.KindHTTP || r.Status != 500 || r.Host != "app.example.com" || r.Release != "v7" {
1831 t.Fatalf("record = %+v", r)
1832 }
1833 if r.Name != "500 POST /boom" || r.Path != "/boom" || strings.Contains(r.Name, "token") {
1834 t.Fatalf("name/path = %q %q", r.Name, r.Path)
1835 }
1836}
1837
1838// answering503 is an Activator that answers every request itself with the
1839// wake-path 503 the real one writes, and marks it as the activator's own.
1840type answering503 struct{}
1841
1842func (answering503) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
1843 activator.MarkAnswered(r)
1844 http.Error(w, "instance failed to wake", http.StatusServiceUnavailable)
1845 return true
1846}
1847func (answering503) RetireExcept(map[string]bool) {}
1848
1849func TestEdgeSkipsActivatorAnswered503(t *testing.T) {
1850 reg := openReg(t)
1851 rt := registry.Route{Host: "sleepy.example.com", Kind: "instance", Socket: t.TempDir() + "/x.sock", Status: "ready", Hibernate: true, App: "web"}
1852 if err := reg.Upsert(rt); err != nil {
1853 t.Fatal(err)
1854 }
1855 e, err := New(reg, answering503{})
1856 if err != nil {
1857 t.Fatal(err)
1858 }
1859 sink := &memErrSink{}
1860 e.EnableErrors(sink)
1861 rec := httptest.NewRecorder()
1862 req := httptest.NewRequest("GET", "http://sleepy.example.com/", nil)
1863 e.ServeHTTP(rec, req)
1864 if rec.Code != 503 {
1865 t.Fatalf("status = %d", rec.Code)
1866 }
1867 if len(sink.recs) != 0 {
1868 t.Fatalf("an activator-answered 503 is already a platform log error; got %d edge errors", len(sink.recs))
1869 }
1870}
1871```
1872
1873If `registry.Route` needs different fields for a hibernating instance route to load into the edge's map (check `TestHibernat*` tests in `edge_test.go` or `activator_e2e_test.go` for the minimal upsert they use), copy that shape.
1874
1875- [ ] **Step 2: Run to confirm failure**
1876
1877Run: `go test ./internal/activator/ -run TestAnswerFlag -count=1; go test ./internal/edge/ -run 'TestCountingWriterObserves|TestEdgeEmits|TestEdgeSkips' -count=1`
1878Expected: compile failures.
1879
1880- [ ] **Step 3: `internal/activator/answered.go`**
1881
1882```go
1883package activator
1884
1885import (
1886 "context"
1887 "net/http"
1888)
1889
1890// answeredKey carries the flag the edge attaches so it can tell a 503 the
1891// activator wrote on the wake path from one the upstream returned. The
1892// wake failure already reaches the member as a platform log error
1893// (emitEvent, logsink.go); the edge must not also file it as an edge
1894// error (spec 2026-09-07 §3.2, "exactly once").
1895type answeredKey struct{}
1896
1897// WithAnswerFlag returns r carrying a fresh flag and the flag itself.
1898func WithAnswerFlag(r *http.Request) (*http.Request, *bool) {
1899 f := new(bool)
1900 return r.WithContext(context.WithValue(r.Context(), answeredKey{}, f)), f
1901}
1902
1903// MarkAnswered sets the flag when r carries one; a no-op otherwise.
1904func MarkAnswered(r *http.Request) {
1905 if f, ok := r.Context().Value(answeredKey{}).(*bool); ok {
1906 *f = true
1907 }
1908}
1909```
1910
1911In `serve` (activator.go), call `MarkAnswered(r)` immediately before each of the four `http.Error(w, …, http.StatusServiceUnavailable)` calls (sidecar-asleep, waking elsewhere, waking, failed to wake).
1912
1913- [ ] **Step 4: `countingWriter` observes**
1914
1915In `usagemeter.go`:
1916
1917```go
1918// countingWriter counts response body bytes and OBSERVES the final status
1919// and Content-Type (spec 2026-09-07 §3.3): it is the one wrapper installed
1920// at a single site for every response shape — cache hit, plain proxy,
1921// hibernating, upgrade — so it is where the edge learns that an upstream
1922// answered 5xx (an edge error) or served HTML (a pageview, step 2). It
1923// sits below the compressor, so Content-Type is the synced value
1924// gzipWriter.syncHeader copied down before calling this WriteHeader.
1925// Deliberately does NOT implement io.ReaderFrom: io.Copy falls back to
1926// Write, which counts. Hijack/Flush forward via the interfaces the wrapped
1927// writer provides, mirroring recordingResponseWriter (edge.go).
1928type countingWriter struct {
1929 http.ResponseWriter
1930 n *int64
1931 status int // final status (>= 200); 0 until one is written
1932 ctype string // Content-Type at the final WriteHeader
1933 wroteHdr bool
1934}
1935
1936// WriteHeader latches the first final status. 1xx are forwarded and never
1937// latched (recordingResponseWriter.WriteHeader's reasoning).
1938func (c *countingWriter) WriteHeader(status int) {
1939 if status < 200 {
1940 c.ResponseWriter.WriteHeader(status)
1941 return
1942 }
1943 if !c.wroteHdr {
1944 c.wroteHdr = true
1945 c.status = status
1946 c.ctype = c.ResponseWriter.Header().Get("Content-Type")
1947 }
1948 c.ResponseWriter.WriteHeader(status)
1949}
1950
1951func (c *countingWriter) Write(p []byte) (int, error) {
1952 if !c.wroteHdr {
1953 c.wroteHdr = true
1954 c.status = http.StatusOK
1955 c.ctype = c.ResponseWriter.Header().Get("Content-Type")
1956 }
1957 n, err := c.ResponseWriter.Write(p)
1958 *c.n += int64(n)
1959 return n, err
1960}
1961```
1962
1963Keep `Hijack` and `Flush` as they are.
1964
1965- [ ] **Step 5: Edge wiring**
1966
1967In `edge.go`:
1968
1969Add near the `usageMeter` field:
1970
1971```go
1972 // errSink, when set, receives edge-observed errors (an upstream 5xx)
1973 // — spec 2026-09-07 §3.2. Nil disables emission; nothing else changes.
1974 errSink atomic.Pointer[errorSinkRef]
1975```
1976
1977and:
1978
1979```go
1980// ErrorSink is where the edge files an error it observed on the serving
1981// path. apperr.Sink satisfies it; the interface lives here so this package
1982// depends on the contract, not the buffer.
1983type ErrorSink interface {
1984 Add(acct, app string, r apperr.Record)
1985}
1986
1987type errorSinkRef struct{ ErrorSink }
1988
1989// EnableErrors wires the error sink in. Called once at startup by the
1990// agent; nil is a valid no-op.
1991func (e *Edge) EnableErrors(s ErrorSink) {
1992 if s == nil {
1993 e.errSink.Store(nil)
1994 return
1995 }
1996 e.errSink.Store(&errorSinkRef{s})
1997}
1998
1999// emitEdgeError files one edge-observed 5xx for rt. The path is sanitised
2000// (query and token segments off) before it becomes the record's name.
2001func (e *Edge) emitEdgeError(rt registry.Route, r *http.Request, status int) {
2002 ref := e.errSink.Load()
2003 if ref == nil {
2004 return
2005 }
2006 path := apperr.SanitizePath(r.URL.Path)
2007 ref.Add(release.RouteAccount(rt), rt.App, apperr.Record{
2008 Host: rt.Host,
2009 Source: apperr.SourceEdge,
2010 Kind: apperr.KindHTTP,
2011 Name: fmt.Sprintf("%d %s %s", status, r.Method, path),
2012 Path: path,
2013 Status: status,
2014 Release: rt.Version,
2015 })
2016}
2017```
2018
2019Replace the meter block at `edge.go:1241-1245` with:
2020
2021```go
2022 // The observing writer is installed UNCONDITIONALLY now — the usage
2023 // meter is one of its two consumers, the errors sink the other — at
2024 // the same single site, below everything that follows, so all three
2025 // dispatch shapes plus the upgrade path pass through it. Meter and
2026 // sink are each nil-safe. Status routes are neither billed nor observed.
2027 //
2028 // (hopped attribution paragraph: keep the existing comment text here.)
2029 var bytes int64
2030 ob := &countingWriter{ResponseWriter: w, n: &bytes}
2031 w = ob
2032 var answered *bool
2033 if up.hibernating {
2034 // The activator marks the wake-path 503s it writes itself; those
2035 // are already the member's platform log error and are not filed
2036 // again as edge errors (spec §3.2).
2037 r, answered = activator.WithAnswerFlag(r)
2038 }
2039 if up.route.Kind != "status" {
2040 defer func() {
2041 if m := e.usageMeter.Load(); m != nil {
2042 m.Record(up.route, 1, bytes, hopped)
2043 }
2044 if ob.status >= 500 && (answered == nil || !*answered) {
2045 e.emitEdgeError(up.route, r, ob.status)
2046 }
2047 }()
2048 }
2049```
2050
2051Add `"github.com/carlosframework/platform/internal/activator"` and `"github.com/carlosframework/platform/internal/apperr"` to the imports. Confirm no cycle: `go build ./...` (activator must not import edge; it does not today).
2052
2053Existing usage tests that construct the edge without a meter must still pass: `Record` is skipped when the meter is nil.
2054
2055- [ ] **Step 6: `agent.go` callback**
2056
2057After `e.EnableUsage(m)`:
2058
2059```go
2060 if errSink != nil {
2061 e.EnableErrors(errSink)
2062 }
2063```
2064
2065- [ ] **Step 7: Run edge, activator, host packages**
2066
2067Run: `go build ./... && go vet ./internal/edge/ ./internal/activator/ ./internal/host/ && go test ./internal/edge/ ./internal/activator/ ./internal/host/ -count=1`
2068Expected: PASS, including every pre-existing usage, compress and cache test.
2069
2070- [ ] **Step 8: Commit**
2071
2072```bash
2073git add internal/activator/answered.go internal/activator/activator.go internal/activator/answered_test.go internal/edge internal/host/agent.go
2074git commit -m "edge: the counting writer observes status; upstream 5xx become edge errors
2075
2076Installed unconditionally at the one site every response shape passes;
2077the activator marks its own wake-path 503s so they are filed once, as the
2078platform log error they already are (spec 2026-09-07 §3.2–3.3).
2079
2080Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2081```
2082
2083---
2084
2085### Task 8: `internal/telemetry` settings object
2086
2087**Files:**
2088- Create: `internal/telemetry/telemetry.go`
2089- Test: `internal/telemetry/telemetry_test.go`
2090
2091**Interfaces:**
2092- Produces:
2093 ```go
2094 const (LogLevelError = "error"; LogLevelWarn = "warn"; CountEdge = "edge"; CountOff = "off"; DoorOn = "on"; DoorOff = "off")
2095 type Analytics struct { Count string `json:"count,omitempty"`; Door string `json:"door,omitempty"` }
2096 type Errors struct { LogLevel string `json:"log_level,omitempty"` }
2097 type Settings struct { Analytics *Analytics `json:"analytics,omitempty"`; Errors *Errors `json:"errors,omitempty"` }
2098 func AccountKey(sqid string) string
2099 func AppKey(sqid, app string) string
2100 func Read(ctx, s release.Store, key string) (Settings, string, error) // etag; os.ErrNotExist → zero Settings, "", nil
2101 func Update(ctx, s release.Store, key string, fn func(*Settings)) error // read-modify-CAS-write, 3 attempts
2102 type Resolved struct { LogLevel, LogLevelFrom, Count, CountFrom, Door, DoorFrom string }
2103 func Resolve(ctx, s release.Store, sqid, app string) (Resolved, error)
2104 func ValidLogLevel(s string) bool
2105 ```
2106
2107- [ ] **Step 1: Failing test**
2108
2109```go
2110package telemetry
2111
2112import (
2113 "context"
2114 "testing"
2115
2116 "github.com/carlosframework/platform/internal/release"
2117)
2118
2119func TestResolveAppThenAccountThenDefault(t *testing.T) {
2120 ctx := context.Background()
2121 s := release.NewDirStore(t.TempDir())
2122 r, err := Resolve(ctx, s, "bab", "web")
2123 if err != nil {
2124 t.Fatal(err)
2125 }
2126 if r.LogLevel != LogLevelError || r.LogLevelFrom != "default" || r.Count != CountOff || r.CountFrom != "default" || r.Door != DoorOn {
2127 t.Fatalf("defaults = %+v", r)
2128 }
2129 if err := Update(ctx, s, AccountKey("bab"), func(st *Settings) {
2130 st.Errors = &Errors{LogLevel: LogLevelWarn}
2131 st.Analytics = &Analytics{Count: CountEdge}
2132 }); err != nil {
2133 t.Fatal(err)
2134 }
2135 r, _ = Resolve(ctx, s, "bab", "web")
2136 if r.LogLevel != LogLevelWarn || r.LogLevelFrom != "account" || r.Count != CountEdge || r.CountFrom != "account" || r.Door != DoorOn || r.DoorFrom != "default" {
2137 t.Fatalf("account level = %+v", r)
2138 }
2139 if err := Update(ctx, s, AppKey("bab", "web"), func(st *Settings) {
2140 st.Analytics = &Analytics{Count: CountOff}
2141 }); err != nil {
2142 t.Fatal(err)
2143 }
2144 r, _ = Resolve(ctx, s, "bab", "web")
2145 if r.LogLevel != LogLevelWarn || r.Count != CountOff || r.CountFrom != "app" {
2146 t.Fatalf("app override = %+v", r)
2147 }
2148 // Update preserves the other key.
2149 if err := Update(ctx, s, AppKey("bab", "web"), func(st *Settings) { st.Errors = &Errors{LogLevel: LogLevelError} }); err != nil {
2150 t.Fatal(err)
2151 }
2152 got, _, _ := Read(ctx, s, AppKey("bab", "web"))
2153 if got.Analytics == nil || got.Analytics.Count != CountOff || got.Errors == nil || got.Errors.LogLevel != LogLevelError {
2154 t.Fatalf("read back = %+v", got)
2155 }
2156}
2157```
2158
2159- [ ] **Step 2: Run, expect compile failure**
2160
2161- [ ] **Step 3: Implement**
2162
2163```go
2164// Package telemetry is the settings object behind the Errors and Analytics
2165// tabs (spec 2026-09-07-errors-and-analytics-design.md §3.3): one JSON
2166// shape at two levels — accounts/<sqid>/telemetry.json and
2167// apps/<sqid>/<app>/telemetry.json — resolved app → account → built-in
2168// default. Defaults are rulings (Paul, 2026-09-07): analytics off, the
2169// browser door on, the log level error.
2170package telemetry
2171
2172import (
2173 "context"
2174 "encoding/json"
2175 "errors"
2176 "fmt"
2177 "os"
2178
2179 "github.com/carlosframework/platform/internal/release"
2180)
2181
2182const (
2183 LogLevelError = "error"
2184 LogLevelWarn = "warn"
2185 CountEdge = "edge"
2186 CountOff = "off"
2187 DoorOn = "on"
2188 DoorOff = "off"
2189)
2190
2191// Analytics is the analytics half. Empty strings mean "not set here".
2192type Analytics struct {
2193 Count string `json:"count,omitempty"`
2194 Door string `json:"door,omitempty"`
2195}
2196
2197// Errors is the errors half.
2198type Errors struct {
2199 LogLevel string `json:"log_level,omitempty"`
2200}
2201
2202// Settings is the wire shape at either level. Additive: unknown keys are
2203// ignored, absent halves inherit.
2204type Settings struct {
2205 Analytics *Analytics `json:"analytics,omitempty"`
2206 Errors *Errors `json:"errors,omitempty"`
2207}
2208
2209// AccountKey is the account-level object, beside account.json (which stays
2210// identity-only).
2211func AccountKey(sqid string) string { return "accounts/" + sqid + "/telemetry.json" }
2212
2213// AppKey is the per-app override.
2214func AppKey(sqid, app string) string { return release.AppPrefix(sqid, app) + "telemetry.json" }
2215
2216// ValidLogLevel reports whether s is a level the setting accepts.
2217func ValidLogLevel(s string) bool { return s == LogLevelError || s == LogLevelWarn }
2218
2219// Read returns the object at key and its etag; an absent object is the
2220// zero Settings with an empty etag and no error.
2221func Read(ctx context.Context, s release.Store, key string) (Settings, string, error) {
2222 body, etag, err := s.Get(ctx, key)
2223 if err != nil {
2224 if errors.Is(err, os.ErrNotExist) {
2225 return Settings{}, "", nil
2226 }
2227 return Settings{}, "", err
2228 }
2229 var st Settings
2230 if err := json.Unmarshal(body, &st); err != nil {
2231 return Settings{}, "", fmt.Errorf("telemetry: %s: %w", key, err)
2232 }
2233 return st, etag, nil
2234}
2235
2236// Update applies fn to the object at key under compare-and-swap, three
2237// attempts, so two owners editing different halves at once both land.
2238func Update(ctx context.Context, s release.Store, key string, fn func(*Settings)) error {
2239 var last error
2240 for attempt := 0; attempt < 3; attempt++ {
2241 st, etag, err := Read(ctx, s, key)
2242 if err != nil {
2243 return err
2244 }
2245 fn(&st)
2246 body, err := json.Marshal(st)
2247 if err != nil {
2248 return err
2249 }
2250 if _, err := s.Put(ctx, key, body, etag); err != nil {
2251 if errors.Is(err, release.ErrCASConflict) {
2252 last = err
2253 continue
2254 }
2255 return err
2256 }
2257 return nil
2258 }
2259 return last
2260}
2261
2262// Resolved is what a reader acts on, with each value's provenance so a
2263// settings page can say "inherited from the account".
2264type Resolved struct {
2265 LogLevel, LogLevelFrom string
2266 Count, CountFrom string
2267 Door, DoorFrom string
2268}
2269
2270// Resolve reads both levels and folds them over the defaults. A store
2271// error fails the resolve; an absent object at either level is fine.
2272func Resolve(ctx context.Context, s release.Store, sqid, app string) (Resolved, error) {
2273 r := Resolved{LogLevel: LogLevelError, LogLevelFrom: "default", Count: CountOff, CountFrom: "default", Door: DoorOn, DoorFrom: "default"}
2274 if s == nil {
2275 return r, nil
2276 }
2277 acct, _, err := Read(ctx, s, AccountKey(sqid))
2278 if err != nil {
2279 return r, err
2280 }
2281 appSt, _, err := Read(ctx, s, AppKey(sqid, app))
2282 if err != nil {
2283 return r, err
2284 }
2285 for _, lvl := range []struct {
2286 from string
2287 st Settings
2288 }{{"account", acct}, {"app", appSt}} {
2289 if lvl.st.Errors != nil && ValidLogLevel(lvl.st.Errors.LogLevel) {
2290 r.LogLevel, r.LogLevelFrom = lvl.st.Errors.LogLevel, lvl.from
2291 }
2292 if lvl.st.Analytics != nil {
2293 if c := lvl.st.Analytics.Count; c == CountEdge || c == CountOff {
2294 r.Count, r.CountFrom = c, lvl.from
2295 }
2296 if d := lvl.st.Analytics.Door; d == DoorOn || d == DoorOff {
2297 r.Door, r.DoorFrom = d, lvl.from
2298 }
2299 }
2300 }
2301 return r, nil
2302}
2303```
2304
2305Check how `DirStore.Put` treats the `ifMatch` argument (`""` = unconditional? or create-only?). Read `internal/release/store.go` around `func (d *DirStore) Put` and confirm that `Put(ctx, key, body, "")` on an existing key succeeds or is create-only; if create-only, `Update` must pass the etag read back (which it does) and for a first write the empty etag must mean create — verify with the test.
2306
2307- [ ] **Step 4: Run, PASS, commit**
2308
2309```bash
2310git add internal/telemetry
2311git commit -m "telemetry: the two-level settings object behind Errors and Analytics (spec §3.3)
2312
2313Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2314```
2315
2316---
2317
2318### Task 9: Console — Errors API, tab, and the log-level setting
2319
2320**Files:**
2321- Create: `internal/console/errorsview.go`, `internal/console/apierrors.go`, `internal/console/templates/app_errors.html`
2322- Modify: `internal/console/server.go` (routes near 1050 and 1071; page struct near 1370; `handleAppErrors`), `internal/console/authcli.go:595` (two `cliRoute`s), `internal/console/templates/appnav.html`, `internal/console/templates/app_settings.html`, `internal/console/templates/account_settings.html`
2323- Test: `internal/console/apierrors_test.go`
2324
2325**Interfaces:**
2326- Consumes: `apperr.*`, `applog.ReadWindow`, `telemetry.Resolve/Update`, `s.instanceGate`, `s.appPage`, `s.accountApp`, `s.account`, `s.audit`, `s.setFlash`, `logsCollectCap`, `instanceStoreTimeout`.
2327- Produces:
2328 ```go
2329 type errorsWindow struct { Groups []apperr.Group; Records []apperr.Record; Truncated bool; LogLevel, LogLevelFrom string; Sources []string }
2330 func (s *Server) readErrors(ctx, sqid, app string, f apperr.Filter, level string) (errorsWindow, error)
2331 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"` }
2332 ```
2333 Routes: `GET /api/cli/apps/{account}/{app}/errors` (Instance, Operate); `PUT /api/cli/apps/{account}/{app}/telemetry` (Operate) body `{"errors":{"log_level":"warn"}}`; `GET /a/{sqid}/apps/{app}/errors`; `POST /a/{sqid}/apps/{app}/errors/level`; `POST /a/{sqid}/errors/level`.
2334
2335- [ ] **Step 1: Failing tests** — `internal/console/apierrors_test.go`
2336
2337```go
2338package console
2339
2340import (
2341 "context"
2342 "encoding/json"
2343 "net/http"
2344 "strings"
2345 "testing"
2346 "time"
2347
2348 "github.com/carlosframework/platform/internal/apperr"
2349 "github.com/carlosframework/platform/internal/applog"
2350 "github.com/carlosframework/platform/internal/release"
2351 "github.com/carlosframework/platform/internal/telemetry"
2352)
2353
2354func errorsURL(fx *consoleFixture, acct, app, query string) string {
2355 u := fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/errors"
2356 if query != "" {
2357 u += "?" + query
2358 }
2359 return u
2360}
2361
2362// seedErrors ships one coalesced edge error and, via seedLogChunks, one
2363// error-level platform line and one warn-level app line.
2364func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) {
2365 t.Helper()
2366 seedLogChunks(t, fx, now)
2367 warn := []applog.Record{{TS: now.Add(-7 * time.Minute), Box: "box1", Host: "titogo.example.com",
2368 Stream: applog.StreamApp, Level: applog.LevelWarn, Msg: "slow query 4123ms", Seq: 150}}
2369 body, _ := applog.EncodeChunk(warn)
2370 if _, err := fx.rel.Put(context.Background(), applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(warn[0].TS), 150, len(body)), body, ""); err != nil {
2371 t.Fatal(err)
2372 }
2373 e := []apperr.Record{{TS: now.Add(-3 * time.Minute), Box: "box1", Host: "titogo.example.com",
2374 Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 POST /checkout", Path: "/checkout", Status: 500,
2375 Release: "v3", FP: "edge500", FPV: 1, Count: 12, First: now.Add(-9 * time.Minute), Last: now.Add(-3 * time.Minute), Seq: 200}}
2376 eb, _ := apperr.EncodeChunk(e)
2377 if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(e[0].TS), 200, len(eb)), eb, ""); err != nil {
2378 t.Fatal(err)
2379 }
2380}
2381
2382func TestAPIErrorsGroupsBothFamilies(t *testing.T) {
2383 fx, token := instanceFixture(t, "code-errors-1")
2384 now := time.Now().UTC()
2385 seedErrors(t, fx, now)
2386 get := func(query string) errorsResponse {
2387 t.Helper()
2388 status, _, body := rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", query), token, "")
2389 if status != http.StatusOK {
2390 t.Fatalf("GET ?%s = %d: %s", query, status, body)
2391 }
2392 var resp errorsResponse
2393 if err := json.Unmarshal([]byte(body), &resp); err != nil {
2394 t.Fatal(err)
2395 }
2396 return resp
2397 }
2398 resp := get("")
2399 if len(resp.Groups) != 2 {
2400 t.Fatalf("groups = %+v", resp.Groups)
2401 }
2402 if resp.Groups[0].FP != "edge500" || resp.Groups[0].Count != 12 || resp.Groups[0].Releases[0] != "v3" {
2403 t.Fatalf("newest first, coalesced count: %+v", resp.Groups[0])
2404 }
2405 if resp.Groups[1].Source != apperr.SourceLog || !strings.Contains(resp.Groups[1].Name, "wake failed") {
2406 t.Fatalf("log-derived group: %+v", resp.Groups[1])
2407 }
2408 if resp.LogLevel != "error" || len(resp.Sources) != 2 {
2409 t.Fatalf("meta = %q %v", resp.LogLevel, resp.Sources)
2410 }
2411 // A warn read widens without changing the setting.
2412 if resp = get("level=warn"); len(resp.Groups) != 3 {
2413 t.Fatalf("level=warn groups = %d", len(resp.Groups))
2414 }
2415 // Source and fp filters.
2416 if resp = get("source=edge"); len(resp.Groups) != 1 || resp.Groups[0].FP != "edge500" {
2417 t.Fatalf("source filter = %+v", resp.Groups)
2418 }
2419 if resp = get("fp=edge500"); len(resp.Records) != 1 || resp.Records[0].Status != 500 {
2420 t.Fatalf("fp expand = %+v", resp.Records)
2421 }
2422}
2423
2424func TestAPITelemetryPutSetsLogLevel(t *testing.T) {
2425 fx, token := instanceFixture(t, "code-errors-2")
2426 now := time.Now().UTC()
2427 seedErrors(t, fx, now)
2428 // The instance token may read but not write settings: Operate only.
2429 status, _, body := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", token, `{"errors":{"log_level":"warn"}}`)
2430 if status != http.StatusForbidden && status != http.StatusNotFound {
2431 t.Fatalf("instance token PUT = %d %s", status, body)
2432 }
2433 op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
2434 status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, `{"errors":{"log_level":"warn"}}`)
2435 if status != http.StatusOK {
2436 t.Fatalf("PUT = %d %s", status, body)
2437 }
2438 r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
2439 if r.LogLevel != "warn" || r.LogLevelFrom != "app" {
2440 t.Fatalf("resolved = %+v", r)
2441 }
2442 status, _, body = rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", ""), token, "")
2443 var resp errorsResponse
2444 json.Unmarshal([]byte(body), &resp)
2445 if status != 200 || resp.LogLevel != "warn" || len(resp.Groups) != 3 {
2446 t.Fatalf("after set: %d level=%q groups=%d", status, resp.LogLevel, len(resp.Groups))
2447 }
2448 status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, `{"errors":{"log_level":"loud"}}`)
2449 if status != http.StatusBadRequest {
2450 t.Fatalf("bad level = %d %s", status, body)
2451 }
2452}
2453
2454func TestAppErrorsPage(t *testing.T) {
2455 fx, _ := instanceFixture(t, "code-errors-page")
2456 now := time.Now().UTC()
2457 seedErrors(t, fx, now)
2458 fx.setUser("paul@keymail.dev")
2459 c := newBrowser(t)
2460 signUp(t, fx, c, "code-errors-signin")
2461 status, _, body := get(t, c, fx.u("/apps/titogo/errors"))
2462 if status != http.StatusOK {
2463 t.Fatalf("errors page: %d %s", status, body)
2464 }
2465 for _, want := range []string{"500 POST /checkout", "wake failed", "carlos errors --app titogo", "Sources live: log, edge", "to the nearest flush"} {
2466 if !strings.Contains(body, want) {
2467 t.Errorf("page missing %q", want)
2468 }
2469 }
2470 if strings.Contains(body, "slow query") {
2471 t.Error("warn lines must not show at the default level")
2472 }
2473 status, _, body = get(t, c, fx.u("/apps/titogo/errors?level=warn"))
2474 if status != 200 || !strings.Contains(body, "slow query") {
2475 t.Fatalf("level=warn page: %d", status)
2476 }
2477 status, _, body = get(t, c, fx.u("/apps/titogo/errors?fp=edge500"))
2478 if status != 200 || !strings.Contains(body, "/checkout") || !strings.Contains(body, "v3") {
2479 t.Fatalf("expanded group: %d", status)
2480 }
2481 status, _, body = get(t, c, fx.u("/apps/titogo/errors?since=1ns"))
2482 if status != 200 || !strings.Contains(body, "No errors in this window") {
2483 t.Fatalf("empty window: %d", status)
2484 }
2485}
2486```
2487
2488`operateToken(t, fx)`: find how existing tests mint a service token with the `Operate` role (grep `svcrole.Operate` in `internal/console/*_test.go`; there is a helper minting tokens with a role list for the apicli tests). Use that helper; if its name differs, use it directly in place of `operateToken`. If no such helper exists, mint through the same store call `instanceFixture` uses with `svcrole.Operate` substituted.
2489
2490- [ ] **Step 2: Run, expect compile failure**
2491
2492- [ ] **Step 3: `errorsview.go`**
2493
2494```go
2495package console
2496
2497// The Errors tab's read (spec 2026-09-07 §3.2, §3.6): errors/ records
2498// plus log lines at or above the resolved level, derived on read and
2499// never stored twice, grouped by fingerprint. The tab and the CLI's API
2500// share this function — the API is not an afterthought.
2501
2502import (
2503 "context"
2504 "regexp"
2505 "sort"
2506 "time"
2507
2508 "github.com/carlosframework/platform/internal/apperr"
2509 "github.com/carlosframework/platform/internal/applog"
2510 "github.com/carlosframework/platform/internal/telemetry"
2511)
2512
2513// errorsWindow is one assembled read.
2514type errorsWindow struct {
2515 Groups []apperr.Group
2516 Records []apperr.Record // when f.FP is set: that group's records, oldest first
2517 Truncated bool
2518 LogLevel string // resolved setting
2519 LogLevelFrom string
2520 Sources []string // sources with at least one record in the window
2521}
2522
2523// readErrors assembles the window. level overrides the resolved setting
2524// for THIS read only ("" = use the setting); it is what lets a member on
2525// error widen one look to warn without changing anything (§8 ruling 3).
2526func (s *Server) readErrors(ctx context.Context, sqid, app string, f apperr.Filter, level string) (errorsWindow, error) {
2527 var out errorsWindow
2528 res, err := telemetry.Resolve(ctx, s.cfg.Releases, sqid, app)
2529 if err != nil {
2530 return out, err
2531 }
2532 out.LogLevel, out.LogLevelFrom = res.LogLevel, res.LogLevelFrom
2533 if level == "" {
2534 level = res.LogLevel
2535 }
2536 stored, trimmed, err := apperr.ReadWindow(ctx, s.cfg.Releases, sqid, app, f, logsCollectCap)
2537 if err != nil {
2538 return out, err
2539 }
2540 out.Truncated = trimmed
2541 var derived []apperr.Record
2542 if f.Source == "" || f.Source == apperr.SourceLog {
2543 lf := applog.Filter{Since: f.Since, Until: f.Until, Host: f.Host, Level: level}
2544 lines, ltrim, err := applog.ReadWindow(ctx, s.cfg.Releases, sqid, app, lf, logsCollectCap)
2545 if err != nil {
2546 return out, err
2547 }
2548 out.Truncated = out.Truncated || ltrim
2549 for _, l := range lines {
2550 r := apperr.FromLog(l)
2551 if (f.FP == "" || r.FP == f.FP) && (f.Release == "" || r.Release == f.Release) {
2552 derived = append(derived, r)
2553 }
2554 }
2555 }
2556 all := apperr.MergeRecords(stored, derived)
2557 if f.FP != "" {
2558 out.Records = all
2559 }
2560 out.Groups = apperr.GroupRecords(all)
2561 seen := map[string]bool{}
2562 for _, r := range all {
2563 if !seen[r.Source] {
2564 seen[r.Source] = true
2565 out.Sources = append(out.Sources, r.Source)
2566 }
2567 }
2568 sort.Strings(out.Sources)
2569 return out, nil
2570}
2571
2572// errorsFilterFromQuery reads the tab's and the API's shared params:
2573// since/until/source/host/release/fp/level. The empty badParam means they
2574// parsed.
2575func errorsFilterFromQuery(q map[string][]string, now time.Time) (f apperr.Filter, level string, badParam string) {
2576 get := func(k string) string {
2577 if v := q[k]; len(v) > 0 {
2578 return v[0]
2579 }
2580 return ""
2581 }
2582 var err error
2583 if f.Since, err = applog.ParseTimeArg(get("since"), now); err != nil {
2584 return f, "", "since: " + err.Error()
2585 }
2586 if f.Until, err = applog.ParseTimeArg(get("until"), now); err != nil {
2587 return f, "", "until: " + err.Error()
2588 }
2589 if f.Since.IsZero() {
2590 f.Since = now.Add(-24 * time.Hour)
2591 }
2592 switch v := get("source"); v {
2593 case "", apperr.SourceLog, apperr.SourceEdge, apperr.SourceServer, apperr.SourceBrowser:
2594 f.Source = v
2595 default:
2596 return f, "", "source: want log, edge, server or browser"
2597 }
2598 f.Host, f.Release, f.FP = get("host"), get("release"), get("fp")
2599 if !validFP.MatchString(f.FP) {
2600 return f, "", "fp: want a fingerprint id"
2601 }
2602 if level = get("level"); level != "" && !telemetry.ValidLogLevel(level) {
2603 return f, "", "level: want error or warn"
2604 }
2605 return f, level, ""
2606}
2607
2608var validFP = regexp.MustCompile(`^[A-Za-z0-9_.-]{0,64}$`)
2609```
2610
2611- [ ] **Step 4: `apierrors.go`**
2612
2613```go
2614package console
2615
2616import (
2617 "context"
2618 "encoding/json"
2619 "log"
2620 "net/http"
2621 "time"
2622
2623 "github.com/carlosframework/platform/internal/apperr"
2624 "github.com/carlosframework/platform/internal/telemetry"
2625)
2626
2627// errorsResponse is the wire shape `carlos errors` and the tab read.
2628type errorsResponse struct {
2629 Groups []apperr.Group `json:"groups"`
2630 Records []apperr.Record `json:"records,omitempty"`
2631 Truncated bool `json:"truncated"`
2632 LogLevel string `json:"log_level"`
2633 Sources []string `json:"sources"`
2634}
2635
2636// handleAPIErrors answers `carlos errors`: the instance family's gate
2637// (an app token may read its own errors), the shared filter, one read.
2638func (s *Server) handleAPIErrors(w http.ResponseWriter, r *http.Request) {
2639 _, acct, app, ok := s.instanceGate(w, r)
2640 if !ok {
2641 return
2642 }
2643 f, level, bad := errorsFilterFromQuery(r.URL.Query(), time.Now())
2644 if bad != "" {
2645 jsonError(w, http.StatusBadRequest, bad)
2646 return
2647 }
2648 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
2649 defer cancel()
2650 win, err := s.readErrors(ctx, acct.Sqid, app, f, level)
2651 if err != nil {
2652 log.Printf("console: api errors %s/%s: %v", acct.Sqid, app, err)
2653 jsonError(w, http.StatusBadGateway, "could not read the errors")
2654 return
2655 }
2656 resp := errorsResponse{Groups: win.Groups, Records: win.Records, Truncated: win.Truncated, LogLevel: win.LogLevel, Sources: win.Sources}
2657 if resp.Groups == nil {
2658 resp.Groups = []apperr.Group{}
2659 }
2660 if resp.Sources == nil {
2661 resp.Sources = []string{}
2662 }
2663 writeJSON(w, http.StatusOK, resp)
2664}
2665
2666// handleAPITelemetryPut sets the app-level telemetry object: today the
2667// errors log level; the analytics keys arrive with step 2. Operate only —
2668// an app's own token must not widen what its owner sees.
2669func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) {
2670 principal, acct, app, ok := s.instanceGate(w, r)
2671 if !ok {
2672 return
2673 }
2674 var body telemetry.Settings
2675 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
2676 jsonError(w, http.StatusBadRequest, "body: want {\"errors\":{\"log_level\":\"error|warn\"}}")
2677 return
2678 }
2679 if body.Errors == nil || !telemetry.ValidLogLevel(body.Errors.LogLevel) {
2680 jsonError(w, http.StatusBadRequest, "errors.log_level: want error or warn")
2681 return
2682 }
2683 if body.Analytics != nil {
2684 jsonError(w, http.StatusBadRequest, "analytics: not settable yet")
2685 return
2686 }
2687 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
2688 defer cancel()
2689 if err := telemetry.Update(ctx, s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), func(st *telemetry.Settings) {
2690 st.Errors = &telemetry.Errors{LogLevel: body.Errors.LogLevel}
2691 }); err != nil {
2692 log.Printf("console: api telemetry %s/%s: %v", acct.Sqid, app, err)
2693 jsonError(w, http.StatusBadGateway, "could not write the setting")
2694 return
2695 }
2696 s.audit(acct, app, principal, "errors-level", "set the Errors log level to "+body.Errors.LogLevel)
2697 writeJSON(w, http.StatusOK, map[string]string{"log_level": body.Errors.LogLevel})
2698}
2699```
2700
2701`principal` is the first return of `instanceGate` (a string naming the token's principal — check the type; if it is not a string suitable for `audit`'s `addr`, pass what the logs/instances handlers pass).
2702
2703Register in `authcli.go` beside the logs route:
2704
2705```go
2706 s.cliRoute("GET /api/cli/apps/{account}/{app}/errors", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIErrors)
2707 s.cliRoute("PUT /api/cli/apps/{account}/{app}/telemetry", svcrole.Allow(svcrole.Operate), s.handleAPITelemetryPut)
2708```
2709
2710- [ ] **Step 5: Page handler, fields, routes, templates**
2711
2712Page struct (near line 1370), add:
2713
2714```go
2715 // The Errors tab (errorsview.go).
2716 ErrWin errorsWindow
2717 ErrSince string // "1h" | "24h" | "168h"
2718 ErrSource string
2719 ErrLevel string // "" = the setting
2720 ErrHost string
2721 ErrFP string // expanded group
2722 ErrsErr string
2723```
2724
2725Routes (near 1050 and 1071):
2726
2727```go
2728 s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/errors", s.handleAppErrors)
2729 s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/errors/level", s.handleAppErrorLevel)
2730 s.mux.HandleFunc("POST /a/{sqid}/errors/level", s.handleAccountErrorLevel)
2731```
2732
2733Handlers (in `errorsview.go`):
2734
2735```go
2736// handleAppErrors renders the Errors tab: the same read the API answers,
2737// with the filters as GET params so a view is a URL.
2738func (s *Server) handleAppErrors(w http.ResponseWriter, r *http.Request) {
2739 p, acct, ok := s.appPage(w, r, "errors")
2740 if !ok {
2741 return
2742 }
2743 q := r.URL.Query()
2744 p.ErrSince = q.Get("since")
2745 if p.ErrSince != "1h" && p.ErrSince != "168h" {
2746 p.ErrSince = "24h"
2747 }
2748 q.Set("since", p.ErrSince)
2749 f, level, bad := errorsFilterFromQuery(q, time.Now())
2750 if bad != "" {
2751 f, level, _ = errorsFilterFromQuery(map[string][]string{"since": {p.ErrSince}}, time.Now())
2752 }
2753 p.ErrSource, p.ErrLevel, p.ErrHost, p.ErrFP = f.Source, level, f.Host, f.FP
2754 if s.cfg.Releases != nil {
2755 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
2756 defer cancel()
2757 win, err := s.readErrors(ctx, acct.Sqid, p.AppName, f, level)
2758 if err != nil {
2759 log.Printf("console: app errors %s/%s: %v", acct.Sqid, p.AppName, err)
2760 p.ErrsErr = "The errors could not be read just now — try again in a moment."
2761 }
2762 p.ErrWin = win
2763 }
2764 s.render(w, http.StatusOK, "app_errors", p)
2765}
2766
2767// handleAppErrorLevel is the Settings tab's form: owner, CSRF, one key.
2768func (s *Server) handleAppErrorLevel(w http.ResponseWriter, r *http.Request) {
2769 addr, raw, acct, role, app, ok := s.accountApp(w, r)
2770 if !ok {
2771 return
2772 }
2773 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
2774 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2775 return
2776 }
2777 if role != RoleOwner {
2778 http.NotFound(w, r)
2779 return
2780 }
2781 level := r.FormValue("level")
2782 back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
2783 if !telemetry.ValidLogLevel(level) {
2784 s.setFlash(w, "err", "Choose error or warn.")
2785 http.Redirect(w, r, back, http.StatusSeeOther)
2786 return
2787 }
2788 if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), func(st *telemetry.Settings) {
2789 st.Errors = &telemetry.Errors{LogLevel: level}
2790 }); err != nil {
2791 log.Printf("console: errors level %s/%s: %v", acct.Sqid, app, err)
2792 s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
2793 http.Redirect(w, r, back, http.StatusSeeOther)
2794 return
2795 }
2796 s.audit(acct, app, addr, "errors-level", "set the Errors log level to "+level)
2797 s.setFlash(w, "ok", "Errors now include "+level+" lines and above for this app.")
2798 http.Redirect(w, r, back, http.StatusSeeOther)
2799}
2800
2801// handleAccountErrorLevel is the account settings form: the default every
2802// app without its own setting inherits.
2803func (s *Server) handleAccountErrorLevel(w http.ResponseWriter, r *http.Request) {
2804 addr, raw, acct, role, ok := s.account(w, r)
2805 if !ok {
2806 return
2807 }
2808 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
2809 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2810 return
2811 }
2812 if role != RoleOwner {
2813 http.NotFound(w, r)
2814 return
2815 }
2816 level := r.FormValue("level")
2817 back := "/a/" + url.PathEscape(acct.Sqid) + "/settings"
2818 if !telemetry.ValidLogLevel(level) {
2819 s.setFlash(w, "err", "Choose error or warn.")
2820 http.Redirect(w, r, back, http.StatusSeeOther)
2821 return
2822 }
2823 if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AccountKey(acct.Sqid), func(st *telemetry.Settings) {
2824 st.Errors = &telemetry.Errors{LogLevel: level}
2825 }); err != nil {
2826 log.Printf("console: account errors level %s: %v", acct.Sqid, err)
2827 s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
2828 http.Redirect(w, r, back, http.StatusSeeOther)
2829 return
2830 }
2831 s.audit(acct, "", addr, "errors-level", "set the account's Errors log level to "+level)
2832 s.setFlash(w, "ok", "Errors now include "+level+" lines and above for every app without its own setting.")
2833 http.Redirect(w, r, back, http.StatusSeeOther)
2834}
2835```
2836
2837Confirm `s.account`'s return arity by reading `handleAccountSettings` (`addr, raw, acct, role, ok`). Add `p.ErrLevelSetting telemetry.Resolved` to the page struct and populate it in `handleAppSettings` (`p.ErrLevelSetting, _ = telemetry.Resolve(ctx, s.cfg.Releases, acct.Sqid, app)`) and in `handleAccountSettings` (`Resolve(ctx, s.cfg.Releases, acct.Sqid, "")` — with an empty app name `AppKey` reads a key that does not exist, which returns defaults; that is acceptable for the account page and should be noted in a comment).
2838
2839`appnav.html`: insert after the Logs link:
2840
2841```html
2842 <a href="{{.Nav}}/apps/{{.AppName}}/errors"{{if eq .ActiveTab "errors"}} aria-current="page"{{end}}>Errors</a>
2843```
2844
2845`app_errors.html`:
2846
2847```html
2848{{define "app_errors"}}{{template "top" .}}
2849{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
2850{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
2851{{template "appnav" .}}
2852
2853<h2>Errors</h2>
2854{{if .ErrsErr}}<p class="banner error">{{.ErrsErr}}</p>{{end}}
2855<form method="get" class="small">
2856 <label>from the last
2857 <select name="since">
2858 <option value="1h"{{if eq .ErrSince "1h"}} selected{{end}}>hour</option>
2859 <option value="24h"{{if eq .ErrSince "24h"}} selected{{end}}>day</option>
2860 <option value="168h"{{if eq .ErrSince "168h"}} selected{{end}}>week</option>
2861 </select>
2862 </label>
2863 <label>source
2864 <select name="source">
2865 <option value=""{{if eq .ErrSource ""}} selected{{end}}>all</option>
2866 <option value="log"{{if eq .ErrSource "log"}} selected{{end}}>log</option>
2867 <option value="edge"{{if eq .ErrSource "edge"}} selected{{end}}>edge</option>
2868 <option value="server"{{if eq .ErrSource "server"}} selected{{end}}>server</option>
2869 <option value="browser"{{if eq .ErrSource "browser"}} selected{{end}}>browser</option>
2870 </select>
2871 </label>
2872 <label>log level
2873 <select name="level">
2874 <option value=""{{if eq .ErrLevel ""}} selected{{end}}>setting ({{.ErrWin.LogLevel}})</option>
2875 <option value="error"{{if eq .ErrLevel "error"}} selected{{end}}>error</option>
2876 <option value="warn"{{if eq .ErrLevel "warn"}} selected{{end}}>warn and above</option>
2877 </select>
2878 </label>
2879 <label>host <input name="host" value="{{.ErrHost}}" size="24"></label>
2880 <button type="submit">Update</button>
2881</form>
2882<p class="small">Sources live: {{if .ErrWin.Sources}}{{range $i, $s := .ErrWin.Sources}}{{if $i}}, {{end}}{{$s}}{{end}}{{else}}none yet{{end}}.
2883Counts 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>
2884
2885{{if .ErrFP}}
2886{{with .ErrWin.Records}}
2887<h3>Occurrences of <code>{{$.ErrFP}}</code></h3>
2888<p class="small"><a href="?since={{$.ErrSince}}">All groups</a></p>
2889{{range .}}
2890<div class="tablewrap"><table>
2891 <tr><th>Last</th><td>{{.Last.UTC.Format "2006-01-02 15:04:05"}}</td></tr>
2892 <tr><th>Count</th><td>{{.Count}}</td></tr>
2893 <tr><th>Source</th><td>{{.Source}}{{if .Kind}} / {{.Kind}}{{end}}</td></tr>
2894 <tr><th>Host</th><td>{{.Host}}</td></tr>
2895 {{if .Release}}<tr><th>Release</th><td>{{.Release}}</td></tr>{{end}}
2896 {{if .Path}}<tr><th>Path</th><td><code>{{.Path}}</code>{{if .Status}} → {{.Status}}{{end}}</td></tr>{{end}}
2897 <tr><th>Message</th><td>{{.Name}}</td></tr>
2898 {{if .Stack}}<tr><th>Stack</th><td><pre>{{.Stack}}</pre></td></tr>{{end}}
2899 {{if .Fields}}<tr><th>Fields</th><td>{{range $k, $v := .Fields}}<code>{{$k}}={{$v}}</code> {{end}}</td></tr>{{end}}
2900 <tr><th>Logs</th><td><a href="{{$.Nav}}/apps/{{$.AppName}}/logs?since=1h&host={{.Host}}">around this time</a></td></tr>
2901</table></div>
2902{{end}}
2903{{else}}<p>No occurrences of this group in the window.</p>{{end}}
2904{{else if .ErrWin.Groups}}
2905{{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}}
2906<div class="tablewrap">
2907<table>
2908 <tr><th>Last seen</th><th>Count</th><th>Source</th><th>Error</th><th>Hosts</th><th>Releases</th></tr>
2909 {{range .ErrWin.Groups}}<tr>
2910 <td>{{.Last.UTC.Format "2006-01-02 15:04"}}</td>
2911 <td>{{.Count}}{{if eq .FP "_overflow"}} <span class="small" title="more kinds than the hourly cap; occurrences">(overflow)</span>{{end}}</td>
2912 <td>{{.Source}}{{if .Level}} <span class="small">{{.Level}}</span>{{end}}</td>
2913 <td><a href="?since={{$.ErrSince}}&fp={{.FP}}{{if $.ErrLevel}}&level={{$.ErrLevel}}{{end}}">{{.Name}}</a></td>
2914 <td>{{range $i, $h := .Hosts}}{{if $i}}, {{end}}{{$h}}{{end}}</td>
2915 <td>{{range $i, $r := .Releases}}{{if $i}}, {{end}}<code>{{$r}}</code>{{end}}</td>
2916 </tr>{{end}}
2917</table>
2918</div>
2919<p class="small">Times are UTC. The same view from a terminal: <code>carlos errors --app {{.AppName}} --since {{.ErrSince}}</code>.</p>
2920{{else if not .ErrsErr}}
2921<p>No errors in this window.</p>
2922<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>
2923{{end}}
2924{{template "bottom" .}}{{end}}
2925```
2926
2927`app_settings.html`: before the Feature flags section add:
2928
2929```html
2930<h2>Errors</h2>
2931<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/errors/level" class="settings-row">
2932 <input type="hidden" name="csrf" value="{{.CSRF}}">
2933 <label>Log lines counted as errors:
2934 <select name="level">
2935 <option value="error"{{if eq .ErrLevelSetting.LogLevel "error"}} selected{{end}}>error and above</option>
2936 <option value="warn"{{if eq .ErrLevelSetting.LogLevel "warn"}} selected{{end}}>warn and above</option>
2937 </select>
2938 </label>
2939 <button type="submit">Save</button>
2940 <span class="small">currently {{.ErrLevelSetting.LogLevel}}, from the {{.ErrLevelSetting.LogLevelFrom}}</span>
2941</form>
2942<p class="small">The Errors tab always lets you widen one look to warn without changing this.</p>
2943```
2944
2945Check the CSRF field name and value the rename form uses (`{{.CSRF}}`, `{{.Raw}}` or similar) and match it exactly.
2946
2947`account_settings.html`: before "Delete this account" add the same form posting to `{{.Base}}/errors/level`, with the copy "for every app without its own setting".
2948
2949- [ ] **Step 6: Run console tests**
2950
2951Run: `go build ./... && go vet ./internal/console/ && go test ./internal/console/ -count=1`
2952Expected: PASS (the whole package; template parse errors surface at server construction in every test).
2953
2954- [ ] **Step 7: Commit**
2955
2956```bash
2957git add internal/console
2958git commit -m "console: Errors tab, errors API, and the log-level setting at both levels (spec §3.6)
2959
2960Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2961```
2962
2963---
2964
2965### Task 10: `carlos errors` and the docs heading
2966
2967**Files:**
2968- Create: `errors_cmd.go`
2969- Modify: `apimode.go` (beside `logsGet`), `main.go:80` (dispatch) and the help text near line 204
2970- Modify: `docs/site/cli.md` after `### carlos logs`
2971- Test: `errors_cmd_test.go` (format only) and the docsite gate
2972
2973**Interfaces:**
2974- Consumes: `requireAPISession`, `resolvedAccount`, `resolvedApp`, `appFlag`, `flagUsage`, `apiPath`, `getJSON`, `putJSON` (check its name; `apimode.go` has a JSON PUT/POST helper used by `features set`), `colorFor`, `mutedf`, `stdout`.
2975- Produces: `runErrors(args []string)`; `(*apiSession).errorsGet(ctx, a apiAcct, app string, q url.Values) (apiErrorsResponse, error)`; `(*apiSession).telemetryPut(ctx, a apiAcct, app string, body any) error`; `printErrorGroups(w io.Writer, groups []apperr.Group)`; `printErrorRecords(w io.Writer, recs []apperr.Record)`.
2976
2977- [ ] **Step 1: Failing format test** — `errors_cmd_test.go`
2978
2979```go
2980package main
2981
2982import (
2983 "bytes"
2984 "strings"
2985 "testing"
2986 "time"
2987
2988 "github.com/carlosframework/platform/internal/apperr"
2989)
2990
2991func TestPrintErrorGroups(t *testing.T) {
2992 now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
2993 var buf bytes.Buffer
2994 printErrorGroups(&buf, []apperr.Group{
2995 {FP: "abc123", Source: "edge", Name: "500 POST /checkout", Count: 12, Last: now, Hosts: []string{"a.example"}, Releases: []string{"v3"}},
2996 {FP: "_overflow", Source: "edge", Name: "500 GET /z", Count: 40, Last: now},
2997 })
2998 out := buf.String()
2999 for _, want := range []string{"2026-09-07 10:00", "12", "edge", "500 POST /checkout", "abc123", "a.example", "v3", "overflow"} {
3000 if !strings.Contains(out, want) {
3001 t.Errorf("output missing %q:\n%s", want, out)
3002 }
3003 }
3004}
3005```
3006
3007- [ ] **Step 2: Implement `errors_cmd.go`**
3008
3009```go
3010// `carlos errors` — an app's error groups with no box access (spec
3011// 2026-09-07-errors-and-analytics-design.md §3.6): log lines at the
3012// configured level and edge-observed 5xx, coalesced by fingerprint, read
3013// through the console API. API mode only, like `carlos logs`.
3014package main
3015
3016import (
3017 "context"
3018 "flag"
3019 "fmt"
3020 "io"
3021 "log"
3022 "net/url"
3023 "sort"
3024 "strconv"
3025
3026 "github.com/carlosframework/platform/internal/apperr"
3027)
3028
3029const errorsUsage = `usage: carlos errors --app <a> [flags]
3030
3031Lists error groups, newest first: one row per fingerprint with its count in
3032the window, the source it came from (log, edge, server, browser), the hosts
3033and releases it was seen on. --fp <id> expands one group to its occurrences.
3034--set-level error|warn changes which log lines count for this app.
3035`
3036
3037func runErrors(args []string) {
3038 fs := flag.NewFlagSet("errors", flag.ExitOnError)
3039 app := appFlag(fs, "app whose errors to read")
3040 since := fs.String("since", "24h", "window start: duration back from now, or RFC3339")
3041 until := fs.String("until", "", "window end (default: now)")
3042 source := fs.String("source", "", "only one source: log | edge | server | browser")
3043 level := fs.String("level", "", "log lines at this level and above for this read: error | warn (default: the app's setting)")
3044 host := fs.String("host", "", "only one instance (route host)")
3045 rel := fs.String("release", "", "only one release")
3046 fp := fs.String("fp", "", "expand one group: its occurrences, oldest first")
3047 setLevel := fs.String("set-level", "", "set the app's log level for errors: error | warn")
3048 consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)")
3049 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
3050 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
3051 fs.Usage = flagUsage(fs, errorsUsage)
3052 fs.Parse(args)
3053
3054 sess := requireAPISession("errors", *consoleFlag)
3055 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
3056 app2 := resolvedApp(sess, acct, *app, errorsUsage)
3057 ctx := context.Background()
3058
3059 if *setLevel != "" {
3060 if *setLevel != "error" && *setLevel != "warn" {
3061 log.Fatalf("errors: --set-level wants error or warn")
3062 }
3063 if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"errors": map[string]string{"log_level": *setLevel}}); err != nil {
3064 log.Fatalf("errors: %v", err)
3065 }
3066 fmt.Fprintf(stdout, "Errors for %s now include %s lines and above.\n", app2, *setLevel)
3067 return
3068 }
3069
3070 q := url.Values{}
3071 for k, v := range map[string]string{"since": *since, "until": *until, "source": *source, "level": *level, "host": *host, "release": *rel, "fp": *fp} {
3072 if v != "" {
3073 q.Set(k, v)
3074 }
3075 }
3076 res, err := sess.errorsGet(ctx, acct, app2, q)
3077 if err != nil {
3078 log.Fatalf("errors: %v", err)
3079 }
3080 if *fp != "" {
3081 printErrorRecords(stdout, res.Records)
3082 if len(res.Records) == 0 {
3083 mutedf(stdout, "no occurrences of %s in this window", *fp)
3084 }
3085 return
3086 }
3087 printErrorGroups(stdout, res.Groups)
3088 if len(res.Groups) == 0 {
3089 mutedf(stdout, "no errors in this window (log level %s; sources live: %s) — try --since 168h or --level warn", res.LogLevel, joinOr(res.Sources, "none yet"))
3090 } else {
3091 mutedf(stdout, "log level %s; sources live: %s; counts are to the nearest flush", res.LogLevel, joinOr(res.Sources, "none"))
3092 }
3093 if res.Truncated {
3094 mutedf(stdout, "(the window held more than could be read; counts are within what was read — narrow it)")
3095 }
3096}
3097
3098func joinOr(xs []string, empty string) string {
3099 if len(xs) == 0 {
3100 return empty
3101 }
3102 out := xs[0]
3103 for _, x := range xs[1:] {
3104 out += ", " + x
3105 }
3106 return out
3107}
3108
3109// printErrorGroups renders one row per group: last seen, count, source,
3110// message, then the fingerprint, hosts and releases dimmed.
3111func printErrorGroups(w io.Writer, groups []apperr.Group) {
3112 c := colorFor(w)
3113 for _, g := range groups {
3114 count := strconv.Itoa(g.Count)
3115 if g.FP == apperr.OverflowFP {
3116 count += " (overflow)"
3117 }
3118 line := c.muted(g.Last.UTC().Format("2006-01-02 15:04")) + " " + c.head(fmt.Sprintf("%6s", count)) + " " +
3119 c.muted(fmt.Sprintf("%-7s", g.Source)) + " " + c.bad(g.Name)
3120 line += "\n" + " " + c.muted("fp="+g.FP)
3121 if len(g.Hosts) > 0 {
3122 line += c.muted(" hosts=" + joinOr(g.Hosts, ""))
3123 }
3124 if len(g.Releases) > 0 {
3125 line += c.muted(" releases=" + joinOr(g.Releases, ""))
3126 }
3127 fmt.Fprintln(w, line)
3128 }
3129}
3130
3131// printErrorRecords renders one group's occurrences, oldest first.
3132func printErrorRecords(w io.Writer, recs []apperr.Record) {
3133 c := colorFor(w)
3134 for _, r := range recs {
3135 fmt.Fprintf(w, "%s %s %s %s\n", c.muted(r.Last.UTC().Format("2006-01-02 15:04:05")),
3136 c.head(fmt.Sprintf("x%d", r.Count)), c.muted(r.Host), c.bad(r.Name))
3137 if r.Path != "" {
3138 fmt.Fprintf(w, " %s\n", c.muted(fmt.Sprintf("%s → %d", r.Path, r.Status)))
3139 }
3140 if r.Release != "" {
3141 fmt.Fprintf(w, " %s\n", c.muted("release "+r.Release))
3142 }
3143 if r.Stack != "" {
3144 fmt.Fprintf(w, " %s\n", r.Stack)
3145 }
3146 keys := make([]string, 0, len(r.Fields))
3147 for k := range r.Fields {
3148 keys = append(keys, k)
3149 }
3150 sort.Strings(keys)
3151 for _, k := range keys {
3152 fmt.Fprintf(w, " %s\n", c.muted(k+"="+r.Fields[k]))
3153 }
3154 }
3155}
3156```
3157
3158In `apimode.go`, beside `logsGet`:
3159
3160```go
3161type apiErrorsResponse struct {
3162 Groups []apperr.Group `json:"groups"`
3163 Records []apperr.Record `json:"records"`
3164 Truncated bool `json:"truncated"`
3165 LogLevel string `json:"log_level"`
3166 Sources []string `json:"sources"`
3167}
3168
3169func (s *apiSession) errorsGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiErrorsResponse, error) {
3170 path := apiPath(a, app, "/errors")
3171 if enc := query.Encode(); enc != "" {
3172 if strings.Contains(path, "?") {
3173 path += "&" + enc
3174 } else {
3175 path += "?" + enc
3176 }
3177 }
3178 var res apiErrorsResponse
3179 status, body, err := s.getJSON(ctx, path)
3180 if err != nil {
3181 return res, err
3182 }
3183 if status != http.StatusOK {
3184 return res, apiErrorFromBody(status, body)
3185 }
3186 if err := json.Unmarshal(body, &res); err != nil {
3187 return res, fmt.Errorf("errors: unreadable response: %w", err)
3188 }
3189 return res, nil
3190}
3191
3192func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, body any) error {
3193 status, resp, err := s.putJSON(ctx, apiPath(a, app, "/telemetry"), body)
3194 if err != nil {
3195 return err
3196 }
3197 if status != http.StatusOK {
3198 return apiErrorFromBody(status, resp)
3199 }
3200 return nil
3201}
3202```
3203
3204Find the existing JSON-body request helper in `apimode.go` (grep `func (s \*apiSession) .*JSON` — `postJSON`/`doJSON`/`sendJSON`) and use its real name and signature.
3205
3206`main.go`: add `case "errors": runErrors(args)` beside `logs`, and in the help block near line 204 a line: ` errors error groups for an app: log lines at the set level plus 5xx the edge saw`.
3207
3208`docs/site/cli.md` after the `### carlos logs` section:
3209
3210```markdown
3211### carlos errors
3212
3213One row per distinct error, newest first, with how many times it happened
3214in the window and where. Two sources need no wiring at all: log lines the
3215app tagged `error` (or `warn`, if you set the level) and responses of 500
3216or above that the edge saw. Server-side and browser hooks come later.
3217
3218```sh
3219carlos errors --app hello --since 24h
3220carlos errors --app hello --fp 3f2a9c1b5e7d0a4c
3221carlos errors --app hello --set-level warn
3222```
3223
3224`--fp` expands one group to its occurrences. `--level warn` widens a single
3225read without changing the setting; `--set-level` changes it for the app.
3226The same view is the Errors tab on the app's dashboard.
3227```
3228
3229- [ ] **Step 3: Run the CLI test, docsite gate, whole build**
3230
3231Run: `go build ./... && go test . -run TestPrintErrorGroups -count=1 && go test ./internal/docsite/ -count=1`
3232Expected: PASS; the docsite gate passes with the new heading (and would fail without it — try removing the heading once to see the failure, then restore it).
3233
3234- [ ] **Step 4: Commit**
3235
3236```bash
3237git add errors_cmd.go errors_cmd_test.go apimode.go main.go docs/site/cli.md
3238git commit -m "carlos errors: the app's error groups from a terminal, and the log-level setting
3239
3240Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
3241```
3242
3243---
3244
3245### Task 11: Whole-tree gate, cross-compile, push
3246
3247**Files:** none new.
3248
3249- [ ] **Step 1: Full gate**
3250
3251Run:
3252```bash
3253go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1
3254GOOS=darwin GOARCH=arm64 go build ./... && GOOS=windows GOARCH=amd64 go build ./...
3255```
3256Expected: all clean; `gofmt -l .` prints nothing.
3257
3258- [ ] **Step 2: Spec status line**
3259
3260In `docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md`, change the `**Status:**` line to `approved (Paul, 2026-09-07: "go ahead with that and plan, build review cycle overnight"); step 1 implemented on branch spec-errors-analytics, plan docs/superpowers/plans/2026-09-07-errors-step1-log-and-edge.md.` Commit with the plan file:
3261
3262```bash
3263git add docs/superpowers
3264git commit -m "spec: approved; step 1 plan
3265
3266Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
3267```
3268
3269- [ ] **Step 3: Push and describe**
3270
3271```bash
3272git push origin spec-errors-analytics
3273amadan branch describe carlos/platform spec-errors-analytics -body - < <description file>
3274amadan ci status carlos/platform -branch spec-errors-analytics
3275```
3276
3277Do not merge. The branch waits for Paul's review of the diff.
3278
3279## Self-review against the spec
3280
3281- §3.1 shape: Task 3 `Record` carries every field but `ua`/`referer` (browser/edge analytics fields; step 2/3). Sanitisation: Task 3/4. ✔
3282- §3.2 log source read-derived: Task 9 `readErrors`. Edge errors with activator suppression: Task 7. Fingerprint rules: Task 3. Coalescing cap per app-hour on `(fp, host, release)` with `_overflow`: Task 4. Storage family and consumer list (`ChunkKeySize`, `HourNameOfDir`, `chunkPrune`, `aclWriteAppFamily`): Tasks 1, 2, 6. ✔
3283- §3.3 observing writer, unconditional, status routes excluded, 1xx ignored, implicit 200, Content-Type latched: Task 7. Settings object and resolution: Task 8. ✔
3284- §3.6 tab (window 1h/24h/7d, source/level/host filters, expand, coverage line, "nearest flush", collect-cap disclosure), API paths and roles, CLI flags including `-level`, settings at both levels, retention via `chunkPrune`: Tasks 6, 9, 10. Tombstone `DELETE …/errors/{fp}` is deferred to a follow-up branch and is called out in the branch description. `-release` filter on the CLI: Task 10 (`--release`). ✔
3285- §4 hibernation: nothing here calls the activator; Task 7's flag path is the only touch and it is a context value. ✔
3286- §6 tests: sanitisation, fingerprint, coalescing, log-derived rows, edge errors on the plain path, chunk family byte-identity, store door, observing writer, settings resolution, console gates (page 404 for non-member is inherited from `appPage`; add one `get` as a non-member in `TestAppErrorsPage` if the logs page test has an equivalent — mirror it), docsite. ✔
3287- Type consistency: `apperr.Filter` fields (`Since, Until, Host, Source, Release, FP`) are used identically in Tasks 4 and 9; `errorsResponse` fields match `apiErrorsResponse`; `chunkPrune(ctx, s, fam, box, k, now)` is called with that order in Tasks 6 and its tests. ✔
3288