carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Errors and Analytics: two tabs every app gets for free
2
3**Date:** 2026-09-07
4**Status:** approved (Paul, 2026-09-07: "go ahead with that and plan, build
5review cycle overnight"). Rollout step 1 (§7) is merged (forge main 1a99060; plan
6`docs/superpowers/plans/2026-09-07-errors-step1-log-and-edge.md`). Step 2 is
7implemented on branch `analytics-step2` (plan
8`docs/superpowers/plans/2026-09-07-analytics-step2-edge-counting.md`). Every
9choice below is marked **(decision)** and can be overturned cheaply; the
10four in §8 are Paul's **(ruling)**s of 2026-09-07 and are marked where
11they land. Drafted by Claude Fable,
12then reviewed by a second Fable context and by Codex; both returned
13needs-rework on the first draft and §9 records what they changed. Line numbers cite `origin/main` at `09a22d6`.
14
15## 1. The idea, and why it fits this platform
16
17A member who ships an app to CARLOS today gets a URL, a certificate, logs,
18schedules, and a bill. What they do not get is the two questions every
19operator asks within a day of launch: *is it broken* and *is anyone using
20it*. The answers live in AppSignal, Datadog, Sentry, PostHog, Plausible: a
21second account, a second bill, an SDK to wire in, and for the analytics
22half a cookie banner to justify.
23
24The platform is unusually well placed to answer both questions itself,
25because it already stands in the two places the data passes through:
26
27- **The edge sees every request** to every app, including the ones its
28 cache answers without waking the instance (`writeCached`,
29 `internal/edge/edge.go:1311`). Method, path, status, referer and user
30 agent are in hand at the point where `countingWriter` is installed to
31 bill the bytes (`edge.go:1241`). Server-side page-view counting there
32 costs the app nothing and needs no JavaScript and no cookie. It is the
33 Plausible model, done where Plausible cannot stand.
34- **The agent already captures every line the app writes** and ships it
35 as hour chunks to the bucket (`internal/applog`,
36 `internal/host/logship.go`). A line the app tagged `level=error` is
37 already an error event; it is just filed under Logs, undifferentiated
38 from a million info lines. And when the app dies, the edge answers a
39 502 or reports a wake failure: the crash is already observed.
40
41So the first version of both tabs is mostly *reading what is already
42flowing past*. The hooks add the cases the platform cannot see on its own:
43a public endpoint on the app's own hostname for browser errors and named
44events, and a loopback verb for server-side errors and events. The member
45who wants AppSignal can still rent it; the event shape is deliberately
46close to what those services accept, so a "forward to" setting later is a
47translator, not a redesign (§5).
48
49The proposal is two tabs on the app dashboard, **Errors** and
50**Analytics**, between Logs and Activity in `appnav.html`, each with a
51console API and a `carlos` verb behind it (AGENTS.md: every command must
52complete the job for a user with no infra access).
53
54## 2. What is already right, and must stay right
55
56**Logs are the pattern for events.** `applog.Record`
57(`internal/applog/record.go:72`) is buffered on the box under a token
58budget (`sink.go:95`), flushed as gzip'd JSONL hour chunks under
59`apps/<sqid>/<app>/logs/<box>/<hour>/`, read back by `applog.ReadWindow`
60merging chunks across boxes, pruned by `logshipPrune`
61(`logship.go:161`) on age and byte cap, and served through
62`GET /api/cli/apps/{account}/{app}/logs` (`authcli.go:595`) with a cursor.
63Errors that the platform must store are events too; they reuse this
64machinery rather than growing a second one (§3.2).
65
66**Usage is the pattern for counters.** `usage.EdgeDay`
67(`internal/usage/edge.go`) is a CAS-merged day file, hour-bucketed, one
68per box, flushed every five minutes by `usageLoop`
69(`internal/host/usage_loop.go:27`) from `UsageMeter.Drain()`. Page views
70and visitors are counters; they reuse this shape, with two durability
71fixes the reviewers asked for (§3.3).
72
73**The control socket is the pattern for a loopback API.** `ListenControl`
74(`internal/edge/purge.go:108`) mounts verbs on a 0600 unix socket the app
75finds in `CARLOS_CONTROL_SOCKET`; `schedule.Mount`
76(`internal/schedule/control.go:41`) shows the bearer check against
77`CARLOS_ADMIN_TOKEN`. Server-side ingestion mounts beside it (§3.5). The
78socket is not the activator: a call on it never reaches `beginRequest`.
79
80**Nothing the platform does for itself may keep an instance awake.** The
81scheduled-ticks spec (2026-08-30) exists because one line of accounting
82cost a member fifteen minutes of billed residency per millisecond of
83work. Every ingest path here is handled in the edge or the agent and none
84enters `activator.Service.ServeHTTP`; a beacon from a browser to a
85hibernating app is answered by the edge and the app stays asleep (§4).
86
87**The edge's wrapper order is load-bearing** (AGENTS.md): recorder above
88the compressor, usage counter below it, wrap site after the upgrade
89branch. This design changes what the usage counter *observes*, never
90where it sits (§3.3).
91
92**Fleet boxes write through the store door**, whose write grant is
93segment-exact `apps/<sqid>/<app>/logs/**` (`aclWriteAppLogs`,
94`internal/edge/storedoor.go:503`), and usage is rebased under
95`fleets/<name>/` (`usage_loop.go:47`). Two new families need the door to
96know about them, or a fleet box's writes are refused (§3.2).
97
98## 3. The change
99
100### 3.1 One event shape, four sources
101
102Both tabs are fed by events in one JSON shape. **(decision)** Fields:
103
104```
105ts RFC3339 nanoseconds, stamped by the platform, never taken from input
106source "log" | "edge" | "server" | "browser"
107kind errors: exception class or "http"; analytics: "pageview" | "event"
108name errors: the message; analytics: the event name
109host route host that received it (how owners name instances)
110box the box that recorded it
111release X-Carlos-Version of the serving build, when known
112path request path, query and fragment removed, token segments collapsed (§3.3)
113status HTTP status, when the event came from a request
114referer host-only, never the full URL
115ua browser family only, never the raw string
116stack errors only, capped at 8 KB
117fields map[string]string of caller-supplied scalars, 32 keys, 256 B each
118fp errors only: fingerprint (§3.2)
119fpv errors only: fingerprint algorithm version, 1
120```
121
122Sources and where they enter:
123
124| source | errors | analytics |
125|---------|----------------------------------------------------------|---------------------------------------------|
126| log | applog record at or above `errors.log_level`, read-derived | none |
127| edge | upstream 5xx, gateway 502/504, wake failure | every HTML 200 GET becomes a pageview |
128| server | `POST /errors` on the control socket | `POST /events` on the control socket |
129| browser | `POST /__carlos/e` on the app's own host | `POST /__carlos/a` on the app's own host |
130
131`log` and `edge` need no integration at all. They are why the Errors tab
132is not empty on day one for any app in the fleet, and why Analytics
133starts counting the moment an account turns it on (§3.3).
134
135**Sanitisation before buffering (decision, Codex).** Errors carry
136messages, stacks, paths and caller fields, and those carry secrets in
137practice. Before any record is buffered, on every source: query strings
138and fragments are removed from `path`; in `name`, `stack` and `fields`,
139email addresses become `<email>`, `Bearer`/`Basic` credentials and
140`token=`/`key=`/`secret=`/`password=` values become `<redacted>`, and
141hex or base64 runs of 32+ characters become `<hex>`. The browser snippet
142applies the same rules before sending, so nothing leaves the page that
143the platform would strip. This is a floor, not a guarantee; the Errors
144tab says so in one line.
145
146### 3.2 Errors: sources, fingerprints, coalescing, storage
147
148**The `log` source is derived on read, not stored twice (decision, Fable
149review 7).** The first draft mirrored `level=error` lines into a second
150family. `ReadWindow` with `Filter.Level="error"` (`filter.go:22`) over
151`logs/` yields the same rows with the same retention, and a mirror would
152have had to choose a side of the token budget at `sink.go:95`. So the
153Errors tab reads two families and merges: `logs/` filtered to the
154levels the `errors.log_level` setting names (§3.3; `error` means
155`error`+`fatal`, `warn` adds `warn`; **a setting, per Paul's ruling of
1562026-09-07**), and `errors/` for the other three sources. Whatever the
157setting, every row carries its level and the tab, API and CLI filter on
158it, so a member on `warn` can look at errors only and a member on
159`error` can widen one read to `warn` without changing the setting. A
160JSON line whose `level` is unrecognised is not an error; `ParseAppLine` never
161guesses, and neither does this. A plain-text `panic:` on stderr carries no
162level and is not a `log` error either: the crash reaches the tab through
163the `edge` source when the next request meets a dead instance, which is
164the honest signal.
165
166**Edge errors (decision).** Where the observing writer (§3.3) sees a
167final status of 500–599 from the upstream, or the edge itself answers
168502/504 because the instance did not come up, it emits `source: edge,
169kind: http, name: "<status> <method> <path>"`. Wake failures already reach
170the member through `emitEvent` in `internal/activator/logsink.go`; the
171same call site sends one record to the errors sink. To keep that exactly
172once, the activator branch marks the request context when it answered
173the failure itself, and the observing writer skips a 502/504 that
174carries the mark; the writer's own emission covers only statuses the
175upstream returned or the proxy produced without the activator. Client 4xx are not errors.
176
177**Fingerprint (decision, amended per both reviews).** `fp` groups
178occurrences into one row. A caller may supply one, capped at 64 bytes.
179Otherwise the platform computes `sha256(len-delimited(source, kind,
180norm(name), norm(top frame)))[:16]` with `fpv: 1`. `norm` splits on whitespace
181and punctuation; a token that is a digit run of 4+ characters, a hex run
182of 8+, a UUID, or a quoted string becomes a placeholder; tokens of 1–3
183digits and mixed tokens like `v2` or `E404` are kept, so `HTTP 503`,
184`v2` and `retry 3` still separate groups while `user 4123` and
185`user 9911` do not. Small ids under 1,000 will split groups; that is the
186stated trade-off, and `fp` overrides it. A frame is normalised to file and function with line,
187column and bundle hash removed, so a redeploy does not split every
188browser group. The rule is versioned so it can change without merging
189old groups into new ones. It is wrong sometimes; a caller who knows
190better sets `fp`.
191
192**Coalescing at the box (decision, amended per Codex 8).** An error storm
193is when the tab must work and when a naive stream fails. The box sink
194keeps, per `(app, fp, host, release)` per flush interval, one record
195carrying `count`, `first`, `last`, and the first sample's `stack`, `path`,
196`status` and `fields`. Ten thousand identical panics in a minute become
197one record with `count: 10000`. Coalesced records are capped per app per
198hour at 1,000, counting `(fp, host, release)` combinations, not distinct
199fingerprints, so the memory bound is real (per app, not per box: one
200tenant's storm must not suppress a neighbour's, Codex 9); beyond that,
201the overflow folds into a single `_overflow` row whose `count` is
202occurrences, labelled as such on the tab. A record belongs to a read
203window when `[first, last]` overlaps it and is counted whole; the flush
204interval is five minutes, so a window edge is wrong by at most one
205interval, and the tab's window labels say "to the nearest flush".
206(Correction, 2026-09-24: the ship loop as built flushes every 10 s,
207`errshipTick` in `internal/host/errship.go`, so records coalesce over
20810 s, not five minutes. Found reviewing the request-reference spec.)
209
210**Storage (decision, amended per Fable 6).** `errors/` is a sibling family
211of `logs/`: `apps/<sqid>/<app>/errors/<box>/<hour>/<seq>-<size>.jsonl.gz`.
212The chunk key and hour layout are shared, so `ChunkKey`, `Prefix`,
213`BoxPrefix`, `HourPrefix`, `ChunkKeySize`, `hourNameOfDir`, `hourNameOf`,
214`logshipPrune` and `logshipLoop` take a family argument. The record is
215not shared: `applog.Record` has none of `fp`, `count`, `first`, `last`,
216`stack`, `source`, `kind`, `status`, `path`, `release`, and `EncodeChunk`,
217`DecodeChunk`, `ReadWindow`, `Filter.Match` and `Cursor` all take
218`Record`. The codec becomes generic over the record type, or `Record` is
219widened with `omitempty` fields; the implementer picks whichever leaves
220`logs/` chunks byte-identical, and the existing applog tests are the
221proof. The consumer list for the key shape, per AGENTS.md, is:
222`ChunkKeySize` (`chunk.go:65`), `hourNameOfDir` (`read.go:91`),
223`hourNameOf` (`logship.go:216`), `aclWriteAppLogs` (`storedoor.go:503`),
224`logshipPrune` (`logship.go:161`). Wire shape is additive: nothing under
225`logs/` changes.
226
227**Store door (decision, Fable 3).** `aclWriteAppLogs` becomes a family
228allow-list: `logs`, `errors`, `analytics`, each with the same
229"at least one component below" rule, with door tests for every family
230and for `errors` as an app name. Without this a fleet box's writes are
231refused and the tabs are empty for exactly the members on fleets.
232
233### 3.3 Analytics: counting at the edge
234
235**Where the edge sees it (decision, Fable 2).** The first draft assumed a
236tap on the recorder. The recorder is built only on the hibernating and
237cacheable branches (`edge.go:1322, 1333`); a cache hit goes through
238`writeCached` (`:1311`) and the plain path (`:1339`), every POST included,
239has no recorder at all. The one wrapper installed at a single site for
240every response shape is `countingWriter` (`usagemeter.go:64`), which today
241has no `WriteHeader` and is installed only when a meter is armed
242(`edge.go:1241`). It becomes an *observing* writer: installed
243unconditionally with a nil-safe meter, latching final status and
244`Content-Type` at the first `WriteHeader` of 200 or above (1xx are
245forwarded and ignored), an implicit 200 at first `Write`, and a 200 with
246no `Content-Type` when the handler returns having written nothing, which
247is never a pageview. `Flush` and `Hijack` forward as they do today. The
248meter wrap today skips `Kind == "status"` routes (`edge.go:1241`); the
249observing writer is installed there too, but the tap ignores status
250routes, so the status host never enters any app's analytics. It
251stays below the compressor; `Content-Type` is visible there because
252`gzipWriter.syncHeader` copies its private map down before the underlying
253`WriteHeader` (`compress.go:236`). HEAD skips gzip (`compress.go:197`), so
254the tap excludes by method itself. The upgrade branch (`:1257`) is
255untouched; a 101 is never a pageview. The order rule in AGENTS.md is
256preserved: nothing moves, one wrapper learns to look.
257
258**What is counted (decision, amended per Codex 3).** A request is a
259*pageview* when the method is GET, the final status is 200, and the
260response media type parses as `text/html`. Redirects are not views (the
261destination will be); 304s are not views (no media type, and a
262revalidated page is an undercount the tab states); 204/205 are not;
263requests with `Sec-Purpose: prefetch` or `Purpose: prefetch` are not;
264`Sec-Fetch-Dest`, when present, must be `document` (an `empty` dest is
265`fetch()`, which is how htmx and SPAs load HTML fragments, and a fragment
266is not a page; an absent header is an old UA or a bot, and the bot list
267handles the latter). Requests under
268`/__carlos/` are never counted. A request whose user agent matches the
269vendored bot list is counted into `bots` and nowhere else; the list is
270incomplete by nature and the tab shows `bots` so the member can see what
271was excluded.
272
273**Client IP (decision, Fable 5).** There is no client-IP value in the
274edge today. `clientIP(r)` is defined as: the last element of
275`X-Forwarded-For` (all header lines joined, as `setForwarded` does at
276`homemap.go:915`) when `hopVerified(ctx)` (`homemap.go:767` accepts a
277verified chain verbatim; `:800` strips an unverified one), otherwise the
278host of `RemoteAddr`. A header the client can set never reaches it.
279
280**Visitor (decision, amended per Codex 1 and Fable 10).** A visitor is
281`sha256(day-salt ‖ route host ‖ clientIP ‖ user agent)[:8]`. The salt is
282per box per UTC day, generated at first use, written 0600 to the box's
283state directory beside the registry DB (`/data/`, which the agent
284already owns) so a restart mid-day does not re-salt and double-count,
285and deleted on rotation; it is never written to the bucket. That
286location is Fable's recommendation, confirmed against who owns the
287directory; Codex's ask was for a restart-stable, app-scoped salt shared
288by every box serving the app, which the persisted file gives for the
289first half and which the home-box rule makes unnecessary for the
290second.
291No cookie is set, no IP is stored, and the platform holds nothing that
292joins a hash across days. Because a route is served by its home box (a
293box without the row backhauls, `edge.go:1216`), a visitor is normally
294seen by one box; during an instance move they are counted twice for a
295day. Multi-day ranges on the tab are labelled "daily visitors, summed",
296because that is what they are; Plausible's are too.
297
298**Cardinality (decision, amended per Codex 10).** Unique visitors per day
299per box are a HyperLogLog sketch with 2¹¹ registers (2 KB, standard error
300about 2.3%), one per day, encoded as base64 with a one-byte version
301prefix, merged across boxes by register max. Pageviews per hour are
302integers. Top pages, top referers and named events are Misra–Gries
303summaries with 500 counters each: bounded, mergeable, and correct for any
304app with fewer than 500 distinct keys a day, which is most of them. The
305reader re-folds after merging so the tab never shows more than 500 rows
306plus `_other`. Below 500 keys the counts are exact; above, a counter is a
307lower bound within the summary's error and the table is labelled
308"approximate: more than 500 distinct keys today". Keys are capped at 256
309bytes.
310
311**Page paths (decision, Fable 10).** `pages` keys are the sanitised
312`path` of §3.1: query and fragment removed, and any segment that is a
313UUID, a hex run of 16+ characters, a base64-looking run of 20+, or an
314email replaced by `:token`. `/reset/8f3a…` never reaches the bucket;
315`/blog/2024-review` does.
316
317**Storage (decision, amended per Codex 13).**
318`apps/<sqid>/<app>/analytics/<box>/days/<date>.json`, written by a
319sibling of `UpsertEdgeDay` with the same read-etag-merge-conditional-put
320loop, and two differences from usage. First, buckets are keyed by *event
321time*, not flush time, so a flush delayed across an hour boundary lands
322in the right hour. Second, the drain returns deltas, and a failed flush
323re-queues its deltas into the next drain rather than losing them
324(`usageLoop` documents that it loses the window; modelling grade is fine
325for billing estimates, not for a number a member reads as truth). One
326window survives a failure; a second consecutive failure drops the older
327one and increments `dropped.flush`. A conditional put whose outcome is
328unknown (timeout after send) must not double-count on retry: every delta
329batch carries a box-monotonic `seq`, the day file records `seq` per box,
330and a merge whose `seq` is not greater than the recorded one is a no-op. A process restart loses the in-memory
331window, stated on the tab as "counts since the last flush may be
332missing" only when a restart happened that day. Shape:
333
334```json
335{
336 "v": 1,
337 "date": "2026-09-07",
338 "hours": {"09": {"pv": 412, "bots": 30, "bytes": 1843201}},
339 "visitors": "AQ…",
340 "pages": {"/": 300, "/pricing": 80, "_other": 32},
341 "referers": {"news.ycombinator.com": 200, "direct": 180},
342 "events": {"signup": 12, "checkout": 3},
343 "seq": {"box-a": 1187},
344 "dropped": {"e": {"rate": 0, "size": 0, "origin": 0, "malformed": 0},
345 "a": {"rate": 0, "size": 0, "origin": 0, "malformed": 0},
346 "flush": 0},
347 "countries": {}
348}
349```
350
351`countries` is present and empty in v1 so a GeoIP pass is additive.
352`dropped` is what the member sees when a cap or a failure bit: the tab
353shows it, so a number that is not the whole truth says so.
354
355**Named events.** `carlos.track("signup")` from the browser, or
356`POST /events {"name":"signup"}` from the server, increments
357`events[name]`. Names are lowercased, 64 bytes of `[a-z0-9_.-]`, and
358bounded by the same 500-counter summary. Event properties are accepted in
359`fields` and *not stored* in v1 (§5).
360
361**Accumulation.** The edge holds deltas in memory; the agent's
362five-minute `usageLoop` cadence flushes them in the same tick as usage,
363as a second CAS write (two objects, two writes; the first draft's "one
364round-trip" was wrong).
365
366**The settings object (decision, Fable 9; default is a ruling).**
367`features.json` is console-only and no box reads it. The switches are one
368bucket object at two levels with the same shape:
369
370```
371accounts/<sqid>/telemetry.json account default
372apps/<sqid>/<app>/telemetry.json per-app override
373{"analytics": {"count": "edge" | "off", "door": "on" | "off"},
374 "errors": {"log_level": "error" | "warn"}}
375```
376
377Each key resolves app → account → built-in default, so an account can
378turn analytics on once for every app it has and a single app can still
379differ. The console writes either object with a serial bump and boxes
380converge on the next pass like `instances/*.json`; the account object
381lives beside `account.json`, which stays identity-only. Built-in
382defaults: **`count: off` (ruling, Paul, 2026-09-07)**, `door: on`,
383`log_level: error`. Analytics is therefore opt-in: the visitor design is
384built to be defensible without consent, but whether it is consent-free
385is the deployment's assessment and the member's, not the platform's, so
386the platform does not switch it on for anyone. `count: off` stops
387counting at the observing writer and makes both doors drop analytics
388events (`/__carlos/a` and the socket's `/events`) with `202`; existing
389day files stay until retention. `door: off` closes the browser door
390entirely, errors included, for an owner who wants no public ingest
391surface at all. `"count": "browser"` is reserved for a later mode (§5)
392so the shape is additive.
393
394Because the default is off, the tab is not empty when it is off: it
395shows the switch, what turning it on records, and the one-line privacy
396reading, so a member decides from the tab rather than finding a setting
397by accident. The `getting-started` skill that `carlos skills` fetches
398from carlosframework.com (published from the website repo, not here)
399gains one question in the same step: whether to turn analytics on for
400the account, with the same one-line reading. That is where an agent
401setting up a member's first app will ask it.
402
403### 3.4 The browser door
404
405**Where in the edge (decision, Fable 1, Codex 6).** The first draft said
406"before any routing". That is the wrong spot: the app is known only from
407`up.route`, which exists after `lookupUpstreamPath` (`edge.go:1197`), and
408a box that has no row for the host must backhaul (`:1216`) so the home
409box answers. The intercept sits between the lookup (`:1197`) and the
410meter wrap (`:1241`): after routing, before upgrade (`:1257`), gzip
411(`:1275`), cache (`:1311`) and the activator (`:1322`). At that spot
412beacons are unbilled like `status` routes and `beginRequest` is never
413reached, which is what §4 promises.
414
415**Attribution on a shared hostname.** `lookupUpstreamPath` can route
416different path prefixes on one hostname to different apps (`:1008`). A
417beacon carries no app id. The snippet puts `location.pathname` in every
418beacon's `path`, errors and events alike, and the beacon is attributed
419to the upstream the lookup returns for that *raw* path on that hostname
420(the match runs before §3.1 sanitisation, which is applied to what is
421stored), and to the hostname's root app when `path` is absent or matches
422nothing. Because
423`Origin` must equal that hostname, the sender is already a page served
424from it: same origin, same trust boundary the browser already grants
425those siblings. An unknown hostname takes the existing `!ok` branch and
426is never answered by this code. The lookup must never accept `__carlos` as
427a prefix key; a test pins that.
428
429**Endpoint (decision).**
430
431```
432POST /__carlos/e one error {kind, name, stack, path, fields, fp}
433POST /__carlos/a one event {name, fields} | {"name":"pageview","path":…}
434GET /__carlos/carlos.js loader, Cache-Control: max-age=300, must-revalidate
435GET /__carlos/carlos.<hash>.js the snippet, immutable
436```
437
438Both POSTs answer `202` with an empty body in every case: accepted,
439rate-limited, rejected, malformed. The endpoint reveals nothing, and the
440browser never sees a failure worth retrying. Every drop increments the
441app's `dropped.rate` (or `dropped.origin`, `dropped.size`), which the
442tab shows, so silence outward is not silence to the owner (Codex 11).
443`/__carlos/` is reserved on all routes and never proxied upstream from
444this change on. No app in this repo serves it; §8 asks about the
445deployment roots.
446
447**Who may post (decision, amended per Codex 11, Fable 12).** The request
448must carry an `Origin` whose scheme is `https` and whose host and port
449resolve, through the edge's own map (`lookupIn(e.current(), originHost)`,
450the same lookup the request itself took), to an entry whose `route.Host`
451equals `up.route.Host`; aliases share the primary's route verbatim, so
452this covers them with no registry call on the hot path. A bad
453`Origin` is rejected even if `Referer` passes; `Referer` is consulted
454only when `Origin` is absent. `Sec-Fetch-Site: cross-site` is rejected
455outright. This is identification, not authentication: a direct client
456can forge any of it, as it can against every public beacon on the
457internet, and a per-app public key would add revocation but not truth.
458What bounds the damage is that a forged post can only put rows on the
459owner's own tab, under the owner's own caps, and the owner can turn the
460door off. Body is capped at 16 KB; `stack` at 8 KB.
461
462**Rate (decision, amended per Codex 9).** Token buckets per *app* of
463600/min for errors and 600/min for events, separately, and per visitor
464hash of 60/min, all in edge memory, all dropping silently and counting
465`dropped.rate`. Visitor-bucket state is bounded to 10,000 entries per app
466with LRU eviction. Errors are then coalesced by fingerprint at the box as
467in §3.2, so a page throwing in a loop on ten thousand screens is one row
468with a large count and, if it exceeds the bucket, an honest `dropped`.
469
470**The snippet (decision).** `carlos.js` is vendored into the edge binary
471the way Lucide icons are vendored into the console: in-code, never a
472CDN. It installs `window.onerror` and `unhandledrejection` handlers that
473post to `/__carlos/e` after §3.1 sanitisation, and exposes
474`carlos.track(name, fields)` which posts to `/__carlos/a` via
475`navigator.sendBeacon`. **It does not send pageviews in edge mode.** The
476edge already counted the page when it served the HTML; a JS pageview
477would double every number. What edge mode misses, stated on the tab: SPA
478route changes, HTML served by a CDN in front of CARLOS, and back/forward
479cache restores. The reserved `"count": "browser"` mode (§5) is the
480answer for those apps, not a same-second dedup heuristic. Until then an
481SPA may call `carlos.track("route", {path})` and see it as a named
482event. Members include the snippet with one line:
483
484```html
485<script src="/__carlos/carlos.js" defer></script>
486```
487
488### 3.5 The server door
489
490**Verbs (decision).** `errors.Mount` and `analytics.Mount` follow
491`schedule.Mount` exactly: same bearer check, same 0600 socket, same
492`host` binding.
493
494```
495POST /errors {kind, name, stack, path, status, fields, fp}
496POST /events {name, fields}
497```
498
499The app reads `CARLOS_CONTROL_SOCKET` and `CARLOS_ADMIN_TOKEN` from its
500environment as it already does for schedules. A Rails app wires it in an
501`ActionDispatch` error subscriber; a Go app in an `http.Handler`
502middleware. The confused-deputy discussion in `purge.go` applies
503unchanged: a same-uid sibling that can read the socket can post a fake
504error, and the worst it can do is put a row on the owner's own tab. A
505call on the control socket does not stamp `lastActive`: `ListenControl`
506serves its own mux (wired at `backend_exec.go:624`) and never enters
507`Service.serve`. The same 16 KB body cap, 8 KB stack cap and 600/min
508per-app buckets as the browser door apply, counted into the same
509`dropped` keys; the socket answers `202` on a drop and `400` only on a
510body it cannot parse, since the caller here is the owner's own code and
511can act on it.
512
513Unit-backed and DynamicUser tenants have no control socket today and so
514have no server door; they have `log` and `edge` sources and the browser
515door like everyone else. This is the same gap `purge.go` records for
516schedules, and it closes when that one does.
517
518### 3.6 The tabs, the API, the CLI
519
520**Errors tab** (`GET /a/{sqid}/apps/{app}/errors`, `handleAppErrors`
521through `appPage(w, r, "errors")`, template `app_errors.html`,
522`ActiveTab: "errors"`). Default window 24 h, selectable 1 h / 24 h / 7 d.
523One row per fingerprint: source badge, kind, name, count in window, first
524seen, last seen, hosts affected, releases seen. Rows sort by last seen. A
525row expands in place to the stored sample: stack, path, status, fields,
526and a link to Logs at that timestamp. Filters: source, level, host,
527release.
528Grouping happens on read over the window. `ReadWindow` fetches the whole
529window and discards beyond `logsCollectCap` (50,000), which bounds
530memory, not bucket reads and not totals (Codex 14): when the cap is hit
531the tab says "showing the most recent 50,000 of a larger window" and the
532counts are labelled as counts within what was read. A day rollup is not
533built until a real app shows the read is too slow (§5).
534
535**Analytics tab** (`/apps/{app}/analytics`). Range today / 7 d / 30 d.
536Headline: pageviews, visitors (daily, summed for ranges), bytes served,
537bots excluded, dropped. One chart, per hour for today and per day
538otherwise, drawn the way `templates/metrics.html:29` draws sparklines:
539points computed in Go, an inline `<svg>` in the template, no library.
540Three tables: top pages, top referers, named events.
541
542**API (decision, Fable 8).** Two `cliRoute` paths beside the logs read:
543
544```
545GET /api/cli/apps/{account}/{app}/errors svcrole.Allow(Instance, Operate)
546GET /api/cli/apps/{account}/{app}/analytics svcrole.Allow(Instance, Operate)
547```
548
549Bearer-authenticated through `apiAccountApp` like `/logs`; an app token
550may read its own app's errors and analytics. The session → CSRF → owner
551(byte-identical 404) → fresh-auth ordering applies to the two *pages*,
552which go through `appPage`; the API paths have no session and no CSRF by
553construction. The tabs render from the same functions the API answers
554with.
555
556**CLI.** `carlos errors -app X [-since 24h] [-source browser] [-level warn]
557[-host h] [-release v] [-fp <id>]` lists groups, `-fp` expands one;
558`carlos analytics -app X [-range 7d]` prints the headline, chart as a
559text sparkline, and the three tables. Both get headings in
560`docs/site/cli.md` in the same branch, or `internal/docsite` fails the
561gate.
562
563**Settings.** The three keys of §3.3 on the app Settings tab and on the
564account settings page, and via `carlos apps` and `carlos accounts`,
565writing the two objects. Error *capture* has no switch: an app that is
566failing is an operational fact the owner needs; `log_level` only widens
567what the `log` source counts. A member may
568delete an error group from the tab or the API (`DELETE
569…/errors/{fp}`, owner on the page, `Operate` token on the API). This
570writes a tombstone `apps/<sqid>/<app>/errors/tombstones/<fp>.json`
571carrying the deletion time; the reader hides records of that `fp` with
572`last` before that time, from both `errors/` and the log-derived rows,
573until retention removes the chunks. A later occurrence reappears: this
574is delete, not mute (§5). Logs themselves are untouched; deleting log
575lines is a Logs feature and not part of this design.
576
577**Retention (decision).** Error chunks follow log retention, both age and
578byte cap, in `logshipPrune`. Analytics day files are kept 400 days,
579enough for year-over-year, and are small enough (tens of KB per app-day)
580that no byte cap is needed; the same prune loop deletes past the age.
581
582**Coverage line.** Each tab carries one line saying which sources are
583live for this app: "log, edge" for an app with nothing wired, "+browser"
584once a beacon has arrived, "+server" once the socket verb has. A member
585should never wonder whether silence means no errors or no wiring.
586
587## 4. The consequence to be explicit about: nothing here wakes an instance
588
589Every path in §3 is checked against the scheduled-ticks rule:
590
591- `/__carlos/*` is answered between route lookup and the meter wrap, before
592 the cache and the activator. A beacon to a hibernating app returns `202`
593 from the edge; `beginRequest` is never called and the idle clock is not
594 stamped. The test reads `a.entries[host].lastActive` directly, as
595 `activator_test.go:1378` does.
596- Analytics counting is a tap on responses the edge is already sending.
597- The `log` source is a read over chunks the agent already ships.
598- The server door is a control-socket verb, which does not stamp.
599- Flushes ride the existing `usageLoop` and `logshipLoop` tickers in the
600 agent.
601
602The one new cost is bucket writes: one analytics day-file CAS per box per
603five minutes per app with traffic, and error chunks at the cadence of log
604chunks. Both are bounded by the caps in §3.2–3.3 regardless of traffic.
605Memory on the edge is bounded per app: 500-counter summaries, one 2 KB
606sketch, a 10,000-entry visitor bucket table, and the coalescing map
607capped at 1,000 fingerprints.
608
609## 5. What is not built
610
611- **Resolve and mute.** An owner will want to mark a group resolved or
612 mute a known one. It is the first triage feature to add and comes
613 before alerts; the tombstone in §3.6 is its seed.
614- **Alerts.** "Email me when a new fingerprint appears" belongs with the
615 existing `alertwatch` machinery, not here.
616- **Browser-counted mode** (`"count": "browser"`): the snippet sends
617 pageviews with SPA route support and prerender activation, and the edge
618 stops counting HTML. For apps behind a CDN or built as SPAs.
619- **Event properties.** `fields` on analytics events are accepted and
620 dropped; property breakdowns change the day-file shape additively.
621- **Error day rollups.** Grouping reads the window every time. Fine to
622 50k records; a heavy app will tell us when it is not.
623- **Forwarding.** A `forward: sentry|posthog|…` setting that relays
624 events to a rented service. The shape in §3.1 makes this a translator.
625- **Source maps, breadcrumbs, session replay, funnels, GeoIP, per-country
626 tables.** Rent those.
627- **Fleet-wide views.** Errors across a member's apps, or across the
628 fleet for ops, are a different page with different gates.
629
630## 6. Testing
631
632- **Sanitisation:** table test over messages, stacks, fields and paths
633 containing emails, bearer tokens, `password=`, 32-char hex and base64
634 runs; each is replaced and nothing else is touched.
635- **Fingerprint:** table test of `norm`: digits, hex, UUIDs and quoted
636 strings are placeholders; `v2`, `E404`, `HTTP 503` survive; frames lose
637 line, column and bundle hash; a caller-supplied `fp` is taken verbatim
638 and capped; `fpv` is stamped.
639- **Coalescing:** 10,000 identical errors in one interval produce one
640 record with `count: 10000` and `first < last`; the same `fp` on two
641 hosts produces two records; 1,001 distinct fingerprints for one app
642 produce 1,000 records and one `_overflow`; a second app on the same box
643 is unaffected.
644- **Log-derived rows:** a `level=error` JSON line appears on the Errors
645 read and in Logs; `level=info`, an unparseable line, and a plain
646 `panic:` line do not appear as `log` errors.
647- **Edge errors:** an upstream 500 on a POST (the plain path), a 502 from
648 a failed wake, and a 500 on a cacheable GET each produce exactly one
649 edge error; a 404 produces none.
650- **Chunk family:** `logs/` chunks written and read after the refactor
651 are byte-identical to before; every existing applog test passes
652 unchanged; `errors/` round-trips the new record.
653- **Store door:** a fleet box may write `apps/<sqid>/<app>/errors/x` and
654 `…/analytics/x` and may not write `apps/<sqid>/errors/x`,
655 `apps/<sqid>/<app>/errors`, or `apps/<sqid>/<app>/other/x`.
656- **Observing writer:** status and `Content-Type` latch at `WriteHeader`
657 and at implicit 200; visible below gzip; installed with a nil meter;
658 byte counts unchanged for every existing usage test.
659- **Pageview definition:** `httptest` through `Edge.ServeHTTP` with a fake
660 upstream: HTML 200 GET counts; JSON 200, HTML 404, HTML 302, 304, HEAD,
661 `Sec-Purpose: prefetch`, `/__carlos/carlos.js` do not; a cache hit
662 counts; `Sec-Fetch-Dest: empty` does not; a known bot UA counts only
663 into `bots`; a `status` route counts nothing; `count: off` counts
664 nothing.
665- **Client IP:** verified hop takes the last XFF element; unverified hop
666 and direct take `RemoteAddr`; a client-set XFF on a direct request is
667 ignored.
668- **Visitor hash:** same IP+UA on the same day is one visitor; a
669 different day is different; the salt survives a restart and never
670 appears in the day file; the salt file is removed on rotation.
671- **HLL:** across ten random datasets of 1k, 10k and 100k, the estimate is
672 within 3 standard errors of truth; merge is commutative and idempotent;
673 the encoding round-trips with its version byte.
674- **Misra–Gries:** with fewer than 500 keys the summary is exact; two
675 summaries merge and re-fold to 500 plus `_other` with the total
676 preserved.
677- **Day-file CAS:** event-time bucketing places a flush at 10:00:02 for
678 events at 09:59 into `09`; a failed flush re-queues and the next
679 succeeds with the sum; two failures drop the older window and increment
680 `dropped.flush`; two boxes writing the same app-day converge to the sum
681 of pageviews and the union of sketches.
682- **Browser door:** correct `Origin` counts; missing `Origin` with matching
683 `Referer` counts; foreign `Origin` with matching `Referer` does not;
684 `Sec-Fetch-Site: cross-site` does not; 17 KB body does not; the 601st
685 post in a minute does not; each drop increments the right `dropped`
686 key; every case answers `202`. A hibernating route receives a beacon
687 and `lastActive` is unchanged. On a path-routed hostname the beacon
688 lands on the app owning its `path` prefix. `__carlos` is refused as a
689 prefix-map key.
690- **`/__carlos/` reservation:** a route whose upstream serves `/__carlos/x`
691 never sees the request; the loader revalidates and the hashed asset is
692 immutable.
693- **Server door:** a bad bearer is refused; a good one lands a record;
694 `lastActive` is unchanged after the call; a 17 KB body answers `202`
695 and increments `dropped.e.size`; `count: off` drops `/events` and
696 still accepts `/errors`.
697- **Seq idempotency:** re-merging a batch with an already-recorded `seq`
698 leaves the day file unchanged.
699- **Tombstone:** after `DELETE`, the group is absent from the read and
700 the API; a new occurrence after the deletion time reappears; Logs
701 still shows the underlying lines.
702- **Wake failure once:** a failed wake produces one edge error, not two.
703- **Console gates:** both pages give a non-member the byte-identical 404
704 the Logs tab gives; both API paths refuse a token without `Instance` or
705 `Operate`.
706- **Settings resolution:** with no object at either level nothing is
707 counted and `log_level` is `error`; an account object with
708 `count: edge` turns counting on for every app; an app object with
709 `count: off` under that account turns it off for that app alone;
710 writing either and bumping the serial converges on the box within the
711 next pass; the browser door drops `/__carlos/a` and still accepts
712 `/__carlos/e` when off; `door: off` drops both; `log_level: warn`
713 brings warn lines into the Errors read and `-level error` filters
714 them back out.
715- **Docsite:** the gate passes with both new CLI headings present and
716 fails with either removed.
717
718## 7. Rollout
719
720Each step is a separate branch that ships value on its own and needs no
721member action. Each is canaried on the flagship for a day against edge
722latency, edge memory, bucket write count and the hibernation invariant
723before promotion, in the ladder the fleet already runs.
724
7251. **Errors from `log` and `edge` sources, tab, API, CLI.** Every app in
726 the fleet gets a populated Errors tab. Requires the chunk-family
727 refactor, the store-door allow-list, the observing writer (for edge
728 errors), and the coalescing sink.
7292. **Analytics counting at the edge, tab, API, CLI, the settings
730 objects at both levels, and the skill question.** An account that
731 turns it on gets pageviews and visitors for every app with no
732 JavaScript. Shares the observing writer with step 1, so it lands
733 second. The `getting-started` skill change is a companion branch in
734 the website repo, landed the same day.
7353. **Browser door and `carlos.js`.** Members opt in with one script tag;
736 JS errors and named events appear.
7374. **Server door on the control socket.** Exec-backed apps post
738 structured errors and events.
739
740Wire shapes are additive at every step: `logs/` is untouched, `errors/`
741and `analytics/` are new prefixes, `telemetry.json` absent means off, and
742a box on an older build writes none of them. A mixed-version fleet shows
743the coverage line per host so a missing source is visible rather than
744puzzling.
745
746Binaries roll as usual: promote → adopt → restart for the console; edge
747and agent pick up on adoption.
748
749## 8. Rulings (Paul, 2026-09-07)
750
751Four questions were open in the draft; Paul answered all four.
752
7531. **Analytics is off by default (ruling).** Settable at the account
754 level so one switch covers every app, overridable per app, and asked
755 about in the `getting-started` skill. §3.3 carries the objects and
756 the resolution order; §7 step 2 carries the skill change.
7572. **The reserved prefix is `/__carlos/` (ruling).** Paul asked whether a
758 random per-app identifier would be better for unpredictability. The
759 recommendation is no, and the spec stays static: the path is not a
760 secret and cannot be one, because the snippet URL is in every page's
761 source and the beacon endpoint is public by construction (§3.4).
762 What bounds abuse is the Origin check, the caps, `dropped`, and the
763 `door` switch, none of which a random path would strengthen. A
764 random path would also cost the one-line snippet its one line and
765 would make the reservation rule ("this prefix is never proxied
766 upstream") per-app state instead of a constant. The double underscore
767 buys the collision safety Paul wanted: no app in this repo serves it,
768 and nothing conventional does.
7693. **Warn level is a setting, and level is always filterable (ruling).**
770 `errors.log_level` in §3.3 at either level, `error` by default; the
771 tab, API and CLI filter on level regardless (§3.2, §3.6).
7724. **The salt lives beside the registry DB, 0600 (Fable's
773 recommendation, taken).** §3.3 records both reviewers' positions:
774 Fable named the location and confirmed the agent owns it; Codex asked
775 for restart-stability and cross-box sharing, and the persisted file
776 gives the first while the home-box rule makes the second unnecessary.
777
778The `platform-infrastructure` collision check for `/__carlos/` is still
779worth one grep before step 3 ships; the double underscore makes a hit
780unlikely enough that it is a checklist line, not an open question.
781
782## 9. Review round 1
783
784Both reviewers read the first draft against `main` and returned
785needs-rework. What each changed:
786
787**Fable (second context).** Moved the beacon intercept from "before
788routing" to between `lookupUpstreamPath` and the meter wrap, with the
789backhaul argument (§3.4). Replaced the recorder tap with the observing
790`countingWriter`, citing the three response paths that bypass the
791recorder (§3.3). Found that fleet boxes' writes would be refused by
792`aclWriteAppLogs` (§3.2). Corrected the §1 citations, which pointed at
793ACME and attribution comments. Defined `clientIP` from the verified-hop
794chain (§3.3). Listed the real refactor scope and the key-shape consumer
795list, and corrected `retentionPass` to `logshipPrune` (§3.2, §3.6).
796Argued the log mirror away in favour of a read-side filter (§3.2). Named
797the `cliRoute` API pattern and the gate-ordering scope (§3.6). Found that
798no box reads `features.json`, so the switch became a bucket object
799(§3.3). Sanitised page paths (§3.3). Corrected the chart precedent, the
800Logs-tab grep, and the non-existent test hook (§3.6, §4). Noted the
801500-key merge overflow (§3.3).
802
803**Codex.** Made the visitor salt restart-stable and the multi-day
804visitor figure honest (§3.3). Reframed the privacy claim as the
805deployment's assessment (§8). Tightened the pageview definition to 200
806only, excluding redirects, 304s, HEAD and prefetch (§3.3). Reserved a
807browser-counted mode instead of a same-second dedup (§3.4, §5). Named the
808path-routed attribution problem independently of Fable (§3.4). Made
809fingerprints token-boundary, versioned, and frame-normalised (§3.2). Made
810the coalesced record carry `first`, `last`, host and release (§3.2). Made
811caps per app and exposed `dropped` everywhere something is dropped (§3.2,
812§3.3, §3.4). Replaced first-admitted maps with Misra–Gries and stated the
813HLL error correctly (§3.3). Tightened Origin matching and kept the
814uniform 202 only alongside owner-visible drop counts (§3.4). Added
815sanitisation before buffering and owner-triggered deletion (§3.1, §3.6).
816Made day-file buckets event-time and flushes re-queue on failure (§3.3).
817Added the collect-cap disclosure and the coverage line (§3.6). Split the
818snippet into a revalidating loader and a hashed immutable asset (§3.4).
819Put resolve/mute ahead of alerts in §5. Noted that a plain `panic:` line
820is not a parsed error, which §3.2 now answers through the `edge` source.
821
822Not taken: a per-app public key for the browser door (identification
823without truth, and the caps already bound the damage; §3.4 says why);
824storing the raw pageview path (the token-collapsing rule keeps real page
825names and drops secrets); a mirror of `log` errors into `errors/` (read
826derivation is cheaper and loses nothing).
827
828## 10. Review round 2
829
830Both reviewers re-read the rewrite. Fable: approve-with-amendments, all
831three blockers confirmed resolved against the code; amendments taken:
832the Origin check is a `lookupIn` on the edge's map, not an alias list
833(`upstream.alias` is a bool); `Sec-Fetch-Dest: empty` is a fetch, not a
834page; status routes are excluded from the tap; joined `X-Forwarded-For`
835lines; salt file 0600. Codex: needs-rework on under-specification;
836taken: 1–3 digit tokens survive normalisation so `HTTP 503` does; the
837snippet stamps `path` on every beacon so path-routed events attribute;
838the coalescing cap counts records and the window rule is stated; a
839`seq` per box makes CAS retries idempotent; a `door` switch closes the
840browser door independently of counting and `count: off` also drops the
841socket's `/events`; the server door gets the same size and rate limits;
842Misra–Gries tables are labelled approximate above 500 keys; the
843`dropped` schema lists every key it uses, split by errors and events;
844wake failures are suppressed at the writer when the activator already
845emitted; tombstones have a scope, an authorisation, and a stated
846non-effect on Logs. Nothing from round 2 was declined.
847
848## 11. Amendment 2026-09-24: Availability
849
850**Why.** A day of the flagship's Errors tab (titogo, 2026-09-23) held 29
851groups: 25 edge 502s, 3 edge 503s and one 500. Only the 500 was a failure
852of what a request asked for. The rest said the instance was down,
853restarting or unreachable when some page was asked for, and §3.2's
854`"<status> <method> <path>"` fingerprint split each outage into one row
855per page that happened to hit it. 17 of the 29 rows were one scanner
856probing `/.env`, `/phpinfo.php` and so on against a dead canary. The
857record also could not say *why* the edge had no answer: the proxies
858logged the error and threw it away.
859
860**Availability is the Errors tab's sibling (decision, Bill 2026-09-24).**
861A sub-tab between Errors and Analytics (`GET /a/{sqid}/apps/{app}/availability`),
862an API path beside `/errors` with the same gate
863(`GET /api/cli/apps/{account}/{app}/availability`), and `carlos
864availability`. It holds:
865
866- every edge-observed 502, 503 and 504, whoever wrote it; and
867- the platform stream's events at `warn` and above (failed wakes,
868 exits, a sidecar that will not restart), whatever `errors.log_level`
869 says, since an exit on a signal is filed at `warn` and is exactly the
870 event an outage row needs beside it. The synthetic rate-cap line
871 (`applog.IsDropNotice`) is about the logging budget and is left out.
872
873Errors keeps the rest: the app's own 500, 501 and 505+, and the `log`
874source's app-stream lines. Nothing is in both.
875
876**Grouped by cause, never by path (decision).** An edge 502/503/504 is
877filed as `kind: unavailable` with `cause` and `name: "<status> · <cause
878in the member's terms>"`, and so fingerprinted by status and cause alone;
879the path stays on the record as the occurrence's sample. The cause comes
880from the ErrorHandler that wrote the status, through a per-request
881`outcome` on the context (`edge/answered.go`, which already carried the
882activator's answered flag and now rides every observed request, not only
883hibernating ones):
884
885| cause | where |
886|---------------|--------------------------------------------------------------|
887| `refused` | ECONNREFUSED / ENOENT on the dial: nothing listening |
888| `reset` | EOF, unexpected EOF, ECONNRESET, EPIPE: hung up mid-request |
889| `timeout` | a deadline or a net timeout |
890| `abandoned` | the client left after waiting 5 s or more (below) |
891| `no_upstream` | `multiProxy`: every upstream failing its probe |
892| `fleet_dark` | `fleetProxy`: no box of the fleet connected |
893| `tunnel` | any other failure through a fleet box's tunnel |
894| `static` | a static site's files could not be resolved |
895| `proxy` | any other proxy failure |
896| `upstream` | no handler noted one: the app wrote the status itself (direct routes only; below) |
897
898`unixProxy`, `tcpProxy` and `tunnelProxy` had no ErrorHandler; they get
899one that is `httputil.ReverseProxy`'s default (same log line, same bare
900502) plus the note.
901
902**A visitor who left quickly is not an outage; one kept waiting is
903(decision, amended in review).** When the client's own context is done by
904the time a proxy fails, `ReverseProxy` still writes a 502, to nobody, and
905the error is the cancellation whatever the instance was doing. Within
9065 s of the request (`clientGoneGrace`) the edge notes `client_gone` and
907files nothing: a visitor navigating away mid-load is not the instance's
908fault, and a single-count 502 on a font file is the typical shape of it.
909Past 5 s it files `abandoned`. The review caught why that half matters:
910a dead unix socket is redialled for 13 s and a wedged instance is never
911timed out at all, so a monitor or visitor giving up first is how those
912outages end — skipping them would hide exactly the worst ones.
913
914**Behind a tunnel, the front box files only the hop (decision, Fable
915review; corrected in its follow-up).** When a box proxies a request over
916a reverse channel, the upstream is not the app but another box's edge —
917the agent's, the only tunnel client — which files what it sees serving
918the app, and deliberately files nothing for a wake 503 its own activator
919answered. A 5xx that merely passed back through the front box was
920therefore filed twice, and under this amendment would have been labelled
921"the app answered it itself" for an app that never saw the request. The
922front box now files only failures it noted itself (`tunnel`,
923`fleet_dark`, `abandoned`); everything else is the serving box's record.
924This also ends the double filing of tunnelled 500s, which predates the
925amendment.
926
927The rule keys on how THIS box served the request (`upstream.viaTunnel`,
928the build-time decision), never on the route row: the fleet box that
929serves the app carries the same `fleet/<name>` placement on its own row,
930has no hub, and serves from the socket — it must file everything. (The
931first cut keyed on the row, and a fleet-placed app's 500s were filed
932nowhere; `TestEdgeServingFleetBoxFilesItsOwn` pins the far end.) Two
933edges remain: a far agent whose status store failed to open has no error
934sink and files nothing, which the front box no longer covers; and a
935client that leaves past the grace can be filed `abandoned` on both boxes,
936since the stream reset reaches the far box's request too.
937
938**The overflow row is per family (decision, from review).** The sink's
939hourly `_overflow` row copied its source, kind and status from the first
940record that overflowed, so one family's excess could land on the other
941tab. It is now one row per family (`IsAvailability`), and
942`AsAvailability` leaves an `_overflow` row alone.
943
944**History regroups on read (decision).** Records filed before this carry
945`kind: http`, no `cause`, and the per-path fingerprint. The read
946recognises them by status and regroups them as `"<status> · cause not
947recorded"` (`apperr.AsAvailability`); the FP filter applies after the
948regroup so the row the tab shows expands. The bucket is not rewritten.
949
950**Not changed.** The wire shape is additive (`cause` and a read-only
951`stream` on `apperr.Record`, both `omitempty`); nothing under `logs/` or
952`errors/` moves. The sending half of a backhaul still files nothing when
953its hop fails: it has no route row and so no app to file under, the
954same reason it bills nothing. Correlating a row with the nearest deploy or restart
955on that host, and alerting on a new cause, are the natural next steps
956and are not built here.
957