| 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 | |
| 73 | Append to `internal/applog/chunk_test.go`: |
| 74 | |
| 75 | ```go |
| 76 | func 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 | |
| 93 | func 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 | |
| 122 | Append 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 |
| 125 | func 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 | |
| 157 | Run: `go test ./internal/applog/ -run 'TestFamilyKeys|TestEncodeJSONL|TestReadChunks' -count=1` |
| 158 | Expected: FAIL to compile (`undefined: FamilyPrefix` etc.). |
| 159 | |
| 160 | - [ ] **Step 3: Implement families and the generic codec in `chunk.go`** |
| 161 | |
| 162 | Replace 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. |
| 170 | type Family string |
| 171 | |
| 172 | const ( |
| 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. |
| 181 | func 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. |
| 187 | func 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. |
| 192 | func 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. |
| 198 | func 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. |
| 203 | func Prefix(acct, app string) string { return FamilyPrefix(FamilyLogs, acct, app) } |
| 204 | |
| 205 | // BoxPrefix is one box's subtree within an app's logs. |
| 206 | func 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. |
| 209 | func 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). |
| 221 | func ChunkKey(acct, app, box, hour string, seq uint64, size int) string { |
| 222 | return FamilyChunkKey(FamilyLogs, acct, app, box, hour, seq, size) |
| 223 | } |
| 224 | ``` |
| 225 | |
| 226 | Keep `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. |
| 231 | func 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. |
| 249 | func 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. |
| 272 | func EncodeChunk(recs []Record) ([]byte, error) { return EncodeJSONL(recs) } |
| 273 | |
| 274 | // DecodeChunk parses a log chunk: DecodeJSONL over Record. |
| 275 | func 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. |
| 289 | func 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 | |
| 336 | Then `ReadWindow` becomes: |
| 337 | |
| 338 | ```go |
| 339 | func 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 | |
| 353 | Rename `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 | |
| 357 | Run: `go test ./internal/applog/ -count=1` |
| 358 | Expected: PASS, every pre-existing test included. |
| 359 | |
| 360 | - [ ] **Step 6: Commit** |
| 361 | |
| 362 | ```bash |
| 363 | git add internal/applog |
| 364 | git commit -m "applog: chunk families and a generic JSONL codec, logs/ unchanged |
| 365 | |
| 366 | Prefix/ChunkKey/EncodeChunk/ReadWindow keep their signatures and bytes; |
| 367 | FamilyPrefix, FamilyChunkKey, EncodeJSONL/DecodeJSONL and ReadChunks are |
| 368 | the seams the errors/ family (spec 2026-09-07 §3.2) builds on. |
| 369 | |
| 370 | Co-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 | |
| 386 | In `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 | |
| 404 | Run: `go test ./internal/edge/ -run TestStoreDoorACLMatrix -count=1` |
| 405 | Expected: FAIL on the `errors` and `analytics` write rows (refused today). |
| 406 | |
| 407 | - [ ] **Step 3: Implement** |
| 408 | |
| 409 | Replace `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". |
| 415 | var 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. |
| 423 | func 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 | |
| 440 | Update `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 | |
| 444 | Run: `go test ./internal/edge/ -run TestStoreDoor -count=1` |
| 445 | Expected: PASS. |
| 446 | |
| 447 | - [ ] **Step 5: Commit** |
| 448 | |
| 449 | ```bash |
| 450 | git add internal/edge/storedoor.go internal/edge/storedoor_test.go |
| 451 | git commit -m "store door: the app write grant is a family allow-list (logs, errors, analytics) |
| 452 | |
| 453 | Co-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 |
| 484 | package apperr |
| 485 | |
| 486 | import "testing" |
| 487 | |
| 488 | func 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 | |
| 508 | func 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 |
| 530 | package apperr |
| 531 | |
| 532 | import "testing" |
| 533 | |
| 534 | func 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 | |
| 553 | func 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 | |
| 567 | func 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 | |
| 596 | Run: `go test ./internal/apperr/ -count=1` |
| 597 | Expected: 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. |
| 608 | package apperr |
| 609 | |
| 610 | import ( |
| 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). |
| 618 | const ( |
| 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). |
| 627 | const ( |
| 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. |
| 634 | const 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. |
| 642 | type 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). |
| 664 | const 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. |
| 669 | func 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 |
| 684 | package apperr |
| 685 | |
| 686 | import ( |
| 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. |
| 694 | var ( |
| 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. |
| 704 | func 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 | |
| 715 | var ( |
| 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 "/". |
| 726 | func 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 | |
| 747 | The 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 |
| 752 | package apperr |
| 753 | |
| 754 | import ( |
| 755 | "crypto/sha256" |
| 756 | "encoding/hex" |
| 757 | "regexp" |
| 758 | "strconv" |
| 759 | "strings" |
| 760 | ) |
| 761 | |
| 762 | var ( |
| 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. |
| 778 | func 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. |
| 793 | func 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. |
| 811 | func 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 | |
| 830 | Note 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 | |
| 834 | Run: `go test ./internal/apperr/ -count=1 -v` |
| 835 | Expected: 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 |
| 840 | git add internal/apperr |
| 841 | git commit -m "apperr: error record, sanitisation and fingerprinting (spec §3.1–3.2) |
| 842 | |
| 843 | Co-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 |
| 878 | package apperr |
| 879 | |
| 880 | import ( |
| 881 | "fmt" |
| 882 | "testing" |
| 883 | "time" |
| 884 | |
| 885 | "github.com/carlosframework/platform/internal/applog" |
| 886 | ) |
| 887 | |
| 888 | func 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 | |
| 919 | func 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 | |
| 951 | func 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 |
| 980 | package apperr |
| 981 | |
| 982 | import ( |
| 983 | "context" |
| 984 | "testing" |
| 985 | "time" |
| 986 | |
| 987 | "github.com/carlosframework/platform/internal/release" |
| 988 | ) |
| 989 | |
| 990 | func 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 | |
| 1035 | Run: `go test ./internal/apperr/ -count=1` |
| 1036 | Expected: FAIL to compile. |
| 1037 | |
| 1038 | - [ ] **Step 3: Write `sink.go`** |
| 1039 | |
| 1040 | ```go |
| 1041 | package apperr |
| 1042 | |
| 1043 | import ( |
| 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. |
| 1053 | const 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. |
| 1057 | const 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. |
| 1064 | type 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 | |
| 1073 | type groupKey struct{ fp, host, release string } |
| 1074 | |
| 1075 | type 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. |
| 1086 | func 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. |
| 1092 | func (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. |
| 1160 | func (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. |
| 1185 | func (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 |
| 1199 | package apperr |
| 1200 | |
| 1201 | import ( |
| 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. |
| 1212 | func 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. |
| 1215 | func 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. |
| 1220 | func 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. |
| 1225 | func HourOf(t time.Time) string { return applog.HourOf(t) } |
| 1226 | |
| 1227 | // EncodeChunk renders records as gzipped JSONL. |
| 1228 | func EncodeChunk(recs []Record) ([]byte, error) { return applog.EncodeJSONL(recs) } |
| 1229 | |
| 1230 | // DecodeChunk parses a chunk, skipping malformed lines. |
| 1231 | func 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). |
| 1236 | type 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. |
| 1245 | func (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. |
| 1277 | func 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. |
| 1292 | func 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 | |
| 1311 | Note 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 | |
| 1315 | Run: `go test ./internal/apperr/ -count=1 -v` |
| 1316 | Expected: PASS. |
| 1317 | |
| 1318 | - [ ] **Step 6: Commit** |
| 1319 | |
| 1320 | ```bash |
| 1321 | git add internal/apperr |
| 1322 | git commit -m "apperr: coalescing sink and errors/ chunk read (spec §3.2) |
| 1323 | |
| 1324 | Co-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 |
| 1345 | package apperr |
| 1346 | |
| 1347 | import ( |
| 1348 | "testing" |
| 1349 | "time" |
| 1350 | ) |
| 1351 | |
| 1352 | func 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 |
| 1385 | package apperr |
| 1386 | |
| 1387 | import ( |
| 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). |
| 1394 | type 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). |
| 1411 | func 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 | |
| 1454 | func 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 |
| 1470 | git add internal/apperr/group.go internal/apperr/group_test.go |
| 1471 | git commit -m "apperr: group records by fingerprint for the Errors tab |
| 1472 | |
| 1473 | Co-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 | |
| 1492 | In `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 | |
| 1494 | Run: `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 |
| 1501 | package host |
| 1502 | |
| 1503 | import ( |
| 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 | |
| 1513 | func 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 | |
| 1531 | func 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). |
| 1552 | package host |
| 1553 | |
| 1554 | import ( |
| 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 | |
| 1566 | const errshipTick = 10 * time.Second |
| 1567 | |
| 1568 | func 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. |
| 1604 | func 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. |
| 1616 | func 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 | |
| 1637 | func 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 | |
| 1658 | Beside `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 | |
| 1669 | Beside `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 | |
| 1671 | In 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 | |
| 1675 | Run: `go build ./... && go test ./internal/host/ -count=1` |
| 1676 | Expected: PASS. |
| 1677 | |
| 1678 | ```bash |
| 1679 | git add internal/host |
| 1680 | git commit -m "host: ship coalesced errors like logs; prune by chunk family |
| 1681 | |
| 1682 | Co-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 |
| 1705 | package activator |
| 1706 | |
| 1707 | import ( |
| 1708 | "net/http/httptest" |
| 1709 | "testing" |
| 1710 | ) |
| 1711 | |
| 1712 | func 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 | |
| 1726 | Append to `internal/edge/usagemeter_test.go`: |
| 1727 | |
| 1728 | ```go |
| 1729 | func 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 |
| 1759 | package edge |
| 1760 | |
| 1761 | import ( |
| 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 | |
| 1773 | type memErrSink struct { |
| 1774 | mu sync.Mutex |
| 1775 | recs []apperr.Record |
| 1776 | apps []string |
| 1777 | } |
| 1778 | |
| 1779 | func (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 | |
| 1786 | func 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. |
| 1840 | type answering503 struct{} |
| 1841 | |
| 1842 | func (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 | } |
| 1847 | func (answering503) RetireExcept(map[string]bool) {} |
| 1848 | |
| 1849 | func 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 | |
| 1873 | If `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 | |
| 1877 | Run: `go test ./internal/activator/ -run TestAnswerFlag -count=1; go test ./internal/edge/ -run 'TestCountingWriterObserves|TestEdgeEmits|TestEdgeSkips' -count=1` |
| 1878 | Expected: compile failures. |
| 1879 | |
| 1880 | - [ ] **Step 3: `internal/activator/answered.go`** |
| 1881 | |
| 1882 | ```go |
| 1883 | package activator |
| 1884 | |
| 1885 | import ( |
| 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"). |
| 1895 | type answeredKey struct{} |
| 1896 | |
| 1897 | // WithAnswerFlag returns r carrying a fresh flag and the flag itself. |
| 1898 | func 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. |
| 1904 | func MarkAnswered(r *http.Request) { |
| 1905 | if f, ok := r.Context().Value(answeredKey{}).(*bool); ok { |
| 1906 | *f = true |
| 1907 | } |
| 1908 | } |
| 1909 | ``` |
| 1910 | |
| 1911 | In `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 | |
| 1915 | In `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). |
| 1928 | type 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). |
| 1938 | func (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 | |
| 1951 | func (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 | |
| 1963 | Keep `Hijack` and `Flush` as they are. |
| 1964 | |
| 1965 | - [ ] **Step 5: Edge wiring** |
| 1966 | |
| 1967 | In `edge.go`: |
| 1968 | |
| 1969 | Add 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 | |
| 1977 | and: |
| 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. |
| 1983 | type ErrorSink interface { |
| 1984 | Add(acct, app string, r apperr.Record) |
| 1985 | } |
| 1986 | |
| 1987 | type 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. |
| 1991 | func (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. |
| 2001 | func (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 | |
| 2019 | Replace 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 | |
| 2051 | Add `"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 | |
| 2053 | Existing 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 | |
| 2057 | After `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 | |
| 2067 | Run: `go build ./... && go vet ./internal/edge/ ./internal/activator/ ./internal/host/ && go test ./internal/edge/ ./internal/activator/ ./internal/host/ -count=1` |
| 2068 | Expected: PASS, including every pre-existing usage, compress and cache test. |
| 2069 | |
| 2070 | - [ ] **Step 8: Commit** |
| 2071 | |
| 2072 | ```bash |
| 2073 | git add internal/activator/answered.go internal/activator/activator.go internal/activator/answered_test.go internal/edge internal/host/agent.go |
| 2074 | git commit -m "edge: the counting writer observes status; upstream 5xx become edge errors |
| 2075 | |
| 2076 | Installed unconditionally at the one site every response shape passes; |
| 2077 | the activator marks its own wake-path 503s so they are filed once, as the |
| 2078 | platform log error they already are (spec 2026-09-07 §3.2–3.3). |
| 2079 | |
| 2080 | Co-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 |
| 2110 | package telemetry |
| 2111 | |
| 2112 | import ( |
| 2113 | "context" |
| 2114 | "testing" |
| 2115 | |
| 2116 | "github.com/carlosframework/platform/internal/release" |
| 2117 | ) |
| 2118 | |
| 2119 | func 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. |
| 2170 | package telemetry |
| 2171 | |
| 2172 | import ( |
| 2173 | "context" |
| 2174 | "encoding/json" |
| 2175 | "errors" |
| 2176 | "fmt" |
| 2177 | "os" |
| 2178 | |
| 2179 | "github.com/carlosframework/platform/internal/release" |
| 2180 | ) |
| 2181 | |
| 2182 | const ( |
| 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". |
| 2192 | type Analytics struct { |
| 2193 | Count string `json:"count,omitempty"` |
| 2194 | Door string `json:"door,omitempty"` |
| 2195 | } |
| 2196 | |
| 2197 | // Errors is the errors half. |
| 2198 | type 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. |
| 2204 | type 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). |
| 2211 | func AccountKey(sqid string) string { return "accounts/" + sqid + "/telemetry.json" } |
| 2212 | |
| 2213 | // AppKey is the per-app override. |
| 2214 | func AppKey(sqid, app string) string { return release.AppPrefix(sqid, app) + "telemetry.json" } |
| 2215 | |
| 2216 | // ValidLogLevel reports whether s is a level the setting accepts. |
| 2217 | func 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. |
| 2221 | func 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. |
| 2238 | func 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". |
| 2264 | type 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. |
| 2272 | func 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 | |
| 2305 | Check 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 |
| 2310 | git add internal/telemetry |
| 2311 | git commit -m "telemetry: the two-level settings object behind Errors and Analytics (spec §3.3) |
| 2312 | |
| 2313 | Co-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 |
| 2338 | package console |
| 2339 | |
| 2340 | import ( |
| 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 | |
| 2354 | func 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. |
| 2364 | func 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 | |
| 2382 | func 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 | |
| 2424 | func 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 | |
| 2454 | func 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 |
| 2495 | package 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 | |
| 2502 | import ( |
| 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. |
| 2514 | type 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). |
| 2526 | func (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. |
| 2575 | func 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 | |
| 2608 | var validFP = regexp.MustCompile(`^[A-Za-z0-9_.-]{0,64}$`) |
| 2609 | ``` |
| 2610 | |
| 2611 | - [ ] **Step 4: `apierrors.go`** |
| 2612 | |
| 2613 | ```go |
| 2614 | package console |
| 2615 | |
| 2616 | import ( |
| 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. |
| 2628 | type 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. |
| 2638 | func (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. |
| 2669 | func (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 | |
| 2703 | Register 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 | |
| 2712 | Page 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 | |
| 2725 | Routes (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 | |
| 2733 | Handlers (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. |
| 2738 | func (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. |
| 2768 | func (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. |
| 2803 | func (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 | |
| 2837 | Confirm `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}}. |
| 2883 | Counts are to the nearest flush. Messages are redacted for addresses and credentials before they are stored; treat that as a floor, not a guarantee.</p> |
| 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 | |
| 2945 | Check 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 | |
| 2951 | Run: `go build ./... && go vet ./internal/console/ && go test ./internal/console/ -count=1` |
| 2952 | Expected: PASS (the whole package; template parse errors surface at server construction in every test). |
| 2953 | |
| 2954 | - [ ] **Step 7: Commit** |
| 2955 | |
| 2956 | ```bash |
| 2957 | git add internal/console |
| 2958 | git commit -m "console: Errors tab, errors API, and the log-level setting at both levels (spec §3.6) |
| 2959 | |
| 2960 | Co-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 |
| 2980 | package main |
| 2981 | |
| 2982 | import ( |
| 2983 | "bytes" |
| 2984 | "strings" |
| 2985 | "testing" |
| 2986 | "time" |
| 2987 | |
| 2988 | "github.com/carlosframework/platform/internal/apperr" |
| 2989 | ) |
| 2990 | |
| 2991 | func 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`. |
| 3014 | package main |
| 3015 | |
| 3016 | import ( |
| 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 | |
| 3029 | const errorsUsage = `usage: carlos errors --app <a> [flags] |
| 3030 | |
| 3031 | Lists error groups, newest first: one row per fingerprint with its count in |
| 3032 | the window, the source it came from (log, edge, server, browser), the hosts |
| 3033 | and 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 | |
| 3037 | func 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 | |
| 3098 | func 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. |
| 3111 | func 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. |
| 3132 | func 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 | |
| 3158 | In `apimode.go`, beside `logsGet`: |
| 3159 | |
| 3160 | ```go |
| 3161 | type 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 | |
| 3169 | func (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 | |
| 3192 | func (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 | |
| 3204 | Find 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 | |
| 3213 | One row per distinct error, newest first, with how many times it happened |
| 3214 | in the window and where. Two sources need no wiring at all: log lines the |
| 3215 | app tagged `error` (or `warn`, if you set the level) and responses of 500 |
| 3216 | or above that the edge saw. Server-side and browser hooks come later. |
| 3217 | |
| 3218 | ```sh |
| 3219 | carlos errors --app hello --since 24h |
| 3220 | carlos errors --app hello --fp 3f2a9c1b5e7d0a4c |
| 3221 | carlos errors --app hello --set-level warn |
| 3222 | ``` |
| 3223 | |
| 3224 | `--fp` expands one group to its occurrences. `--level warn` widens a single |
| 3225 | read without changing the setting; `--set-level` changes it for the app. |
| 3226 | The 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 | |
| 3231 | Run: `go build ./... && go test . -run TestPrintErrorGroups -count=1 && go test ./internal/docsite/ -count=1` |
| 3232 | Expected: 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 |
| 3237 | git add errors_cmd.go errors_cmd_test.go apimode.go main.go docs/site/cli.md |
| 3238 | git commit -m "carlos errors: the app's error groups from a terminal, and the log-level setting |
| 3239 | |
| 3240 | Co-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 | |
| 3251 | Run: |
| 3252 | ```bash |
| 3253 | go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1 |
| 3254 | GOOS=darwin GOARCH=arm64 go build ./... && GOOS=windows GOARCH=amd64 go build ./... |
| 3255 | ``` |
| 3256 | Expected: all clean; `gofmt -l .` prints nothing. |
| 3257 | |
| 3258 | - [ ] **Step 2: Spec status line** |
| 3259 | |
| 3260 | In `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 |
| 3263 | git add docs/superpowers |
| 3264 | git commit -m "spec: approved; step 1 plan |
| 3265 | |
| 3266 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| 3267 | ``` |
| 3268 | |
| 3269 | - [ ] **Step 3: Push and describe** |
| 3270 | |
| 3271 | ```bash |
| 3272 | git push origin spec-errors-analytics |
| 3273 | amadan branch describe carlos/platform spec-errors-analytics -body - < <description file> |
| 3274 | amadan ci status carlos/platform -branch spec-errors-analytics |
| 3275 | ``` |
| 3276 | |
| 3277 | Do 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 | |