carlos / platform Public
Errors and Analytics: two tabs every app gets for free
Date: 2026-09-07 Status: approved (Paul, 2026-09-07: "go ahead with that and plan, build review cycle overnight"). Rollout step 1 (§7) is merged (forge main 1a99060; plan docs/superpowers/plans/2026-09-07-errors-step1-log-and-edge.md). Step 2 is implemented on branch analytics-step2 (plan docs/superpowers/plans/2026-09-07-analytics-step2-edge-counting.md). Every choice below is marked (decision) and can be overturned cheaply; the four in §8 are Paul's (ruling)s of 2026-09-07 and are marked where they land. Drafted by Claude Fable, then reviewed by a second Fable context and by Codex; both returned needs-rework on the first draft and §9 records what they changed. Line numbers cite origin/main at 09a22d6.
1. The idea, and why it fits this platform
A member who ships an app to CARLOS today gets a URL, a certificate, logs, schedules, and a bill. What they do not get is the two questions every operator asks within a day of launch: is it broken and is anyone using it. The answers live in AppSignal, Datadog, Sentry, PostHog, Plausible: a second account, a second bill, an SDK to wire in, and for the analytics half a cookie banner to justify.
The platform is unusually well placed to answer both questions itself, because it already stands in the two places the data passes through:
- The edge sees every request to every app, including the ones its cache answers without waking the instance (
writeCached,internal/edge/edge.go:1311). Method, path, status, referer and user agent are in hand at the point wherecountingWriteris installed to bill the bytes (edge.go:1241). Server-side page-view counting there costs the app nothing and needs no JavaScript and no cookie. It is the Plausible model, done where Plausible cannot stand. - The agent already captures every line the app writes and ships it as hour chunks to the bucket (
internal/applog,internal/host/logship.go). A line the app taggedlevel=erroris already an error event; it is just filed under Logs, undifferentiated from a million info lines. And when the app dies, the edge answers a 502 or reports a wake failure: the crash is already observed.
So the first version of both tabs is mostly reading what is already flowing past. The hooks add the cases the platform cannot see on its own: a public endpoint on the app's own hostname for browser errors and named events, and a loopback verb for server-side errors and events. The member who wants AppSignal can still rent it; the event shape is deliberately close to what those services accept, so a "forward to" setting later is a translator, not a redesign (§5).
The proposal is two tabs on the app dashboard, Errors and Analytics, between Logs and Activity in appnav.html, each with a console API and a carlos verb behind it (AGENTS.md: every command must complete the job for a user with no infra access).
2. What is already right, and must stay right
Logs are the pattern for events. applog.Record (internal/applog/record.go:72) is buffered on the box under a token budget (sink.go:95), flushed as gzip'd JSONL hour chunks under apps/<sqid>/<app>/logs/<box>/<hour>/, read back by applog.ReadWindow merging chunks across boxes, pruned by logshipPrune (logship.go:161) on age and byte cap, and served through GET /api/cli/apps/{account}/{app}/logs (authcli.go:595) with a cursor. Errors that the platform must store are events too; they reuse this machinery rather than growing a second one (§3.2).
Usage is the pattern for counters. usage.EdgeDay (internal/usage/edge.go) is a CAS-merged day file, hour-bucketed, one per box, flushed every five minutes by usageLoop (internal/host/usage_loop.go:27) from UsageMeter.Drain(). Page views and visitors are counters; they reuse this shape, with two durability fixes the reviewers asked for (§3.3).
The control socket is the pattern for a loopback API. ListenControl (internal/edge/purge.go:108) mounts verbs on a 0600 unix socket the app finds in CARLOS_CONTROL_SOCKET; schedule.Mount (internal/schedule/control.go:41) shows the bearer check against CARLOS_ADMIN_TOKEN. Server-side ingestion mounts beside it (§3.5). The socket is not the activator: a call on it never reaches beginRequest.
Nothing the platform does for itself may keep an instance awake. The scheduled-ticks spec (2026-08-30) exists because one line of accounting cost a member fifteen minutes of billed residency per millisecond of work. Every ingest path here is handled in the edge or the agent and none enters activator.Service.ServeHTTP; a beacon from a browser to a hibernating app is answered by the edge and the app stays asleep (§4).
The edge's wrapper order is load-bearing (AGENTS.md): recorder above the compressor, usage counter below it, wrap site after the upgrade branch. This design changes what the usage counter observes, never where it sits (§3.3).
Fleet boxes write through the store door, whose write grant is segment-exact apps/<sqid>/<app>/logs/** (aclWriteAppLogs, internal/edge/storedoor.go:503), and usage is rebased under fleets/<name>/ (usage_loop.go:47). Two new families need the door to know about them, or a fleet box's writes are refused (§3.2).
3. The change
3.1 One event shape, four sources
Both tabs are fed by events in one JSON shape. (decision) Fields:
ts RFC3339 nanoseconds, stamped by the platform, never taken from input
source "log" | "edge" | "server" | "browser"
kind errors: exception class or "http"; analytics: "pageview" | "event"
name errors: the message; analytics: the event name
host route host that received it (how owners name instances)
box the box that recorded it
release X-Carlos-Version of the serving build, when known
path request path, query and fragment removed, token segments collapsed (§3.3)
status HTTP status, when the event came from a request
referer host-only, never the full URL
ua browser family only, never the raw string
stack errors only, capped at 8 KB
fields map[string]string of caller-supplied scalars, 32 keys, 256 B each
fp errors only: fingerprint (§3.2)
fpv errors only: fingerprint algorithm version, 1Sources and where they enter:
| source | errors | analytics | |---------|----------------------------------------------------------|---------------------------------------------| | log | applog record at or above errors.log_level, read-derived | none | | edge | upstream 5xx, gateway 502/504, wake failure | every HTML 200 GET becomes a pageview | | server | POST /errors on the control socket | POST /events on the control socket | | browser | POST /__carlos/e on the app's own host | POST /__carlos/a on the app's own host |
log and edge need no integration at all. They are why the Errors tab is not empty on day one for any app in the fleet, and why Analytics starts counting the moment an account turns it on (§3.3).
Sanitisation before buffering (decision, Codex). Errors carry messages, stacks, paths and caller fields, and those carry secrets in practice. Before any record is buffered, on every source: query strings and fragments are removed from path; in name, stack and fields, email addresses become <email>, Bearer/Basic credentials and token=/key=/secret=/password= values become <redacted>, and hex or base64 runs of 32+ characters become <hex>. The browser snippet applies the same rules before sending, so nothing leaves the page that the platform would strip. This is a floor, not a guarantee; the Errors tab says so in one line.
3.2 Errors: sources, fingerprints, coalescing, storage
The log source is derived on read, not stored twice (decision, Fable review 7). The first draft mirrored level=error lines into a second family. ReadWindow with Filter.Level="error" (filter.go:22) over logs/ yields the same rows with the same retention, and a mirror would have had to choose a side of the token budget at sink.go:95. So the Errors tab reads two families and merges: logs/ filtered to the levels the errors.log_level setting names (§3.3; error means error+fatal, warn adds warn; a setting, per Paul's ruling of 2026-09-07), and errors/ for the other three sources. Whatever the setting, every row carries its level and the tab, API and CLI filter on it, so a member on warn can look at errors only and a member on error can widen one read to warn without changing the setting. A JSON line whose level is unrecognised is not an error; ParseAppLine never guesses, and neither does this. A plain-text panic: on stderr carries no level and is not a log error either: the crash reaches the tab through the edge source when the next request meets a dead instance, which is the honest signal.
Edge errors (decision). Where the observing writer (§3.3) sees a final status of 500–599 from the upstream, or the edge itself answers 502/504 because the instance did not come up, it emits source: edge, kind: http, name: "<status> <method> <path>". Wake failures already reach the member through emitEvent in internal/activator/logsink.go; the same call site sends one record to the errors sink. To keep that exactly once, the activator branch marks the request context when it answered the failure itself, and the observing writer skips a 502/504 that carries the mark; the writer's own emission covers only statuses the upstream returned or the proxy produced without the activator. Client 4xx are not errors.
Fingerprint (decision, amended per both reviews). fp groups occurrences into one row. A caller may supply one, capped at 64 bytes. Otherwise the platform computes sha256(len-delimited(source, kind, norm(name), norm(top frame)))[:16] with fpv: 1. norm splits on whitespace and punctuation; a token that is a digit run of 4+ characters, a hex run of 8+, a UUID, or a quoted string becomes a placeholder; tokens of 1–3 digits and mixed tokens like v2 or E404 are kept, so HTTP 503, v2 and retry 3 still separate groups while user 4123 and user 9911 do not. Small ids under 1,000 will split groups; that is the stated trade-off, and fp overrides it. A frame is normalised to file and function with line, column and bundle hash removed, so a redeploy does not split every browser group. The rule is versioned so it can change without merging old groups into new ones. It is wrong sometimes; a caller who knows better sets fp.
Coalescing at the box (decision, amended per Codex 8). An error storm is when the tab must work and when a naive stream fails. The box sink keeps, per (app, fp, host, release) per flush interval, one record carrying count, first, last, and the first sample's stack, path, status and fields. Ten thousand identical panics in a minute become one record with count: 10000. Coalesced records are capped per app per hour at 1,000, counting (fp, host, release) combinations, not distinct fingerprints, so the memory bound is real (per app, not per box: one tenant's storm must not suppress a neighbour's, Codex 9); beyond that, the overflow folds into a single _overflow row whose count is occurrences, labelled as such on the tab. A record belongs to a read window when [first, last] overlaps it and is counted whole; the flush interval is five minutes, so a window edge is wrong by at most one interval, and the tab's window labels say "to the nearest flush". (Correction, 2026-09-24: the ship loop as built flushes every 10 s, errshipTick in internal/host/errship.go, so records coalesce over 10 s, not five minutes. Found reviewing the request-reference spec.)
Storage (decision, amended per Fable 6). errors/ is a sibling family of logs/: apps/<sqid>/<app>/errors/<box>/<hour>/<seq>-<size>.jsonl.gz. The chunk key and hour layout are shared, so ChunkKey, Prefix, BoxPrefix, HourPrefix, ChunkKeySize, hourNameOfDir, hourNameOf, logshipPrune and logshipLoop take a family argument. The record is not shared: applog.Record has none of fp, count, first, last, stack, source, kind, status, path, release, and EncodeChunk, DecodeChunk, ReadWindow, Filter.Match and Cursor all take Record. The codec becomes generic over the record type, or Record is widened with omitempty fields; the implementer picks whichever leaves logs/ chunks byte-identical, and the existing applog tests are the proof. The consumer list for the key shape, per AGENTS.md, is: ChunkKeySize (chunk.go:65), hourNameOfDir (read.go:91), hourNameOf (logship.go:216), aclWriteAppLogs (storedoor.go:503), logshipPrune (logship.go:161). Wire shape is additive: nothing under logs/ changes.
Store door (decision, Fable 3). aclWriteAppLogs becomes a family allow-list: logs, errors, analytics, each with the same "at least one component below" rule, with door tests for every family and for errors as an app name. Without this a fleet box's writes are refused and the tabs are empty for exactly the members on fleets.
3.3 Analytics: counting at the edge
Where the edge sees it (decision, Fable 2). The first draft assumed a tap on the recorder. The recorder is built only on the hibernating and cacheable branches (edge.go:1322, 1333); a cache hit goes through writeCached (:1311) and the plain path (:1339), every POST included, has no recorder at all. The one wrapper installed at a single site for every response shape is countingWriter (usagemeter.go:64), which today has no WriteHeader and is installed only when a meter is armed (edge.go:1241). It becomes an observing writer: installed unconditionally with a nil-safe meter, latching final status and Content-Type at the first WriteHeader of 200 or above (1xx are forwarded and ignored), an implicit 200 at first Write, and a 200 with no Content-Type when the handler returns having written nothing, which is never a pageview. Flush and Hijack forward as they do today. The meter wrap today skips Kind == "status" routes (edge.go:1241); the observing writer is installed there too, but the tap ignores status routes, so the status host never enters any app's analytics. It stays below the compressor; Content-Type is visible there because gzipWriter.syncHeader copies its private map down before the underlying WriteHeader (compress.go:236). HEAD skips gzip (compress.go:197), so the tap excludes by method itself. The upgrade branch (:1257) is untouched; a 101 is never a pageview. The order rule in AGENTS.md is preserved: nothing moves, one wrapper learns to look.
What is counted (decision, amended per Codex 3). A request is a pageview when the method is GET, the final status is 200, and the response media type parses as text/html. Redirects are not views (the destination will be); 304s are not views (no media type, and a revalidated page is an undercount the tab states); 204/205 are not; requests with Sec-Purpose: prefetch or Purpose: prefetch are not; Sec-Fetch-Dest, when present, must be document (an empty dest is fetch(), which is how htmx and SPAs load HTML fragments, and a fragment is not a page; an absent header is an old UA or a bot, and the bot list handles the latter). Requests under /__carlos/ are never counted. A request whose user agent matches the vendored bot list is counted into bots and nowhere else; the list is incomplete by nature and the tab shows bots so the member can see what was excluded.
Client IP (decision, Fable 5). There is no client-IP value in the edge today. clientIP(r) is defined as: the last element of X-Forwarded-For (all header lines joined, as setForwarded does at homemap.go:915) when hopVerified(ctx) (homemap.go:767 accepts a verified chain verbatim; :800 strips an unverified one), otherwise the host of RemoteAddr. A header the client can set never reaches it.
Visitor (decision, amended per Codex 1 and Fable 10). A visitor is sha256(day-salt ‖ route host ‖ clientIP ‖ user agent)[:8]. The salt is per box per UTC day, generated at first use, written 0600 to the box's state directory beside the registry DB (/data/, which the agent already owns) so a restart mid-day does not re-salt and double-count, and deleted on rotation; it is never written to the bucket. That location is Fable's recommendation, confirmed against who owns the directory; Codex's ask was for a restart-stable, app-scoped salt shared by every box serving the app, which the persisted file gives for the first half and which the home-box rule makes unnecessary for the second. No cookie is set, no IP is stored, and the platform holds nothing that joins a hash across days. Because a route is served by its home box (a box without the row backhauls, edge.go:1216), a visitor is normally seen by one box; during an instance move they are counted twice for a day. Multi-day ranges on the tab are labelled "daily visitors, summed", because that is what they are; Plausible's are too.
Cardinality (decision, amended per Codex 10). Unique visitors per day per box are a HyperLogLog sketch with 2¹¹ registers (2 KB, standard error about 2.3%), one per day, encoded as base64 with a one-byte version prefix, merged across boxes by register max. Pageviews per hour are integers. Top pages, top referers and named events are Misra–Gries summaries with 500 counters each: bounded, mergeable, and correct for any app with fewer than 500 distinct keys a day, which is most of them. The reader re-folds after merging so the tab never shows more than 500 rows plus _other. Below 500 keys the counts are exact; above, a counter is a lower bound within the summary's error and the table is labelled "approximate: more than 500 distinct keys today". Keys are capped at 256 bytes.
Page paths (decision, Fable 10). pages keys are the sanitised path of §3.1: query and fragment removed, and any segment that is a UUID, a hex run of 16+ characters, a base64-looking run of 20+, or an email replaced by :token. /reset/8f3a… never reaches the bucket; /blog/2024-review does.
Storage (decision, amended per Codex 13). apps/<sqid>/<app>/analytics/<box>/days/<date>.json, written by a sibling of UpsertEdgeDay with the same read-etag-merge-conditional-put loop, and two differences from usage. First, buckets are keyed by event time, not flush time, so a flush delayed across an hour boundary lands in the right hour. Second, the drain returns deltas, and a failed flush re-queues its deltas into the next drain rather than losing them (usageLoop documents that it loses the window; modelling grade is fine for billing estimates, not for a number a member reads as truth). One window survives a failure; a second consecutive failure drops the older one and increments dropped.flush. A conditional put whose outcome is unknown (timeout after send) must not double-count on retry: every delta batch carries a box-monotonic seq, the day file records seq per box, and a merge whose seq is not greater than the recorded one is a no-op. A process restart loses the in-memory window, stated on the tab as "counts since the last flush may be missing" only when a restart happened that day. Shape:
{
"v": 1,
"date": "2026-09-07",
"hours": {"09": {"pv": 412, "bots": 30, "bytes": 1843201}},
"visitors": "AQ…",
"pages": {"/": 300, "/pricing": 80, "_other": 32},
"referers": {"news.ycombinator.com": 200, "direct": 180},
"events": {"signup": 12, "checkout": 3},
"seq": {"box-a": 1187},
"dropped": {"e": {"rate": 0, "size": 0, "origin": 0, "malformed": 0},
"a": {"rate": 0, "size": 0, "origin": 0, "malformed": 0},
"flush": 0},
"countries": {}
}countries is present and empty in v1 so a GeoIP pass is additive. dropped is what the member sees when a cap or a failure bit: the tab shows it, so a number that is not the whole truth says so.
Named events. carlos.track("signup") from the browser, or POST /events {"name":"signup"} from the server, increments events[name]. Names are lowercased, 64 bytes of [a-z0-9_.-], and bounded by the same 500-counter summary. Event properties are accepted in fields and not stored in v1 (§5).
Accumulation. The edge holds deltas in memory; the agent's five-minute usageLoop cadence flushes them in the same tick as usage, as a second CAS write (two objects, two writes; the first draft's "one round-trip" was wrong).
The settings object (decision, Fable 9; default is a ruling). features.json is console-only and no box reads it. The switches are one bucket object at two levels with the same shape:
accounts/<sqid>/telemetry.json account default
apps/<sqid>/<app>/telemetry.json per-app override
{"analytics": {"count": "edge" | "off", "door": "on" | "off"},
"errors": {"log_level": "error" | "warn"}}Each key resolves app → account → built-in default, so an account can turn analytics on once for every app it has and a single app can still differ. The console writes either object with a serial bump and boxes converge on the next pass like instances/*.json; the account object lives beside account.json, which stays identity-only. Built-in defaults: count: off (ruling, Paul, 2026-09-07), door: on, log_level: error. Analytics is therefore opt-in: the visitor design is built to be defensible without consent, but whether it is consent-free is the deployment's assessment and the member's, not the platform's, so the platform does not switch it on for anyone. count: off stops counting at the observing writer and makes both doors drop analytics events (/__carlos/a and the socket's /events) with 202; existing day files stay until retention. door: off closes the browser door entirely, errors included, for an owner who wants no public ingest surface at all. "count": "browser" is reserved for a later mode (§5) so the shape is additive.
Because the default is off, the tab is not empty when it is off: it shows the switch, what turning it on records, and the one-line privacy reading, so a member decides from the tab rather than finding a setting by accident. The getting-started skill that carlos skills fetches from carlosframework.com (published from the website repo, not here) gains one question in the same step: whether to turn analytics on for the account, with the same one-line reading. That is where an agent setting up a member's first app will ask it.
3.4 The browser door
Where in the edge (decision, Fable 1, Codex 6). The first draft said "before any routing". That is the wrong spot: the app is known only from up.route, which exists after lookupUpstreamPath (edge.go:1197), and a box that has no row for the host must backhaul (:1216) so the home box answers. The intercept sits between the lookup (:1197) and the meter wrap (:1241): after routing, before upgrade (:1257), gzip (:1275), cache (:1311) and the activator (:1322). At that spot beacons are unbilled like status routes and beginRequest is never reached, which is what §4 promises.
Attribution on a shared hostname. lookupUpstreamPath can route different path prefixes on one hostname to different apps (:1008). A beacon carries no app id. The snippet puts location.pathname in every beacon's path, errors and events alike, and the beacon is attributed to the upstream the lookup returns for that raw path on that hostname (the match runs before §3.1 sanitisation, which is applied to what is stored), and to the hostname's root app when path is absent or matches nothing. Because Origin must equal that hostname, the sender is already a page served from it: same origin, same trust boundary the browser already grants those siblings. An unknown hostname takes the existing !ok branch and is never answered by this code. The lookup must never accept __carlos as a prefix key; a test pins that.
Endpoint (decision).
POST /__carlos/e one error {kind, name, stack, path, fields, fp}
POST /__carlos/a one event {name, fields} | {"name":"pageview","path":…}
GET /__carlos/carlos.js loader, Cache-Control: max-age=300, must-revalidate
GET /__carlos/carlos.<hash>.js the snippet, immutableBoth POSTs answer 202 with an empty body in every case: accepted, rate-limited, rejected, malformed. The endpoint reveals nothing, and the browser never sees a failure worth retrying. Every drop increments the app's dropped.rate (or dropped.origin, dropped.size), which the tab shows, so silence outward is not silence to the owner (Codex 11). /__carlos/ is reserved on all routes and never proxied upstream from this change on. No app in this repo serves it; §8 asks about the deployment roots.
Who may post (decision, amended per Codex 11, Fable 12). The request must carry an Origin whose scheme is https and whose host and port resolve, through the edge's own map (lookupIn(e.current(), originHost), the same lookup the request itself took), to an entry whose route.Host equals up.route.Host; aliases share the primary's route verbatim, so this covers them with no registry call on the hot path. A bad Origin is rejected even if Referer passes; Referer is consulted only when Origin is absent. Sec-Fetch-Site: cross-site is rejected outright. This is identification, not authentication: a direct client can forge any of it, as it can against every public beacon on the internet, and a per-app public key would add revocation but not truth. What bounds the damage is that a forged post can only put rows on the owner's own tab, under the owner's own caps, and the owner can turn the door off. Body is capped at 16 KB; stack at 8 KB.
Rate (decision, amended per Codex 9). Token buckets per app of 600/min for errors and 600/min for events, separately, and per visitor hash of 60/min, all in edge memory, all dropping silently and counting dropped.rate. Visitor-bucket state is bounded to 10,000 entries per app with LRU eviction. Errors are then coalesced by fingerprint at the box as in §3.2, so a page throwing in a loop on ten thousand screens is one row with a large count and, if it exceeds the bucket, an honest dropped.
The snippet (decision). carlos.js is vendored into the edge binary the way Lucide icons are vendored into the console: in-code, never a CDN. It installs window.onerror and unhandledrejection handlers that post to /__carlos/e after §3.1 sanitisation, and exposes carlos.track(name, fields) which posts to /__carlos/a via navigator.sendBeacon. It does not send pageviews in edge mode. The edge already counted the page when it served the HTML; a JS pageview would double every number. What edge mode misses, stated on the tab: SPA route changes, HTML served by a CDN in front of CARLOS, and back/forward cache restores. The reserved "count": "browser" mode (§5) is the answer for those apps, not a same-second dedup heuristic. Until then an SPA may call carlos.track("route", {path}) and see it as a named event. Members include the snippet with one line:
<script src="/__carlos/carlos.js" defer></script>3.5 The server door
Verbs (decision). errors.Mount and analytics.Mount follow schedule.Mount exactly: same bearer check, same 0600 socket, same host binding.
POST /errors {kind, name, stack, path, status, fields, fp}
POST /events {name, fields}The app reads CARLOS_CONTROL_SOCKET and CARLOS_ADMIN_TOKEN from its environment as it already does for schedules. A Rails app wires it in an ActionDispatch error subscriber; a Go app in an http.Handler middleware. The confused-deputy discussion in purge.go applies unchanged: a same-uid sibling that can read the socket can post a fake error, and the worst it can do is put a row on the owner's own tab. A call on the control socket does not stamp lastActive: ListenControl serves its own mux (wired at backend_exec.go:624) and never enters Service.serve. The same 16 KB body cap, 8 KB stack cap and 600/min per-app buckets as the browser door apply, counted into the same dropped keys; the socket answers 202 on a drop and 400 only on a body it cannot parse, since the caller here is the owner's own code and can act on it.
Unit-backed and DynamicUser tenants have no control socket today and so have no server door; they have log and edge sources and the browser door like everyone else. This is the same gap purge.go records for schedules, and it closes when that one does.
3.6 The tabs, the API, the CLI
Errors tab (GET /a/{sqid}/apps/{app}/errors, handleAppErrors through appPage(w, r, "errors"), template app_errors.html, ActiveTab: "errors"). Default window 24 h, selectable 1 h / 24 h / 7 d. One row per fingerprint: source badge, kind, name, count in window, first seen, last seen, hosts affected, releases seen. Rows sort by last seen. A row expands in place to the stored sample: stack, path, status, fields, and a link to Logs at that timestamp. Filters: source, level, host, release. Grouping happens on read over the window. ReadWindow fetches the whole window and discards beyond logsCollectCap (50,000), which bounds memory, not bucket reads and not totals (Codex 14): when the cap is hit the tab says "showing the most recent 50,000 of a larger window" and the counts are labelled as counts within what was read. A day rollup is not built until a real app shows the read is too slow (§5).
Analytics tab (/apps/{app}/analytics). Range today / 7 d / 30 d. Headline: pageviews, visitors (daily, summed for ranges), bytes served, bots excluded, dropped. One chart, per hour for today and per day otherwise, drawn the way templates/metrics.html:29 draws sparklines: points computed in Go, an inline <svg> in the template, no library. Three tables: top pages, top referers, named events.
API (decision, Fable 8). Two cliRoute paths beside the logs read:
GET /api/cli/apps/{account}/{app}/errors svcrole.Allow(Instance, Operate)
GET /api/cli/apps/{account}/{app}/analytics svcrole.Allow(Instance, Operate)Bearer-authenticated through apiAccountApp like /logs; an app token may read its own app's errors and analytics. The session → CSRF → owner (byte-identical 404) → fresh-auth ordering applies to the two pages, which go through appPage; the API paths have no session and no CSRF by construction. The tabs render from the same functions the API answers with.
CLI. carlos errors -app X [-since 24h] [-source browser] [-level warn] [-host h] [-release v] [-fp <id>] lists groups, -fp expands one; carlos analytics -app X [-range 7d] prints the headline, chart as a text sparkline, and the three tables. Both get headings in docs/site/cli.md in the same branch, or internal/docsite fails the gate.
Settings. The three keys of §3.3 on the app Settings tab and on the account settings page, and via carlos apps and carlos accounts, writing the two objects. Error capture has no switch: an app that is failing is an operational fact the owner needs; log_level only widens what the log source counts. A member may delete an error group from the tab or the API (DELETE …/errors/{fp}, owner on the page, Operate token on the API). This writes a tombstone apps/<sqid>/<app>/errors/tombstones/<fp>.json carrying the deletion time; the reader hides records of that fp with last before that time, from both errors/ and the log-derived rows, until retention removes the chunks. A later occurrence reappears: this is delete, not mute (§5). Logs themselves are untouched; deleting log lines is a Logs feature and not part of this design.
Retention (decision). Error chunks follow log retention, both age and byte cap, in logshipPrune. Analytics day files are kept 400 days, enough for year-over-year, and are small enough (tens of KB per app-day) that no byte cap is needed; the same prune loop deletes past the age.
Coverage line. Each tab carries one line saying which sources are live for this app: "log, edge" for an app with nothing wired, "+browser" once a beacon has arrived, "+server" once the socket verb has. A member should never wonder whether silence means no errors or no wiring.
4. The consequence to be explicit about: nothing here wakes an instance
Every path in §3 is checked against the scheduled-ticks rule:
/__carlos/*is answered between route lookup and the meter wrap, before the cache and the activator. A beacon to a hibernating app returns202from the edge;beginRequestis never called and the idle clock is not stamped. The test readsa.entries[host].lastActivedirectly, asactivator_test.go:1378does.- Analytics counting is a tap on responses the edge is already sending.
- The
logsource is a read over chunks the agent already ships. - The server door is a control-socket verb, which does not stamp.
- Flushes ride the existing
usageLoopandlogshipLooptickers in the agent.
The one new cost is bucket writes: one analytics day-file CAS per box per five minutes per app with traffic, and error chunks at the cadence of log chunks. Both are bounded by the caps in §3.2–3.3 regardless of traffic. Memory on the edge is bounded per app: 500-counter summaries, one 2 KB sketch, a 10,000-entry visitor bucket table, and the coalescing map capped at 1,000 fingerprints.
5. What is not built
- Resolve and mute. An owner will want to mark a group resolved or mute a known one. It is the first triage feature to add and comes before alerts; the tombstone in §3.6 is its seed.
- Alerts. "Email me when a new fingerprint appears" belongs with the existing
alertwatchmachinery, not here. - Browser-counted mode (
"count": "browser"): the snippet sends pageviews with SPA route support and prerender activation, and the edge stops counting HTML. For apps behind a CDN or built as SPAs. - Event properties.
fieldson analytics events are accepted and dropped; property breakdowns change the day-file shape additively. - Error day rollups. Grouping reads the window every time. Fine to 50k records; a heavy app will tell us when it is not.
- Forwarding. A
forward: sentry|posthog|…setting that relays events to a rented service. The shape in §3.1 makes this a translator. - Source maps, breadcrumbs, session replay, funnels, GeoIP, per-country tables. Rent those.
- Fleet-wide views. Errors across a member's apps, or across the fleet for ops, are a different page with different gates.
6. Testing
- Sanitisation: table test over messages, stacks, fields and paths containing emails, bearer tokens,
password=, 32-char hex and base64 runs; each is replaced and nothing else is touched. - Fingerprint: table test of
norm: digits, hex, UUIDs and quoted strings are placeholders;v2,E404,HTTP 503survive; frames lose line, column and bundle hash; a caller-suppliedfpis taken verbatim and capped;fpvis stamped. - Coalescing: 10,000 identical errors in one interval produce one record with
count: 10000andfirst < last; the samefpon two hosts produces two records; 1,001 distinct fingerprints for one app produce 1,000 records and one_overflow; a second app on the same box is unaffected. - Log-derived rows: a
level=errorJSON line appears on the Errors read and in Logs;level=info, an unparseable line, and a plainpanic:line do not appear aslogerrors. - Edge errors: an upstream 500 on a POST (the plain path), a 502 from a failed wake, and a 500 on a cacheable GET each produce exactly one edge error; a 404 produces none.
- Chunk family:
logs/chunks written and read after the refactor are byte-identical to before; every existing applog test passes unchanged;errors/round-trips the new record. - Store door: a fleet box may write
apps/<sqid>/<app>/errors/xand…/analytics/xand may not writeapps/<sqid>/errors/x,apps/<sqid>/<app>/errors, orapps/<sqid>/<app>/other/x. - Observing writer: status and
Content-Typelatch atWriteHeaderand at implicit 200; visible below gzip; installed with a nil meter; byte counts unchanged for every existing usage test. - Pageview definition:
httptestthroughEdge.ServeHTTPwith a fake upstream: HTML 200 GET counts; JSON 200, HTML 404, HTML 302, 304, HEAD,Sec-Purpose: prefetch,/__carlos/carlos.jsdo not; a cache hit counts;Sec-Fetch-Dest: emptydoes not; a known bot UA counts only intobots; astatusroute counts nothing;count: offcounts nothing. - Client IP: verified hop takes the last XFF element; unverified hop and direct take
RemoteAddr; a client-set XFF on a direct request is ignored. - Visitor hash: same IP+UA on the same day is one visitor; a different day is different; the salt survives a restart and never appears in the day file; the salt file is removed on rotation.
- HLL: across ten random datasets of 1k, 10k and 100k, the estimate is within 3 standard errors of truth; merge is commutative and idempotent; the encoding round-trips with its version byte.
- Misra–Gries: with fewer than 500 keys the summary is exact; two summaries merge and re-fold to 500 plus
_otherwith the total preserved. - Day-file CAS: event-time bucketing places a flush at 10:00:02 for events at 09:59 into
09; a failed flush re-queues and the next succeeds with the sum; two failures drop the older window and incrementdropped.flush; two boxes writing the same app-day converge to the sum of pageviews and the union of sketches. - Browser door: correct
Origincounts; missingOriginwith matchingReferercounts; foreignOriginwith matchingRefererdoes not;Sec-Fetch-Site: cross-sitedoes not; 17 KB body does not; the 601st post in a minute does not; each drop increments the rightdroppedkey; every case answers202. A hibernating route receives a beacon andlastActiveis unchanged. On a path-routed hostname the beacon lands on the app owning itspathprefix.__carlosis refused as a prefix-map key. /__carlos/reservation: a route whose upstream serves/__carlos/xnever sees the request; the loader revalidates and the hashed asset is immutable.- Server door: a bad bearer is refused; a good one lands a record;
lastActiveis unchanged after the call; a 17 KB body answers202and incrementsdropped.e.size;count: offdrops/eventsand still accepts/errors. - Seq idempotency: re-merging a batch with an already-recorded
seqleaves the day file unchanged. - Tombstone: after
DELETE, the group is absent from the read and the API; a new occurrence after the deletion time reappears; Logs still shows the underlying lines. - Wake failure once: a failed wake produces one edge error, not two.
- Console gates: both pages give a non-member the byte-identical 404 the Logs tab gives; both API paths refuse a token without
InstanceorOperate. - Settings resolution: with no object at either level nothing is counted and
log_leveliserror; an account object withcount: edgeturns counting on for every app; an app object withcount: offunder that account turns it off for that app alone; writing either and bumping the serial converges on the box within the next pass; the browser door drops/__carlos/aand still accepts/__carlos/ewhen off;door: offdrops both;log_level: warnbrings warn lines into the Errors read and-level errorfilters them back out. - Docsite: the gate passes with both new CLI headings present and fails with either removed.
7. Rollout
Each step is a separate branch that ships value on its own and needs no member action. Each is canaried on the flagship for a day against edge latency, edge memory, bucket write count and the hibernation invariant before promotion, in the ladder the fleet already runs.
- Errors from
logandedgesources, tab, API, CLI. Every app in the fleet gets a populated Errors tab. Requires the chunk-family refactor, the store-door allow-list, the observing writer (for edge errors), and the coalescing sink. - Analytics counting at the edge, tab, API, CLI, the settings objects at both levels, and the skill question. An account that turns it on gets pageviews and visitors for every app with no JavaScript. Shares the observing writer with step 1, so it lands second. The
getting-startedskill change is a companion branch in the website repo, landed the same day. - Browser door and
carlos.js. Members opt in with one script tag; JS errors and named events appear. - Server door on the control socket. Exec-backed apps post structured errors and events.
Wire shapes are additive at every step: logs/ is untouched, errors/ and analytics/ are new prefixes, telemetry.json absent means off, and a box on an older build writes none of them. A mixed-version fleet shows the coverage line per host so a missing source is visible rather than puzzling.
Binaries roll as usual: promote → adopt → restart for the console; edge and agent pick up on adoption.
8. Rulings (Paul, 2026-09-07)
Four questions were open in the draft; Paul answered all four.
- Analytics is off by default (ruling). Settable at the account level so one switch covers every app, overridable per app, and asked about in the
getting-startedskill. §3.3 carries the objects and the resolution order; §7 step 2 carries the skill change. - The reserved prefix is
/__carlos/(ruling). Paul asked whether a random per-app identifier would be better for unpredictability. The recommendation is no, and the spec stays static: the path is not a secret and cannot be one, because the snippet URL is in every page's source and the beacon endpoint is public by construction (§3.4). What bounds abuse is the Origin check, the caps,dropped, and thedoorswitch, none of which a random path would strengthen. A random path would also cost the one-line snippet its one line and would make the reservation rule ("this prefix is never proxied upstream") per-app state instead of a constant. The double underscore buys the collision safety Paul wanted: no app in this repo serves it, and nothing conventional does. - Warn level is a setting, and level is always filterable (ruling).
errors.log_levelin §3.3 at either level,errorby default; the tab, API and CLI filter on level regardless (§3.2, §3.6). - The salt lives beside the registry DB, 0600 (Fable's recommendation, taken). §3.3 records both reviewers' positions: Fable named the location and confirmed the agent owns it; Codex asked for restart-stability and cross-box sharing, and the persisted file gives the first while the home-box rule makes the second unnecessary.
The platform-infrastructure collision check for /__carlos/ is still worth one grep before step 3 ships; the double underscore makes a hit unlikely enough that it is a checklist line, not an open question.
9. Review round 1
Both reviewers read the first draft against main and returned needs-rework. What each changed:
Fable (second context). Moved the beacon intercept from "before routing" to between lookupUpstreamPath and the meter wrap, with the backhaul argument (§3.4). Replaced the recorder tap with the observing countingWriter, citing the three response paths that bypass the recorder (§3.3). Found that fleet boxes' writes would be refused by aclWriteAppLogs (§3.2). Corrected the §1 citations, which pointed at ACME and attribution comments. Defined clientIP from the verified-hop chain (§3.3). Listed the real refactor scope and the key-shape consumer list, and corrected retentionPass to logshipPrune (§3.2, §3.6). Argued the log mirror away in favour of a read-side filter (§3.2). Named the cliRoute API pattern and the gate-ordering scope (§3.6). Found that no box reads features.json, so the switch became a bucket object (§3.3). Sanitised page paths (§3.3). Corrected the chart precedent, the Logs-tab grep, and the non-existent test hook (§3.6, §4). Noted the 500-key merge overflow (§3.3).
Codex. Made the visitor salt restart-stable and the multi-day visitor figure honest (§3.3). Reframed the privacy claim as the deployment's assessment (§8). Tightened the pageview definition to 200 only, excluding redirects, 304s, HEAD and prefetch (§3.3). Reserved a browser-counted mode instead of a same-second dedup (§3.4, §5). Named the path-routed attribution problem independently of Fable (§3.4). Made fingerprints token-boundary, versioned, and frame-normalised (§3.2). Made the coalesced record carry first, last, host and release (§3.2). Made caps per app and exposed dropped everywhere something is dropped (§3.2, §3.3, §3.4). Replaced first-admitted maps with Misra–Gries and stated the HLL error correctly (§3.3). Tightened Origin matching and kept the uniform 202 only alongside owner-visible drop counts (§3.4). Added sanitisation before buffering and owner-triggered deletion (§3.1, §3.6). Made day-file buckets event-time and flushes re-queue on failure (§3.3). Added the collect-cap disclosure and the coverage line (§3.6). Split the snippet into a revalidating loader and a hashed immutable asset (§3.4). Put resolve/mute ahead of alerts in §5. Noted that a plain panic: line is not a parsed error, which §3.2 now answers through the edge source.
Not taken: a per-app public key for the browser door (identification without truth, and the caps already bound the damage; §3.4 says why); storing the raw pageview path (the token-collapsing rule keeps real page names and drops secrets); a mirror of log errors into errors/ (read derivation is cheaper and loses nothing).
10. Review round 2
Both reviewers re-read the rewrite. Fable: approve-with-amendments, all three blockers confirmed resolved against the code; amendments taken: the Origin check is a lookupIn on the edge's map, not an alias list (upstream.alias is a bool); Sec-Fetch-Dest: empty is a fetch, not a page; status routes are excluded from the tap; joined X-Forwarded-For lines; salt file 0600. Codex: needs-rework on under-specification; taken: 1–3 digit tokens survive normalisation so HTTP 503 does; the snippet stamps path on every beacon so path-routed events attribute; the coalescing cap counts records and the window rule is stated; a seq per box makes CAS retries idempotent; a door switch closes the browser door independently of counting and count: off also drops the socket's /events; the server door gets the same size and rate limits; Misra–Gries tables are labelled approximate above 500 keys; the dropped schema lists every key it uses, split by errors and events; wake failures are suppressed at the writer when the activator already emitted; tombstones have a scope, an authorisation, and a stated non-effect on Logs. Nothing from round 2 was declined.
11. Amendment 2026-09-24: Availability
Why. A day of the flagship's Errors tab (titogo, 2026-09-23) held 29 groups: 25 edge 502s, 3 edge 503s and one 500. Only the 500 was a failure of what a request asked for. The rest said the instance was down, restarting or unreachable when some page was asked for, and §3.2's "<status> <method> <path>" fingerprint split each outage into one row per page that happened to hit it. 17 of the 29 rows were one scanner probing /.env, /phpinfo.php and so on against a dead canary. The record also could not say why the edge had no answer: the proxies logged the error and threw it away.
Availability is the Errors tab's sibling (decision, Bill 2026-09-24). A sub-tab between Errors and Analytics (GET /a/{sqid}/apps/{app}/availability), an API path beside /errors with the same gate (GET /api/cli/apps/{account}/{app}/availability), and carlos availability. It holds:
- every edge-observed 502, 503 and 504, whoever wrote it; and
- the platform stream's events at
warnand above (failed wakes, exits, a sidecar that will not restart), whatevererrors.log_levelsays, since an exit on a signal is filed atwarnand is exactly the event an outage row needs beside it. The synthetic rate-cap line (applog.IsDropNotice) is about the logging budget and is left out.
Errors keeps the rest: the app's own 500, 501 and 505+, and the log source's app-stream lines. Nothing is in both.
Grouped by cause, never by path (decision). An edge 502/503/504 is filed as kind: unavailable with cause and name: "<status> · <cause in the member's terms>", and so fingerprinted by status and cause alone; the path stays on the record as the occurrence's sample. The cause comes from the ErrorHandler that wrote the status, through a per-request outcome on the context (edge/answered.go, which already carried the activator's answered flag and now rides every observed request, not only hibernating ones):
| cause | where | |---------------|--------------------------------------------------------------| | refused | ECONNREFUSED / ENOENT on the dial: nothing listening | | reset | EOF, unexpected EOF, ECONNRESET, EPIPE: hung up mid-request | | timeout | a deadline or a net timeout | | abandoned | the client left after waiting 5 s or more (below) | | no_upstream | multiProxy: every upstream failing its probe | | fleet_dark | fleetProxy: no box of the fleet connected | | tunnel | any other failure through a fleet box's tunnel | | static | a static site's files could not be resolved | | proxy | any other proxy failure | | upstream | no handler noted one: the app wrote the status itself (direct routes only; below) |
unixProxy, tcpProxy and tunnelProxy had no ErrorHandler; they get one that is httputil.ReverseProxy's default (same log line, same bare 502) plus the note.
A visitor who left quickly is not an outage; one kept waiting is (decision, amended in review). When the client's own context is done by the time a proxy fails, ReverseProxy still writes a 502, to nobody, and the error is the cancellation whatever the instance was doing. Within 5 s of the request (clientGoneGrace) the edge notes client_gone and files nothing: a visitor navigating away mid-load is not the instance's fault, and a single-count 502 on a font file is the typical shape of it. Past 5 s it files abandoned. The review caught why that half matters: a dead unix socket is redialled for 13 s and a wedged instance is never timed out at all, so a monitor or visitor giving up first is how those outages end — skipping them would hide exactly the worst ones.
Behind a tunnel, the front box files only the hop (decision, Fable review; corrected in its follow-up). When a box proxies a request over a reverse channel, the upstream is not the app but another box's edge — the agent's, the only tunnel client — which files what it sees serving the app, and deliberately files nothing for a wake 503 its own activator answered. A 5xx that merely passed back through the front box was therefore filed twice, and under this amendment would have been labelled "the app answered it itself" for an app that never saw the request. The front box now files only failures it noted itself (tunnel, fleet_dark, abandoned); everything else is the serving box's record. This also ends the double filing of tunnelled 500s, which predates the amendment.
The rule keys on how THIS box served the request (upstream.viaTunnel, the build-time decision), never on the route row: the fleet box that serves the app carries the same fleet/<name> placement on its own row, has no hub, and serves from the socket — it must file everything. (The first cut keyed on the row, and a fleet-placed app's 500s were filed nowhere; TestEdgeServingFleetBoxFilesItsOwn pins the far end.) Two edges remain: a far agent whose status store failed to open has no error sink and files nothing, which the front box no longer covers; and a client that leaves past the grace can be filed abandoned on both boxes, since the stream reset reaches the far box's request too.
The overflow row is per family (decision, from review). The sink's hourly _overflow row copied its source, kind and status from the first record that overflowed, so one family's excess could land on the other tab. It is now one row per family (IsAvailability), and AsAvailability leaves an _overflow row alone.
History regroups on read (decision). Records filed before this carry kind: http, no cause, and the per-path fingerprint. The read recognises them by status and regroups them as "<status> · cause not recorded" (apperr.AsAvailability); the FP filter applies after the regroup so the row the tab shows expands. The bucket is not rewritten.
Not changed. The wire shape is additive (cause and a read-only stream on apperr.Record, both omitempty); nothing under logs/ or errors/ moves. The sending half of a backhaul still files nothing when its hop fails: it has no route row and so no app to file under, the same reason it bills nothing. Correlating a row with the nearest deploy or restart on that host, and alerting on a new cause, are the natural next steps and are not built here.