carlos / platform Public

agent, console, spec: bound the pre-listen telemetry pass; drop a duplicated sentence

Three follow-ups from the re-review of the fix wave.

The pass RunAgent makes before either listener binds now runs under a 20s
timeout — the same telemetryBootTimeout shape as internal/edge's cert
mirror and home map, and for the same reason. Nothing is listening until it
returns, so a slow or unreachable control plane has to cost the one-minute
switch gap this pass exists to close, and never the front door itself.

The Errors tab printed the carlos.js instruction twice on an empty window:
the previous commit moved the sentence up beside the sources line but left
the original in the empty-state paragraph. The empty-state copy is gone,
along with the HTML comment that travelled up with the moved one; the
sentences either side of it are untouched.

And the spec caught up with two rulings the wave settled. §3.2 now says the
coalescing budget is per app per source per hour with one _overflow row per
source; §3.4 now says the Origin rule takes https on the port the edge
listens on, and http only under --dev.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6ffd8cae846ba5052dc9b55c461b0855b33cc6bd parent 057ec98
4 files changed, +31 −11
  • docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md +14 −5
  • internal/console/templates/app_errors.html +1 −5
  • internal/host/agent.go +8 −1
  • internal/host/telemetry_pass.go +8 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md b/docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md
index c1c0ec571..552f9df15 100644
--- a/docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md
+++ b/docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md
@@ -199,10 +199,13 @@ 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
+source 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; per
+source because the browser door admits 600 events a minute, each free to
+carry a fresh fingerprint, and a shared budget would blind edge errors
+for the rest of the hour — §8 step 3 ruling 11). Beyond the cap the
+overflow folds into one `_overflow` row per source, 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
@@ -446,7 +449,13 @@ 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
+must carry an `Origin` whose scheme is `https` on the port the edge
+listens on (`-https`, so `:8443` there means an origin naming `:8443`
+and not a portless one — a browser sends the port whenever it is not the
+scheme's default). `http` origins are accepted only under `--dev`, where
+the edge serves the pages over plain HTTP itself and `http` is therefore
+what the browser reports; a TLS edge refuses them outright (§8 step 3
+ruling 10). The host and port must
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
diff --git a/internal/console/templates/app_errors.html b/internal/console/templates/app_errors.html
index f2a992efa..447b630ca 100644
--- a/internal/console/templates/app_errors.html
+++ b/internal/console/templates/app_errors.html
@@ -79,10 +79,6 @@ Counts are to the nearest flush. Messages are redacted for addresses and credent
<p class="small">Times are UTC. The same view from a terminal: <code>carlos errors --app {{.AppName}} --since {{.ErrSince}}</code>.</p>
{{else if not .ErrsErr}}
<p>No errors in this window.</p>
-<!-- The script tag below is prose, not a real subresource fetch: its src
- attribute is spelled with &#61; for = and &quot; for the quotes so
- TestConsoleTemplatesFetchNothingOffOrigin (icons_test.go) does not
- misread this line as one. -->
-<p class="small">Log lines at <code>{{.ErrWin.LogLevel}}</code> and above and responses of 500 or higher appear here on their own. A JavaScript error needs one line in the page: <code>&lt;script src&#61;&quot;/__carlos/carlos.js&quot; defer&gt;&lt;/script&gt;</code> — messages, stacks and fields are redacted for addresses, credentials and long tokens before they leave the page and again on arrival (a floor, not a guarantee). A server-side error needs the hook (next step). The level is set on the Settings tab. The same view from a terminal: <code>carlos errors --app {{.AppName}}</code>.</p>
+<p class="small">Log lines at <code>{{.ErrWin.LogLevel}}</code> and above and responses of 500 or higher appear here on their own. A server-side error needs the hook (next step). The level is set on the Settings tab. The same view from a terminal: <code>carlos errors --app {{.AppName}}</code>.</p>
{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index b0d19b062..f6bd530ef 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -465,10 +465,17 @@ func RunAgent(args []string) {
// gap nobody would ever find from the tab. Costs one route List plus
// two Gets per distinct app, on a path that is already doing far
// more work than that.
+ //
+ // Bounded on the cert mirror's and the home map's terms (edge.go's
+ // pre-listen refreshes, telemetryBootTimeout): nothing listens until
+ // this returns, so a slow control plane must degrade to the old
+ // one-minute gap, never to no front door.
if st, err := openDeployStore(context.Background()); err != nil {
log.Printf("agent: telemetry first pass: %v (analytics stays off until the next pass)", err)
} else {
- telemetryPass(context.Background(), st, reg)
+ ctx, cancel := context.WithTimeout(context.Background(), telemetryBootTimeout)
+ telemetryPass(ctx, st, reg)
+ cancel()
}
go usageLoop(m.Drain, am)
},
diff --git a/internal/host/telemetry_pass.go b/internal/host/telemetry_pass.go
index 19ca9544c..b35af7074 100644
--- a/internal/host/telemetry_pass.go
+++ b/internal/host/telemetry_pass.go
@@ -9,12 +9,20 @@ import (
"context"
"log"
"sync/atomic"
+ "time"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/telemetry"
)
+// telemetryBootTimeout budgets the ONE pass RunAgent makes before either
+// listener binds. Same 20s as internal/edge's certMirrorBootTimeout and
+// homeMapBootTimeout, for the same reason: the front door is held shut
+// until it returns, so a store that is slow or unreachable must cost the
+// one-minute gap this pass exists to close and nothing more.
+var telemetryBootTimeout = 20 * time.Second
+
var analyticsSink atomic.Pointer[func(map[string]telemetry.Switches)]
// setAnalyticsSink registers where a resolved switch map goes (the edge's