carlos / platform Public

Merge branch 'analytics-step2' into main

claude agent, operated by paul@keymail.dev 40c6ab73df40451baf9fc43bf1d90aa158f10182 parent 8e16153parent 6496b9b
40 files changed, +5473 −29
  • analytics_cmd.go +195 −0
  • analytics_cmd_test.go +130 −0
  • apimode.go +52 −2
  • docs/site/cli.md +15 −0
  • docs/superpowers/plans/2026-09-07-analytics-step2-edge-counting.md +2224 −0
  • docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md +4 −3
  • internal/analytics/bots.go +32 −0
  • internal/analytics/bots_test.go +27 −0
  • internal/analytics/day.go +174 −0
  • internal/analytics/day_test.go +94 −0
  • internal/analytics/hll.go +116 −0
  • internal/analytics/hll_test.go +71 −0
  • internal/analytics/meter.go +265 −0
  • internal/analytics/meter_test.go +239 −0
  • internal/analytics/read.go +127 −0
  • internal/analytics/read_test.go +46 −0
  • internal/analytics/salt.go +98 −0
  • internal/analytics/salt_test.go +64 −0
  • internal/analytics/topk.go +106 −0
  • internal/analytics/topk_test.go +57 −0
  • internal/console/analyticsview.go +409 −0
  • internal/console/apianalytics.go +94 −0
  • internal/console/apianalytics_test.go +184 −0
  • internal/console/apierrors.go +68 −12
  • internal/console/authcli.go +6 −0
  • internal/console/server.go +38 −2
  • internal/console/svcroutes_test.go +11 −0
  • internal/console/templates/account_settings.html +23 −0
  • internal/console/templates/app_analytics.html +45 −0
  • internal/console/templates/app_settings.html +25 −0
  • internal/console/templates/appnav.html +1 −0
  • internal/edge/analytics.go +127 −0
  • internal/edge/analytics_test.go +111 −0
  • internal/edge/edge.go +11 −0
  • internal/host/agent.go +11 −1
  • internal/host/telemetry_pass.go +69 −0
  • internal/host/telemetry_pass_test.go +38 −0
  • internal/host/usage_loop.go +31 −6
  • internal/host/usage_loop_test.go +31 −3
  • main.go +4 −0

CI passed — run details

diff --git a/analytics_cmd.go b/analytics_cmd.go
new file mode 100644
index 000000000..27f781de9
--- /dev/null
+++ b/analytics_cmd.go
@@ -0,0 +1,195 @@
+// `carlos analytics` — an app's page views and visitors from a terminal,
+// counted at the edge as it serves your app. API mode only.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/url"
+ "sort"
+)
+
+const analyticsUsage = `usage: carlos analytics --app <a> [flags]
+
+Page views, daily visitors, bytes served, top pages, top referers and named
+events, counted at the edge. Off until you turn it on.
+
+--range is today, 7d or 30d; multi-day visitor totals are daily visitors
+summed. The same view is the Analytics tab on the app's dashboard.
+`
+
+func runAnalytics(args []string) {
+ fs := flag.NewFlagSet("analytics", flag.ExitOnError)
+ app := appFlag(fs, "app whose analytics to read")
+ rangeFlag := fs.String("range", "today", "window: today | 7d | 30d")
+ setCounting := fs.String("set-count", "", "set analytics counting: edge | off")
+ setDoor := fs.String("set-door", "", "set door: on | off")
+ consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ fs.Usage = flagUsage(fs, analyticsUsage)
+ fs.Parse(args)
+
+ sess := requireAPISession("analytics", *consoleFlag)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, analyticsUsage)
+ ctx := context.Background()
+
+ if *setCounting != "" || *setDoor != "" {
+ body, err := analyticsSetBody(*setCounting, *setDoor)
+ if err != nil {
+ log.Fatalf("analytics: %v", err)
+ }
+ if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"analytics": body}); err != nil {
+ log.Fatalf("analytics: %v", err)
+ }
+ // Build summary of what was set
+ var parts []string
+ if *setCounting != "" {
+ parts = append(parts, fmt.Sprintf("counting %s", *setCounting))
+ }
+ if *setDoor != "" {
+ parts = append(parts, fmt.Sprintf("door %s", *setDoor))
+ }
+ fmt.Fprintf(stdout, "Analytics for %s: %s.\n", app2, joinOr(parts, ""))
+ return
+ }
+
+ q := url.Values{"range": {*rangeFlag}}
+ res, err := sess.analyticsGet(ctx, acct, app2, q)
+ if err != nil {
+ log.Fatalf("analytics: %v", err)
+ }
+ printAnalytics(stdout, res)
+ if !res.On {
+ mutedf(stdout, "analytics is off for this app (from the %s) — turn it on with `carlos analytics --app %s --set-count edge`", res.CountFrom, app2)
+ }
+}
+
+// analyticsSetBody builds the analytics settings map from the given flag values,
+// validating them first. Returns error for invalid values.
+func analyticsSetBody(count, door string) (map[string]string, error) {
+ if count != "" && count != "edge" && count != "off" {
+ return nil, fmt.Errorf("--set-count wants edge or off")
+ }
+ if door != "" && door != "on" && door != "off" {
+ return nil, fmt.Errorf("--set-door wants on or off")
+ }
+ body := make(map[string]string)
+ if count != "" {
+ body["count"] = count
+ }
+ if door != "" {
+ body["door"] = door
+ }
+ return body, nil
+}
+
+// printAnalytics renders the analytics summary: a headline line with totals,
+// a text sparkline of days, then three tables sorted by value descending.
+func printAnalytics(w io.Writer, res apiAnalyticsResponse) {
+ c := colorFor(w)
+
+ // Headline line
+ fmt.Fprintf(w, "%s · %s · %s · %s · %s\n",
+ c.head(fmt.Sprintf("page views %d", res.PV)),
+ c.head(fmt.Sprintf("visitors %d (daily visitors, summed)", res.Visitors)),
+ c.muted(fmt.Sprintf("bytes %d", res.Bytes)),
+ c.muted(fmt.Sprintf("bots %d", res.Bots)),
+ c.muted(fmt.Sprintf("dropped %d", res.Dropped.E.Rate+res.Dropped.E.Size+res.Dropped.E.Origin+res.Dropped.E.Malformed+res.Dropped.A.Rate+res.Dropped.A.Size+res.Dropped.A.Origin+res.Dropped.A.Malformed+res.Dropped.Flush)))
+
+ // Sparkline: for today (Days[0]), show hours; otherwise one glyph per day
+ sparkline := ""
+ if len(res.Days) > 0 {
+ if res.Range == "today" {
+ // Show hours of today
+ maxHour := int64(0)
+ for _, h := range res.Days[0].Hours {
+ if h > maxHour {
+ maxHour = h
+ }
+ }
+ glyphs := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
+ for _, h := range res.Days[0].Hours {
+ if maxHour == 0 {
+ sparkline += "▁"
+ } else {
+ level := int((h * int64(len(glyphs)-1)) / maxHour)
+ sparkline += string(glyphs[level])
+ }
+ }
+ } else {
+ // One glyph per day
+ maxPV := int64(0)
+ for _, day := range res.Days {
+ if day.PV > maxPV {
+ maxPV = day.PV
+ }
+ }
+ glyphs := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
+ for _, day := range res.Days {
+ if maxPV == 0 {
+ sparkline += "▁"
+ } else {
+ level := int((day.PV * int64(len(glyphs)-1)) / maxPV)
+ sparkline += string(glyphs[level])
+ }
+ }
+ }
+ fmt.Fprintf(w, "%s\n\n", sparkline)
+ }
+
+ // Pages table
+ printAnalyticsTable(w, "Pages", res.Pages)
+
+ // Referers table
+ printAnalyticsTable(w, "Referers", res.Referers)
+
+ // Events table
+ printAnalyticsTable(w, "Events", res.Events)
+}
+
+// printAnalyticsTable renders one analytics table (pages, referers, or events)
+// sorted by value descending, with _other last.
+func printAnalyticsTable(w io.Writer, title string, data map[string]int64) {
+ if len(data) == 0 {
+ return
+ }
+
+ c := colorFor(w)
+
+ // Separate _other and regular entries
+ var other int64
+ entries := make([]struct {
+ key string
+ value int64
+ }, 0, len(data))
+
+ for k, v := range data {
+ if k == "_other" {
+ other = v
+ } else {
+ entries = append(entries, struct {
+ key string
+ value int64
+ }{k, v})
+ }
+ }
+
+ // Sort by value descending
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].value > entries[j].value
+ })
+
+ fmt.Fprintf(w, "%s\n", c.head(title))
+ for _, e := range entries {
+ fmt.Fprintf(w, " %-30s %6d\n", e.key, e.value)
+ }
+ if other > 0 {
+ fmt.Fprintf(w, " %-30s %6d\n", c.muted("_other"), other)
+ }
+ fmt.Fprintf(w, "\n")
+}
diff --git a/analytics_cmd_test.go b/analytics_cmd_test.go
new file mode 100644
index 000000000..ada1248b2
--- /dev/null
+++ b/analytics_cmd_test.go
@@ -0,0 +1,130 @@
+package main
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/analytics"
+)
+
+func TestPrintAnalytics(t *testing.T) {
+ res := apiAnalyticsResponse{
+ Range: "7d",
+ PV: 5,
+ Visitors: 4,
+ Bytes: 100,
+ Bots: 0,
+ Pages: map[string]int64{
+ "/pricing": 3,
+ "/": 2,
+ },
+ Referers: map[string]int64{
+ "news.ycombinator.com": 5,
+ },
+ Events: map[string]int64{
+ "signup": 1,
+ },
+ Days: []analytics.DaySummary{
+ {
+ Date: "2026-09-07",
+ PV: 3,
+ Bytes: 50,
+ Bots: 0,
+ Visitors: 2,
+ Hours: [24]int64{1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ },
+ {
+ Date: "2026-09-06",
+ PV: 2,
+ Bytes: 50,
+ Bots: 0,
+ Visitors: 2,
+ Hours: [24]int64{0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ },
+ },
+ On: true,
+ }
+
+ var buf bytes.Buffer
+ printAnalytics(&buf, res)
+ output := buf.String()
+
+ checks := []string{
+ "5", // PV count
+ "4", // Visitors count
+ "daily visitors, summed", // text from headline
+ "/pricing", // page
+ "news.ycombinator.com", // referer
+ "signup", // event
+ }
+
+ for _, check := range checks {
+ if !bytes.Contains(buf.Bytes(), []byte(check)) {
+ t.Errorf("output missing %q:\n%s", check, output)
+ }
+ }
+}
+
+func TestAnalyticsSetFlags(t *testing.T) {
+ tests := []struct {
+ name string
+ count string
+ door string
+ wantErr bool
+ want map[string]string
+ }{
+ {
+ name: "both flags",
+ count: "edge",
+ door: "on",
+ want: map[string]string{"count": "edge", "door": "on"},
+ },
+ {
+ name: "count only",
+ count: "off",
+ want: map[string]string{"count": "off"},
+ },
+ {
+ name: "door only",
+ door: "off",
+ want: map[string]string{"door": "off"},
+ },
+ {
+ name: "neither",
+ want: map[string]string{},
+ },
+ {
+ name: "invalid count",
+ count: "invalid",
+ wantErr: true,
+ },
+ {
+ name: "invalid door",
+ door: "maybe",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := analyticsSetBody(tt.count, tt.door)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("analyticsSetBody() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if err != nil {
+ return
+ }
+ if len(got) != len(tt.want) {
+ t.Errorf("analyticsSetBody() got %v, want %v", got, tt.want)
+ return
+ }
+ for k, v := range tt.want {
+ if got[k] != v {
+ t.Errorf("analyticsSetBody() got %v, want %v", got, tt.want)
+ return
+ }
+ }
+ })
+ }
+}
diff --git a/apimode.go b/apimode.go
index 226121240..4348c1561 100644
--- a/apimode.go
+++ b/apimode.go
@@ -32,6 +32,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/analytics"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/apperr"
"github.com/carlosframework/platform/internal/applog"
@@ -2881,8 +2882,57 @@ func (s *apiSession) errorsGet(ctx context.Context, a apiAcct, app string, query
return res, nil
}
-// telemetryPut sets an app's telemetry settings (currently just the errors
-// log level) — PUT /api/cli/apps/{account}/{app}/telemetry.
+// apiAnalyticsResponse is the console's analytics answer — days, pages,
+// referers, and events are the wire shapes decoded as-is.
+type apiAnalyticsResponse struct {
+ Range string `json:"range"`
+ From string `json:"from"`
+ To string `json:"to"`
+ On bool `json:"on"`
+ Count string `json:"count"`
+ CountFrom string `json:"count_from"`
+ PV int64 `json:"pv"`
+ Visitors int64 `json:"visitors"`
+ Bytes int64 `json:"bytes"`
+ Bots int64 `json:"bots"`
+ Pages map[string]int64 `json:"pages"`
+ Referers map[string]int64 `json:"referers"`
+ Events map[string]int64 `json:"events"`
+ PagesApprox bool `json:"pages_approx"`
+ ReferersApprox bool `json:"referers_approx"`
+ EventsApprox bool `json:"events_approx"`
+ Days []analytics.DaySummary `json:"days"`
+ Dropped analytics.Dropped `json:"dropped"`
+}
+
+// analyticsGet reads an app's analytics for the given range. query carries
+// the filter params exactly as the endpoint speaks them.
+func (s *apiSession) analyticsGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiAnalyticsResponse, error) {
+ path := apiPath(a, app, "/analytics")
+ if enc := query.Encode(); enc != "" {
+ if strings.Contains(path, "?") {
+ path += "&" + enc
+ } else {
+ path += "?" + enc
+ }
+ }
+ var res apiAnalyticsResponse
+ status, body, err := s.getJSON(ctx, path)
+ if err != nil {
+ return res, err
+ }
+ if status != http.StatusOK {
+ return res, apiErrorFromBody(status, body)
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return res, fmt.Errorf("analytics: unreadable response: %w", err)
+ }
+ return res, nil
+}
+
+// telemetryPut sets an app's telemetry settings: the errors log level and
+// the analytics switches, either or both in one body — PUT
+// /api/cli/apps/{account}/{app}/telemetry.
func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, body any) error {
status, resp, err := s.putJSON(ctx, apiPath(a, app, "/telemetry"), body)
if err != nil {
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 6b7b3c38e..ba2b1cb8c 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -579,6 +579,21 @@ carlos errors --app hello --set-level warn
read without changing the setting; `--set-level` changes it for the app.
The same view is the Errors tab on the app's dashboard.
+### carlos analytics
+
+Page views, daily visitors, bytes served, top pages, top referers and named
+events, counted at the edge as it serves your app: no JavaScript, no cookie,
+no stored IP. Off until you turn it on, for one app or for the whole account.
+
+```sh
+carlos analytics --app hello --set-count edge
+carlos analytics --app hello --range 7d
+```
+
+`--range` is `today`, `7d` or `30d`; multi-day visitor totals are daily
+visitors summed. The same view is the Analytics tab on the app's dashboard,
+and the switch is on the Settings tab too.
+
### carlos store
Object storage for an app. `create` declares that the app wants a bucket,
diff --git a/docs/superpowers/plans/2026-09-07-analytics-step2-edge-counting.md b/docs/superpowers/plans/2026-09-07-analytics-step2-edge-counting.md
new file mode 100644
index 000000000..08c2091c4
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-analytics-step2-edge-counting.md
@@ -0,0 +1,2224 @@
+# Analytics, step 2: counting at the edge — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** An account that turns analytics on gets pageviews, daily visitors, bytes, top pages, top referers and named events for every app, counted at the edge with no JavaScript and no cookie, on an Analytics tab, an API and `carlos analytics`.
+
+**Architecture:** A new leaf package `internal/analytics` owns the sketches (HyperLogLog for visitors, Misra–Gries for top keys), the in-memory per-app meter the edge feeds, the day-file wire shape and its CAS merge with per-box `seq` idempotency, the daily salt file, the bot list, and the cross-box range read. The edge's existing observer defer builds one observation per response and hands it to the meter when the route's app is switched on; the switch map is pushed into the edge by a host pass that resolves `telemetry.json` for the routes the box serves. The agent flushes the meter on the usage loop's five-minute tick as a second CAS write. The console gains the tab, `GET …/analytics`, the switches at both levels (and `PUT …/telemetry` learns the analytics keys, with a serial bump), and the CLI gains `carlos analytics`.
+
+**Tech Stack:** Go 1.22+, `html/template`, inline SVG computed in Go, `release.Store` bucket, sqlite registry; no new module dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md` — §3.3 in full (where the edge sees it; what is counted; client IP; visitor; cardinality; page paths; storage; named events; accumulation; the settings object), §3.6 (Analytics tab, API, CLI, Settings), §4, §6, §7 step 2, §8 rulings 1 and 4.
+
+## Global Constraints
+
+- **Defaults (ruling):** `analytics.count` = `off`, `analytics.door` = `on`. Nothing is counted for an app until an account or app object says `count: edge`.
+- **Pageview** = method GET, final status 200, media type `text/html`; not a redirect, 304, 204/205, HEAD; not `Sec-Purpose: prefetch` / `Purpose: prefetch`; `Sec-Fetch-Dest` when present must be `document`; never under `/__carlos/`; a bot UA counts into `bots` only; status routes never counted.
+- **Visitor** = `sha256(day-salt ‖ route host ‖ clientIP ‖ user agent)[:8]`; salt per box per UTC day, 0600 in the box data dir (`dataDir()`, default `/data`), generated at first use, deleted on rotation, never in the bucket. No cookie; no IP stored.
+- **clientIP(r)** = last element of the joined `X-Forwarded-For` when `hopVerified(ctx)`, else host of `RemoteAddr`.
+- **Cardinality:** HLL with 2¹¹ registers, encoded base64 with a one-byte version prefix (`1`), merged by register max. Pages/referers/events are Misra–Gries with 500 counters, keys capped at 256 bytes; the reader re-folds to 500 + `_other`; above 500 distinct keys the table is labelled approximate.
+- **Page path keys** are `apperr.SanitizePath(path)`. Referer keys are host-only; empty → `direct`.
+- **Storage:** `apps/<sqid>/<app>/analytics/<box>/days/<date>.json`; buckets keyed by event time; drain returns deltas; a failed flush re-queues once (second consecutive failure drops the older window and increments `dropped.flush`); every delta batch carries a box-monotonic `seq`, the day file records `seq` per box, a merge with `seq` ≤ recorded is a no-op.
+- **Day file shape (v 1):** `{"v","date","hours":{"HH":{"pv","bots","bytes"}},"visitors","pages","referers","events","seq":{box:n},"dropped":{"e":{…},"a":{…},"flush"},"countries":{}}` exactly as spec §3.3; `dropped.e`/`dropped.a` are written as zeros in this step (the doors arrive in steps 3–4).
+- **Switch convergence:** the console writes `telemetry.json` and bumps the serial; boxes resolve `count` app → account → default on the serial-triggered full pass and on the five-minute instances loop; `count: off` stops counting at the observer.
+- **Accumulation:** flushes ride the existing `usageLoop` tick (five minutes) as a second CAS write.
+- **API:** `GET /api/cli/apps/{account}/{app}/analytics` with `svcrole.Allow(svcrole.Instance, svcrole.Operate)` through `instanceGate`; `PUT …/telemetry` gains `analytics` (Operate service; owner person), bumps the serial when analytics changed.
+- **Tab:** range today / 7d / 30d; headline pageviews, visitors ("daily visitors, summed" for ranges), bytes, bots, dropped; one inline SVG chart computed in Go (per hour today, per day otherwise), no library; three tables; a "counts since the last flush may be missing" note only when a restart happened that day; approximate label above 500 keys. When `count` is off the tab shows the switch and the one-line privacy reading instead of numbers.
+- **CLI:** `carlos analytics --app X [--range today|7d|30d] [--set-count edge|off] [--set-door on|off]`; heading `### carlos analytics` in `docs/site/cli.md` or the docsite gate fails.
+- Wire shapes additive; nothing wakes an instance; the edge wrapper order is unchanged (the tap is a read of `ob.status`/`ob.ctype`/`bytes` in the existing defer).
+- Go: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp/g` (mkdir `/tmp/g`); retry once with the sandbox disabled on "read-only file system". Before push: `GOOS=darwin GOARCH=arm64 go build ./...` and `GOOS=windows GOARCH=amd64 go build ./...` (the salt file code must build on all three).
+- Commit trailer: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. Branch `analytics-step2`, cut from main at 1a99060. Push to `origin` (the forge). Never `gh`.
+- The `getting-started` skill question (spec §7 step 2) lives in the `carlosframework/website` repo and is NOT a task here; Task 10 files it as a follow-up in the branch description.
+
+## File structure
+
+| File | Responsibility |
+|---|---|
+| `internal/analytics/hll.go` | `HLL` sketch: add, merge, count, encode/decode |
+| `internal/analytics/topk.go` | `TopK` Misra–Gries summary: add, merge, fold |
+| `internal/analytics/bots.go` | `IsBot(ua)` vendored substring list |
+| `internal/analytics/salt.go` | `DaySalt(dir, date)`: 0600 file, rotation |
+| `internal/analytics/day.go` | `Day` wire shape, keys, `Delta`, `Merge`, `UpsertDay` |
+| `internal/analytics/meter.go` | `Meter`: `Obs`, `Observe`, `Track`, `Drain`, `Requeue` |
+| `internal/analytics/read.go` | `ReadRange` across boxes, `Summary` |
+| `internal/edge/analytics.go` | `AnalyticsMeter` iface, `EnableAnalytics`, `SetAnalyticsOn`, `clientIP`, `observeAnalytics` |
+| `internal/edge/edge.go` | one call in the observer defer |
+| `internal/host/telemetry_pass.go` | resolve switches for the box's routes, push to the edge |
+| `internal/host/usage_loop.go`, `agent.go` | meter construction, flush on the usage tick, pass wiring |
+| `internal/console/analyticsview.go` | `readAnalytics`, chart points, page + settings handlers |
+| `internal/console/apianalytics.go` | `GET …/analytics`; `apierrors.go` PUT accepts analytics |
+| `internal/console/templates/app_analytics.html`, `appnav.html`, `app_settings.html`, `account_settings.html` | tab, nav, switches |
+| `analytics_cmd.go`, `apimode.go`, `main.go`, `docs/site/cli.md` | CLI |
+
+---
+
+### Task 1: HyperLogLog and Misra–Gries sketches
+
+**Files:**
+- Create: `internal/analytics/hll.go`, `internal/analytics/topk.go`
+- Test: `internal/analytics/hll_test.go`, `internal/analytics/topk_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ const HLLPrecision = 11; const hllRegisters = 1 << HLLPrecision
+ type HLL struct { reg [hllRegisters]uint8 }
+ func (h *HLL) Add(hash uint64)
+ func (h *HLL) Merge(o *HLL)
+ func (h *HLL) Count() float64
+ func (h *HLL) Empty() bool
+ func (h *HLL) Encode() string // base64(std) of [1, reg...]
+ func DecodeHLL(s string) (*HLL, error) // "" → empty sketch
+ const TopKSize = 500; const OtherKey = "_other"; const MaxKeyLen = 256
+ type TopK struct { counts map[string]int64; other int64 }
+ func NewTopK() *TopK
+ func (t *TopK) Add(key string, n int64)
+ func (t *TopK) Merge(o *TopK)
+ func (t *TopK) Fold() map[string]int64 // ≤500 keys plus OtherKey when other>0
+ func TopKFrom(m map[string]int64) *TopK // inverse of Fold (OtherKey → other)
+ func (t *TopK) Approximate() bool // other > 0
+ ```
+
+- [ ] **Step 1: Failing tests**
+
+`hll_test.go`:
+```go
+package analytics
+
+import (
+ "hash/fnv"
+ "math"
+ "testing"
+)
+
+func h64(s string) uint64 { f := fnv.New64a(); f.Write([]byte(s)); return f.Sum64() }
+
+func TestHLLEstimatesWithinThreeSigma(t *testing.T) {
+ for _, n := range []int{1000, 10000, 100000} {
+ var h HLL
+ for i := 0; i < n; i++ {
+ h.Add(h64("v" + itoa(i)))
+ }
+ got := h.Count()
+ sigma := 1.04 / math.Sqrt(hllRegisters) * float64(n)
+ if math.Abs(got-float64(n)) > 3*sigma {
+ t.Errorf("n=%d: estimate %.0f off by more than 3σ (%.0f)", n, got, 3*sigma)
+ }
+ }
+}
+
+func TestHLLMergeIsUnionAndIdempotent(t *testing.T) {
+ var a, b HLL
+ for i := 0; i < 5000; i++ {
+ a.Add(h64("a" + itoa(i)))
+ b.Add(h64("b" + itoa(i)))
+ }
+ var ab HLL
+ ab.Merge(&a)
+ ab.Merge(&b)
+ ab.Merge(&b) // idempotent
+ if got := ab.Count(); math.Abs(got-10000) > 3*(1.04/math.Sqrt(hllRegisters))*10000 {
+ t.Fatalf("union estimate %.0f", got)
+ }
+ var ba HLL
+ ba.Merge(&b)
+ ba.Merge(&a)
+ if ab.reg != ba.reg {
+ t.Fatal("merge must be commutative")
+ }
+}
+
+func TestHLLEncodeRoundTripAndVersion(t *testing.T) {
+ var h HLL
+ h.Add(h64("x"))
+ enc := h.Encode()
+ if enc[:2] != "AQ" { // base64 of a leading 0x01 byte
+ t.Fatalf("version prefix missing: %q", enc[:4])
+ }
+ d, err := DecodeHLL(enc)
+ if err != nil || d.reg != h.reg {
+ t.Fatalf("round trip: %v", err)
+ }
+ if e, err := DecodeHLL(""); err != nil || !e.Empty() {
+ t.Fatal("empty string must decode to an empty sketch")
+ }
+ if _, err := DecodeHLL("AgAA"); err == nil {
+ t.Fatal("unknown version must error")
+ }
+}
+
+func itoa(i int) string { return string(rune('0'+i%10)) + string(rune('0'+(i/10)%10)) + string(rune('0'+(i/100)%10)) + string(rune('0'+(i/1000)%10)) + string(rune('0'+(i/10000)%10)) + string(rune('0'+(i/100000)%10)) }
+```
+
+`topk_test.go`:
+```go
+package analytics
+
+import "testing"
+
+func TestTopKExactBelowCapAndBoundedAbove(t *testing.T) {
+ k := NewTopK()
+ for i := 0; i < 100; i++ {
+ k.Add("/p"+itoa(i), int64(i+1))
+ }
+ f := k.Fold()
+ if len(f) != 100 || f["/p99"] != 100 || k.Approximate() {
+ t.Fatalf("below cap must be exact: len=%d p99=%d approx=%v", len(f), f["/p99"], k.Approximate())
+ }
+ for i := 0; i < 2000; i++ {
+ k.Add("/q"+itoa(i), 1)
+ }
+ f = k.Fold()
+ if len(f) > TopKSize+1 || !k.Approximate() || f[OtherKey] == 0 {
+ t.Fatalf("above cap: len=%d approx=%v other=%d", len(f), k.Approximate(), f[OtherKey])
+ }
+ // Total mass is conserved between counters and _other.
+ var total int64
+ for _, v := range f {
+ total += v
+ }
+ if total != 100*101/2+2000 {
+ t.Fatalf("mass %d", total)
+ }
+ // A heavy hitter survives.
+ if f["/p99"] < 100-2000/TopKSize {
+ t.Fatalf("heavy hitter undercounted: %d", f["/p99"])
+ }
+}
+
+func TestTopKMergeRefolds(t *testing.T) {
+ a, b := NewTopK(), NewTopK()
+ for i := 0; i < 400; i++ {
+ a.Add("/a"+itoa(i), 2)
+ b.Add("/b"+itoa(i), 3)
+ }
+ a.Merge(b)
+ f := a.Fold()
+ if len(f) > TopKSize+1 || f[OtherKey] == 0 {
+ t.Fatalf("merged 800 keys must fold to ≤501: %d", len(f))
+ }
+ r := TopKFrom(f)
+ if r.other != f[OtherKey] || len(r.counts) != len(f)-1 {
+ t.Fatal("TopKFrom must invert Fold")
+ }
+ long := NewTopK()
+ long.Add(string(make([]byte, 300)), 1)
+ for key := range long.Fold() {
+ if len(key) > MaxKeyLen {
+ t.Fatal("keys must be capped at 256 bytes")
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect compile failure**
+
+- [ ] **Step 3: Implement `hll.go`**
+
+```go
+// Package analytics is the pure half of the Analytics tab (spec
+// 2026-09-07-errors-and-analytics-design.md §3.3): the visitor and top-key
+// sketches, the in-memory meter the edge feeds, the day-file wire shape
+// and its idempotent CAS merge, the daily salt, the bot list and the
+// cross-box range read. internal/edge observes, internal/host flushes,
+// internal/console reads — the applog/apperr split.
+package analytics
+
+import (
+ "encoding/base64"
+ "errors"
+ "math"
+ "math/bits"
+)
+
+// HLLPrecision is the register-index width: 2^11 registers, 2 KB, ~2.3%
+// standard error (spec §3.3). One sketch per app per day per box.
+const HLLPrecision = 11
+const hllRegisters = 1 << HLLPrecision
+
+// HLL is a HyperLogLog cardinality sketch over 64-bit hashes.
+type HLL struct{ reg [hllRegisters]uint8 }
+
+// Add records one hashed element.
+func (h *HLL) Add(hash uint64) {
+ idx := hash >> (64 - HLLPrecision)
+ rest := hash<<HLLPrecision | 1<<(HLLPrecision-1) // guard bit bounds the run
+ rank := uint8(bits.LeadingZeros64(rest)) + 1
+ if rank > h.reg[idx] {
+ h.reg[idx] = rank
+ }
+}
+
+// Merge folds o into h by register max — the union of the two sets.
+func (h *HLL) Merge(o *HLL) {
+ for i := range h.reg {
+ if o.reg[i] > h.reg[i] {
+ h.reg[i] = o.reg[i]
+ }
+ }
+}
+
+// Empty reports whether nothing was added.
+func (h *HLL) Empty() bool {
+ for _, r := range h.reg {
+ if r != 0 {
+ return false
+ }
+ }
+ return true
+}
+
+// Count estimates the cardinality (Flajolet et al. 2007 with the small-
+// range linear-counting correction).
+func (h *HLL) Count() float64 {
+ m := float64(hllRegisters)
+ alpha := 0.7213 / (1 + 1.079/m)
+ var sum float64
+ zeros := 0
+ for _, r := range h.reg {
+ sum += math.Pow(2, -float64(r))
+ if r == 0 {
+ zeros++
+ }
+ }
+ est := alpha * m * m / sum
+ if est <= 2.5*m && zeros > 0 {
+ est = m * math.Log(m/float64(zeros))
+ }
+ return est
+}
+
+const hllVersion = 1
+
+// Encode renders the sketch as base64 of a version byte plus the registers.
+func (h *HLL) Encode() string {
+ b := make([]byte, 1+hllRegisters)
+ b[0] = hllVersion
+ copy(b[1:], h.reg[:])
+ return base64.StdEncoding.EncodeToString(b)
+}
+
+// DecodeHLL parses Encode's output; "" is an empty sketch, an unknown
+// version is an error (a future shape this build cannot read).
+func DecodeHLL(s string) (*HLL, error) {
+ h := &HLL{}
+ if s == "" {
+ return h, nil
+ }
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ if len(b) != 1+hllRegisters || b[0] != hllVersion {
+ return nil, errors.New("analytics: unknown visitors sketch version or size")
+ }
+ copy(h.reg[:], b[1:])
+ return h, nil
+}
+```
+
+- [ ] **Step 4: Implement `topk.go`**
+
+```go
+package analytics
+
+// TopKSize bounds a top-keys summary (pages, referers, events) per app
+// per day per box (spec §3.3). Misra–Gries: exact below the cap, a lower
+// bound within the summary's error above it, mergeable, with the evicted
+// mass kept in OtherKey so totals are conserved.
+const TopKSize = 500
+
+// OtherKey is the fold-over row for keys beyond the cap.
+const OtherKey = "_other"
+
+// MaxKeyLen caps a key's bytes.
+const MaxKeyLen = 256
+
+// TopK is a Misra–Gries summary with TopKSize counters.
+type TopK struct {
+ counts map[string]int64
+ other int64
+}
+
+// NewTopK returns an empty summary.
+func NewTopK() *TopK { return &TopK{counts: make(map[string]int64)} }
+
+// Add counts n occurrences of key. When the summary is full and key is
+// new, every counter is decremented by the smallest amount that frees a
+// slot, and the decremented mass moves to other.
+func (t *TopK) Add(key string, n int64) {
+ if len(key) > MaxKeyLen {
+ key = key[:MaxKeyLen]
+ }
+ if n <= 0 {
+ return
+ }
+ if _, ok := t.counts[key]; ok || len(t.counts) < TopKSize {
+ t.counts[key] += n
+ return
+ }
+ // Full and new: take the smallest counter as the decrement.
+ min := int64(-1)
+ for _, v := range t.counts {
+ if min < 0 || v < min {
+ min = v
+ }
+ }
+ if n <= min {
+ t.other += n
+ return
+ }
+ for k, v := range t.counts {
+ if v <= min {
+ delete(t.counts, k)
+ t.other += v
+ } else {
+ t.counts[k] = v - min
+ t.other += min
+ }
+ }
+ t.counts[key] = n - min
+ t.other += min
+}
+
+// Merge folds o into t: counters add, other adds, then t re-folds to the
+// cap so a merge of two full summaries stays bounded.
+func (t *TopK) Merge(o *TopK) {
+ merged := NewTopK()
+ merged.other = t.other + o.other
+ sum := make(map[string]int64, len(t.counts)+len(o.counts))
+ for k, v := range t.counts {
+ sum[k] += v
+ }
+ for k, v := range o.counts {
+ sum[k] += v
+ }
+ for k, v := range sum { // Add re-applies the cap
+ merged.Add(k, v)
+ }
+ t.counts, t.other = merged.counts, merged.other
+}
+
+// Fold returns the counters plus OtherKey when any mass fell over.
+func (t *TopK) Fold() map[string]int64 {
+ out := make(map[string]int64, len(t.counts)+1)
+ for k, v := range t.counts {
+ out[k] = v
+ }
+ if t.other > 0 {
+ out[OtherKey] = t.other
+ }
+ return out
+}
+
+// TopKFrom rebuilds a summary from a folded map (the day file's shape).
+func TopKFrom(m map[string]int64) *TopK {
+ t := NewTopK()
+ for k, v := range m {
+ if k == OtherKey {
+ t.other += v
+ continue
+ }
+ t.Add(k, v)
+ }
+ return t
+}
+
+// Approximate reports whether counters are lower bounds (mass fell over).
+func (t *TopK) Approximate() bool { return t.other > 0 }
+```
+
+- [ ] **Step 5: Run `go test ./internal/analytics/ -count=1 -v`, PASS; gofmt; vet. Commit** "analytics: HyperLogLog and Misra–Gries sketches (spec §3.3)".
+
+---
+
+### Task 2: Bot list, day salt, day-file shape and idempotent CAS merge
+
+**Files:**
+- Create: `internal/analytics/bots.go`, `internal/analytics/salt.go`, `internal/analytics/day.go`
+- Test: `internal/analytics/bots_test.go`, `internal/analytics/salt_test.go`, `internal/analytics/day_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ func IsBot(ua string) bool
+ func DaySalt(dir, date string) ([]byte, error) // 32 bytes; creates 0600 <dir>/analytics-salt-<date>; removes other analytics-salt-* files
+ type HourRow struct { PV int64 `json:"pv"`; Bots int64 `json:"bots"`; Bytes int64 `json:"bytes"` }
+ type Dropped struct { E DropCounts `json:"e"`; A DropCounts `json:"a"`; Flush int64 `json:"flush"` }
+ type DropCounts struct { Rate, Size, Origin, Malformed int64 } // json tags rate,size,origin,malformed
+ type Day struct { V int; Date string; Hours map[string]HourRow; Visitors string; Pages, Referers, Events map[string]int64; Seq map[string]int64; Dropped Dropped; Countries map[string]int64; Restarted bool `json:"restarted,omitempty"` }
+ type Delta struct { Date string; Box string; Seq int64; Hours map[string]HourRow; Visitors *HLL; Pages, Referers, Events *TopK; Dropped Dropped; Restarted bool }
+ func Prefix(sqid, app string) string // applog.FamilyPrefix(FamilyAnalytics, …)
+ func BoxPrefix(sqid, app, box string) string // Prefix + box + "/days/"
+ func DayKey(sqid, app, box, date string) string
+ func (d *Day) Merge(x Delta) (applied bool, err error) // seq ≤ recorded → false,nil
+ func UpsertDay(ctx, s release.Store, sqid, app string, x Delta) error // CAS ×3
+ ```
+
+- [ ] **Step 1: Failing tests**
+
+`bots_test.go`:
+```go
+package analytics
+
+import "testing"
+
+func TestIsBot(t *testing.T) {
+ yes := []string{
+ "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
+ "Mozilla/5.0 (compatible; bingbot/2.0)", "curl/8.4.0", "Wget/1.21",
+ "python-requests/2.31", "Go-http-client/1.1", "Mozilla/5.0 HeadlessChrome/120",
+ "facebookexternalhit/1.1", "Slackbot-LinkExpanding", "AhrefsBot/7.0", "",
+ }
+ no := []string{
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
+ }
+ for _, ua := range yes {
+ if !IsBot(ua) {
+ t.Errorf("IsBot(%q) = false", ua)
+ }
+ }
+ for _, ua := range no {
+ if IsBot(ua) {
+ t.Errorf("IsBot(%q) = true", ua)
+ }
+ }
+}
+```
+
+`salt_test.go`:
+```go
+package analytics
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestDaySaltIsStableAndRotates(t *testing.T) {
+ dir := t.TempDir()
+ a, err := DaySalt(dir, "2026-09-07")
+ if err != nil || len(a) != 32 {
+ t.Fatalf("salt: %v len=%d", err, len(a))
+ }
+ b, _ := DaySalt(dir, "2026-09-07")
+ if string(a) != string(b) {
+ t.Fatal("same day must return the same salt (restart-stable)")
+ }
+ if runtime.GOOS != "windows" {
+ st, _ := os.Stat(filepath.Join(dir, "analytics-salt-2026-09-07"))
+ if st.Mode().Perm() != 0o600 {
+ t.Fatalf("mode %v", st.Mode().Perm())
+ }
+ }
+ c, _ := DaySalt(dir, "2026-09-08")
+ if string(c) == string(a) {
+ t.Fatal("a new day must salt afresh")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "analytics-salt-2026-09-07")); !os.IsNotExist(err) {
+ t.Fatal("rotation must delete the previous day's salt")
+ }
+}
+```
+
+`day_test.go`:
+```go
+package analytics
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func delta(box string, seq int64, pv int64) Delta {
+ h := &HLL{}
+ h.Add(h64(box + itoa(int(seq))))
+ p := NewTopK()
+ p.Add("/", pv)
+ return Delta{Date: "2026-09-07", Box: box, Seq: seq, Hours: map[string]HourRow{"09": {PV: pv, Bytes: pv * 100}},
+ Visitors: h, Pages: p, Referers: NewTopK(), Events: NewTopK()}
+}
+
+func TestDayMergeIsSeqIdempotent(t *testing.T) {
+ var d Day
+ if ok, err := d.Merge(delta("box-a", 5, 10)); !ok || err != nil {
+ t.Fatalf("first merge: %v %v", ok, err)
+ }
+ if ok, _ := d.Merge(delta("box-a", 5, 10)); ok {
+ t.Fatal("replaying the same seq must be a no-op")
+ }
+ if ok, _ := d.Merge(delta("box-a", 4, 10)); ok {
+ t.Fatal("an older seq must be a no-op")
+ }
+ if ok, _ := d.Merge(delta("box-b", 1, 7)); !ok {
+ t.Fatal("another box's seq is independent")
+ }
+ if d.Hours["09"].PV != 17 || d.Pages["/"] != 17 || d.Seq["box-a"] != 5 || d.Seq["box-b"] != 1 || d.V != 1 || d.Date != "2026-09-07" {
+ t.Fatalf("merged = %+v", d)
+ }
+ vis, _ := DecodeHLL(d.Visitors)
+ if c := vis.Count(); c < 1.5 || c > 2.5 {
+ t.Fatalf("visitors ≈ 2, got %.2f", c)
+ }
+ b, _ := json.Marshal(d)
+ for _, want := range []string{`"v":1`, `"hours"`, `"visitors"`, `"pages"`, `"referers"`, `"events"`, `"seq"`, `"dropped"`, `"countries"`} {
+ if !contains(string(b), want) {
+ t.Errorf("wire missing %s in %s", want, b)
+ }
+ }
+}
+
+func TestUpsertDayCASAcrossBoxes(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 1, 3)); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil { // retry of an ambiguous put
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, DayKey("bab", "web", "box-a", "2026-09-07"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var d Day
+ json.Unmarshal(raw, &d)
+ if d.Hours["09"].PV != 7 || d.Seq["box-a"] != 2 {
+ t.Fatalf("day = %+v", d)
+ }
+ if DayKey("bab", "web", "box-a", "2026-09-07") != "apps/bab/web/analytics/box-a/days/2026-09-07.json" {
+ t.Fatal(DayKey("bab", "web", "box-a", "2026-09-07"))
+ }
+}
+
+func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0) }
+func indexOf(s, sub string) int {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return i
+ }
+ }
+ return -1
+}
+```
+
+- [ ] **Step 2: Run, expect compile failure**
+
+- [ ] **Step 3: Implement `bots.go`**
+
+```go
+package analytics
+
+import "strings"
+
+// botMarkers is the vendored user-agent list (spec §3.3): incomplete by
+// nature, so the tab shows the bots count rather than pretending. Matched
+// case-insensitively as substrings. An empty UA is a bot: no browser sends
+// none.
+var botMarkers = []string{
+ "bot", "crawler", "spider", "slurp", "curl/", "wget/", "python-requests",
+ "go-http-client", "headlesschrome", "phantomjs", "facebookexternalhit",
+ "ahrefs", "semrush", "mj12", "dotbot", "petalbot", "bytespider", "gptbot",
+ "claudebot", "ccbot", "applebot", "yandex", "baiduspider", "duckduckbot",
+ "linkedinbot", "twitterbot", "discordbot", "telegrambot", "whatsapp",
+ "pingdom", "uptimerobot", "statuscake", "site24x7", "newrelicpinger",
+ "lighthouse", "pagespeed", "gtmetrix", "httpclient", "okhttp", "java/",
+ "libwww", "scrapy", "node-fetch", "axios/", "postmanruntime", "insomnia",
+}
+
+// IsBot reports whether ua looks like an automated client.
+func IsBot(ua string) bool {
+ if ua == "" {
+ return true
+ }
+ l := strings.ToLower(ua)
+ for _, m := range botMarkers {
+ if strings.Contains(l, m) {
+ return true
+ }
+ }
+ return false
+}
+```
+
+- [ ] **Step 4: Implement `salt.go`**
+
+```go
+package analytics
+
+import (
+ "crypto/rand"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const saltPrefix = "analytics-salt-"
+
+// DaySalt returns the box's visitor salt for date (YYYY-MM-DD), creating
+// <dir>/analytics-salt-<date> with 32 random bytes at 0600 when absent and
+// deleting any other day's file (spec §3.3: restart-stable within the day,
+// gone on rotation, never in the bucket). dir is the box data directory.
+func DaySalt(dir, date string) ([]byte, error) {
+ path := filepath.Join(dir, saltPrefix+date)
+ if b, err := os.ReadFile(path); err == nil && len(b) == 32 {
+ return b, nil
+ }
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return nil, err
+ }
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ if errors.Is(err, os.ErrExist) { // a sibling process won the race
+ return os.ReadFile(path)
+ }
+ return nil, err
+ }
+ _, werr := f.Write(b)
+ cerr := f.Close()
+ if werr != nil || cerr != nil {
+ os.Remove(path)
+ return nil, errors.Join(werr, cerr)
+ }
+ // Rotation: every other day's salt goes, so nothing on disk can join a
+ // hash across days.
+ entries, _ := os.ReadDir(dir)
+ for _, e := range entries {
+ if n := e.Name(); strings.HasPrefix(n, saltPrefix) && n != saltPrefix+date {
+ os.Remove(filepath.Join(dir, n))
+ }
+ }
+ return b, nil
+}
+```
+
+- [ ] **Step 5: Implement `day.go`**
+
+```go
+package analytics
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// HourRow is one UTC hour's counters.
+type HourRow struct {
+ PV int64 `json:"pv"`
+ Bots int64 `json:"bots"`
+ Bytes int64 `json:"bytes"`
+}
+
+// DropCounts is why events were dropped at a door (steps 3–4 fill them).
+type DropCounts struct {
+ Rate int64 `json:"rate"`
+ Size int64 `json:"size"`
+ Origin int64 `json:"origin"`
+ Malformed int64 `json:"malformed"`
+}
+
+// Dropped is the honesty block (spec §3.3).
+type Dropped struct {
+ E DropCounts `json:"e"`
+ A DropCounts `json:"a"`
+ Flush int64 `json:"flush"`
+}
+
+// Day is apps/<sqid>/<app>/analytics/<box>/days/<date>.json (spec §3.3):
+// one box's counting for one app for one UTC day. Additive shape.
+type Day struct {
+ V int `json:"v"`
+ Date string `json:"date"`
+ Hours map[string]HourRow `json:"hours"`
+ Visitors string `json:"visitors"`
+ Pages map[string]int64 `json:"pages"`
+ Referers map[string]int64 `json:"referers"`
+ Events map[string]int64 `json:"events"`
+ Seq map[string]int64 `json:"seq"`
+ Dropped Dropped `json:"dropped"`
+ Countries map[string]int64 `json:"countries"`
+ Restarted bool `json:"restarted,omitempty"`
+}
+
+// Delta is one flush batch from one box for one app-day.
+type Delta struct {
+ Date string
+ Box string
+ Seq int64
+ Hours map[string]HourRow
+ Visitors *HLL
+ Pages *TopK
+ Referers *TopK
+ Events *TopK
+ Dropped Dropped
+ Restarted bool
+}
+
+// Prefix is the app's analytics/ subtree.
+func Prefix(sqid, app string) string { return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) }
+
+// BoxPrefix is one box's day files.
+func BoxPrefix(sqid, app, box string) string { return Prefix(sqid, app) + box + "/days/" }
+
+// DayKey is one day file.
+func DayKey(sqid, app, box, date string) string { return BoxPrefix(sqid, app, box) + date + ".json" }
+
+// Merge folds x into d unless x.Seq is not above the recorded seq for
+// x.Box — the idempotency rule that makes a retried conditional put safe.
+func (d *Day) Merge(x Delta) (bool, error) {
+ if d.Seq == nil {
+ d.Seq = map[string]int64{}
+ }
+ if x.Seq <= d.Seq[x.Box] {
+ return false, nil
+ }
+ d.V, d.Date = 1, x.Date
+ if d.Hours == nil {
+ d.Hours = map[string]HourRow{}
+ }
+ for h, row := range x.Hours {
+ cur := d.Hours[h]
+ cur.PV += row.PV
+ cur.Bots += row.Bots
+ cur.Bytes += row.Bytes
+ d.Hours[h] = cur
+ }
+ vis, err := DecodeHLL(d.Visitors)
+ if err != nil {
+ return false, err
+ }
+ if x.Visitors != nil {
+ vis.Merge(x.Visitors)
+ }
+ d.Visitors = vis.Encode()
+ d.Pages = mergeTop(d.Pages, x.Pages)
+ d.Referers = mergeTop(d.Referers, x.Referers)
+ d.Events = mergeTop(d.Events, x.Events)
+ d.Dropped.E = addDrops(d.Dropped.E, x.Dropped.E)
+ d.Dropped.A = addDrops(d.Dropped.A, x.Dropped.A)
+ d.Dropped.Flush += x.Dropped.Flush
+ d.Restarted = d.Restarted || x.Restarted
+ if d.Countries == nil {
+ d.Countries = map[string]int64{}
+ }
+ d.Seq[x.Box] = x.Seq
+ return true, nil
+}
+
+func mergeTop(m map[string]int64, t *TopK) map[string]int64 {
+ k := TopKFrom(m)
+ if t != nil {
+ k.Merge(t)
+ }
+ return k.Fold()
+}
+
+func addDrops(a, b DropCounts) DropCounts {
+ return DropCounts{a.Rate + b.Rate, a.Size + b.Size, a.Origin + b.Origin, a.Malformed + b.Malformed}
+}
+
+// UpsertDay CAS-merges x into the app-day object for x.Box: read (etag),
+// Merge, conditional put; on ErrCASConflict re-read and retry, three
+// attempts. A no-op merge (seq already recorded) writes nothing.
+func UpsertDay(ctx context.Context, s release.Store, sqid, app string, x Delta) error {
+ key := DayKey(sqid, app, x.Box, x.Date)
+ for attempt := 0; attempt < 3; attempt++ {
+ var day Day
+ raw, etag, err := s.Get(ctx, key)
+ switch {
+ case err == nil:
+ if uerr := json.Unmarshal(raw, &day); uerr != nil {
+ return fmt.Errorf("analytics: parse %s: %w", key, uerr)
+ }
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return err
+ }
+ applied, err := day.Merge(x)
+ if err != nil {
+ return err
+ }
+ if !applied {
+ return nil
+ }
+ out, err := json.Marshal(day)
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, out, etag); err == nil {
+ return nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ }
+ return fmt.Errorf("analytics: %s: lost CAS race three times", key)
+}
+```
+
+Ensure `Day` zero-value fields marshal as `{}` not `null` for `hours`, `pages`, `referers`, `events`, `seq`, `countries` (initialise nil maps in `Merge`; `mergeTop` returns a non-nil map).
+
+- [ ] **Step 6: Run, PASS (also `GOOS=windows go build ./internal/analytics/` to prove the salt file compiles everywhere). Commit** "analytics: bot list, day salt, day file with seq-idempotent CAS merge (spec §3.3)".
+
+---
+
+### Task 3: The meter the edge feeds
+
+**Files:**
+- Create: `internal/analytics/meter.go`
+- Test: `internal/analytics/meter_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type Obs struct { Acct, App, Host, Method string; Status int; ContentType, Path, RefererHost, UA, ClientIP string; Bytes int64; Prefetch bool; FetchDest string; When time.Time }
+ type AppKey = applog.AppKey
+ func NewMeter(saltDir, box string, now func() time.Time) *Meter
+ func (m *Meter) Observe(o Obs) // decides pageview/bot; counts bytes for every response
+ func (m *Meter) Track(acct, app, name string, when time.Time) // named events (steps 3–4 call it)
+ func (m *Meter) Drain() map[AppKey][]Delta // one Delta per app-day touched, seq stamped
+ func (m *Meter) Requeue(pending map[AppKey][]Delta) // failed flush: kept once, then dropped with Dropped.Flush++
+ func IsPageview(o Obs) (pageview bool, bot bool) // pure rule from §3.3
+ func VisitorHash(salt []byte, host, ip, ua string) uint64
+ func EventName(s string) (string, bool) // lowercased, ≤64 bytes of [a-z0-9_.-]
+ ```
+
+- [ ] **Step 1: Failing tests**
+
+```go
+package analytics
+
+import (
+ "testing"
+ "time"
+)
+
+func obs(method string, status int, ctype string) Obs {
+ return Obs{Acct: "bab", App: "web", Host: "h1", Method: method, Status: status, ContentType: ctype,
+ Path: "/pricing?x=1", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.5",
+ Bytes: 1200, When: time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)}
+}
+
+func TestIsPageviewRule(t *testing.T) {
+ cases := []struct {
+ name string
+ o Obs
+ pv bool
+ bot bool
+ }{
+ {"html 200", obs("GET", 200, "text/html; charset=utf-8"), true, false},
+ {"json", obs("GET", 200, "application/json"), false, false},
+ {"404", obs("GET", 404, "text/html"), false, false},
+ {"redirect", obs("GET", 302, "text/html"), false, false},
+ {"304", obs("GET", 304, ""), false, false},
+ {"head", obs("HEAD", 200, "text/html"), false, false},
+ {"post", obs("POST", 200, "text/html"), false, false},
+ {"prefetch", func() Obs { o := obs("GET", 200, "text/html"); o.Prefetch = true; return o }(), false, false},
+ {"fetch dest empty", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "empty"; return o }(), false, false},
+ {"fetch dest document", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "document"; return o }(), true, false},
+ {"carlos prefix", func() Obs { o := obs("GET", 200, "text/html"); o.Path = "/__carlos/a"; return o }(), false, false},
+ {"bot", func() Obs { o := obs("GET", 200, "text/html"); o.UA = "Googlebot/2.1"; return o }(), false, true},
+ }
+ for _, c := range cases {
+ pv, bot := IsPageview(c.o)
+ if pv != c.pv || bot != c.bot {
+ t.Errorf("%s: pv=%v bot=%v, want %v %v", c.name, pv, bot, c.pv, c.bot)
+ }
+ }
+}
+
+func TestMeterCountsAndDrains(t *testing.T) {
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ m.Observe(obs("GET", 200, "text/html"))
+ m.Observe(obs("GET", 200, "text/html"))
+ o := obs("GET", 200, "text/html")
+ o.ClientIP = "198.51.100.9"
+ m.Observe(o)
+ m.Observe(obs("GET", 200, "application/json")) // bytes only
+ b := obs("GET", 200, "text/html")
+ b.UA = "curl/8"
+ m.Observe(b)
+ m.Track("bab", "web", "Signup", now)
+ m.Track("bab", "web", "bad name!", now) // rejected
+ out := m.Drain()
+ ds := out[AppKey{Acct: "bab", App: "web"}]
+ if len(ds) != 1 {
+ t.Fatalf("deltas = %d", len(ds))
+ }
+ d := ds[0]
+ if d.Date != "2026-09-07" || d.Box != "box1" || d.Seq == 0 {
+ t.Fatalf("delta = %+v", d)
+ }
+ if h := d.Hours["09"]; h.PV != 3 || h.Bots != 1 || h.Bytes != 6000 {
+ t.Fatalf("hour = %+v", h)
+ }
+ if c := d.Visitors.Count(); c < 1.5 || c > 2.5 {
+ t.Fatalf("visitors ≈ 2, got %.2f", c)
+ }
+ if p := d.Pages.Fold(); p["/pricing"] != 3 {
+ t.Fatalf("pages = %v", p)
+ }
+ if r := d.Referers.Fold(); r["news.ycombinator.com"] != 3 {
+ t.Fatalf("referers = %v", r)
+ }
+ if e := d.Events.Fold(); e["signup"] != 1 || len(e) != 1 {
+ t.Fatalf("events = %v", e)
+ }
+ if len(m.Drain()) != 0 {
+ t.Fatal("second drain must be empty")
+ }
+ // Event-time bucketing: an observation at 09:59 flushed at 10:00 lands in 09.
+ m.Observe(func() Obs { o := obs("GET", 200, "text/html"); o.When = now.Add(29 * time.Minute); return o }())
+ now = now.Add(31 * time.Minute)
+ if d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0]; d.Hours["09"].PV != 1 || d.Hours["10"].PV != 0 {
+ t.Fatalf("event-time bucket: %+v", d.Hours)
+ }
+}
+
+func TestMeterRequeueSurvivesOnce(t *testing.T) {
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ m.Observe(obs("GET", 200, "text/html"))
+ first := m.Drain()
+ m.Requeue(first) // flush failed
+ m.Observe(obs("GET", 200, "text/html"))
+ second := m.Drain()
+ ds := second[AppKey{Acct: "bab", App: "web"}]
+ if len(ds) != 2 || ds[0].Seq >= ds[1].Seq {
+ t.Fatalf("requeued delta must come back first with its own seq: %+v", ds)
+ }
+ m.Requeue(second) // failed again: the older window drops
+ m.Observe(obs("GET", 200, "text/html"))
+ third := m.Drain()[AppKey{Acct: "bab", App: "web"}]
+ if len(third) != 2 || third[1].Dropped.Flush != 1 {
+ t.Fatalf("second failure must drop the oldest and count it: %+v", third)
+ }
+}
+
+func TestVisitorHashAndEventName(t *testing.T) {
+ s1, s2 := []byte("salt-a"), []byte("salt-b")
+ if VisitorHash(s1, "h", "ip", "ua") != VisitorHash(s1, "h", "ip", "ua") {
+ t.Fatal("deterministic")
+ }
+ if VisitorHash(s1, "h", "ip", "ua") == VisitorHash(s2, "h", "ip", "ua") {
+ t.Fatal("salt must change the hash")
+ }
+ for in, want := range map[string]string{"Signup": "signup", "checkout.done": "checkout.done", "a b": "", "": "", "x/y": ""} {
+ got, ok := EventName(in)
+ if (want == "") == ok || got != want {
+ t.Errorf("EventName(%q) = %q,%v", in, got, ok)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect compile failure**
+
+- [ ] **Step 3: Implement `meter.go`**
+
+```go
+package analytics
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "log"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/apperr"
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// Obs is one served response as the edge saw it (spec §3.3). The edge
+// fills it from the observer defer; nothing here touches the request.
+type Obs struct {
+ Acct, App, Host string
+ Method string
+ Status int
+ ContentType string
+ Path string // raw; sanitised here
+ RefererHost string // host only, "" = direct
+ UA string
+ ClientIP string
+ Bytes int64
+ Prefetch bool // Sec-Purpose/Purpose: prefetch
+ FetchDest string // Sec-Fetch-Dest, "" when absent
+ When time.Time
+}
+
+// AppKey is the (account, app) the meter buckets by.
+type AppKey = applog.AppKey
+
+// IsPageview applies §3.3's rule: GET, 200, text/html, not prefetch, dest
+// document or absent, not under /__carlos/. A bot UA is reported
+// separately and never a pageview.
+func IsPageview(o Obs) (pageview, bot bool) {
+ if o.Method != "GET" || o.Status != 200 {
+ return false, false
+ }
+ mt := strings.ToLower(strings.TrimSpace(strings.SplitN(o.ContentType, ";", 2)[0]))
+ if mt != "text/html" {
+ return false, false
+ }
+ if o.Prefetch || (o.FetchDest != "" && o.FetchDest != "document") {
+ return false, false
+ }
+ if strings.HasPrefix(o.Path, "/__carlos/") {
+ return false, false
+ }
+ if IsBot(o.UA) {
+ return false, true
+ }
+ return true, false
+}
+
+// VisitorHash is sha256(salt ‖ host ‖ ip ‖ ua) folded to 64 bits for the
+// sketch; the platform never stores the inputs.
+func VisitorHash(salt []byte, host, ip, ua string) uint64 {
+ h := sha256.New()
+ h.Write(salt)
+ h.Write([]byte{0})
+ h.Write([]byte(host))
+ h.Write([]byte{0})
+ h.Write([]byte(ip))
+ h.Write([]byte{0})
+ h.Write([]byte(ua))
+ return binary.BigEndian.Uint64(h.Sum(nil)[:8])
+}
+
+var eventName = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
+
+// EventName normalises a named event: lowercased, 64 bytes of [a-z0-9_.-].
+func EventName(s string) (string, bool) {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if !eventName.MatchString(s) {
+ return "", false
+ }
+ return s, true
+}
+
+// Meter accumulates per-app deltas between flushes. One per edge process;
+// Observe sits on the serving path (one mutex, no I/O except the first
+// salt read of a day).
+type Meter struct {
+ box string
+ saltDir string
+ now func() time.Time
+
+ mu sync.Mutex
+ cur map[AppKey]map[string]*Delta // app → date → delta being built
+ pending map[AppKey][]Delta // re-queued after a failed flush (one window)
+ seq int64
+ saltDay string
+ salt []byte
+ started string // date the process started: Restarted is set on that day's first delta
+}
+
+// NewMeter returns a meter whose salts live in saltDir (the box data dir).
+func NewMeter(saltDir, box string, now func() time.Time) *Meter {
+ return &Meter{box: box, saltDir: saltDir, now: now, cur: map[AppKey]map[string]*Delta{},
+ pending: map[AppKey][]Delta{}, seq: now().UnixMilli(), started: usage.DayOf(now())}
+}
+
+func (m *Meter) saltFor(date string) []byte {
+ if m.saltDay == date && m.salt != nil {
+ return m.salt
+ }
+ s, err := DaySalt(m.saltDir, date)
+ if err != nil {
+ log.Printf("analytics: day salt: %v (visitors not counted until it works)", err)
+ return nil
+ }
+ m.saltDay, m.salt = date, s
+ return s
+}
+
+func (m *Meter) delta(k AppKey, date string) *Delta {
+ days := m.cur[k]
+ if days == nil {
+ days = map[string]*Delta{}
+ m.cur[k] = days
+ }
+ d := days[date]
+ if d == nil {
+ d = &Delta{Date: date, Box: m.box, Hours: map[string]HourRow{}, Visitors: &HLL{},
+ Pages: NewTopK(), Referers: NewTopK(), Events: NewTopK(), Restarted: date == m.started}
+ days[date] = d
+ }
+ return d
+}
+
+// Observe counts one response. Bytes count for every response; pageview,
+// bot, visitor, page and referer only when the rule says so.
+func (m *Meter) Observe(o Obs) {
+ pv, bot := IsPageview(o)
+ date, hour := usage.DayOf(o.When), usage.HourLabel(o.When)
+ path := apperr.SanitizePath(o.Path)
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ d := m.delta(AppKey{Acct: o.Acct, App: o.App}, date)
+ row := d.Hours[hour]
+ row.Bytes += o.Bytes
+ if bot {
+ row.Bots++
+ }
+ if pv {
+ row.PV++
+ if salt := m.saltFor(date); salt != nil {
+ d.Visitors.Add(VisitorHash(salt, o.Host, o.ClientIP, o.UA))
+ }
+ d.Pages.Add(path, 1)
+ ref := o.RefererHost
+ if ref == "" {
+ ref = "direct"
+ }
+ d.Referers.Add(strings.ToLower(ref), 1)
+ }
+ d.Hours[hour] = row
+}
+
+// Track counts one named event (the doors call it; nothing does in step 2).
+func (m *Meter) Track(acct, app, name string, when time.Time) {
+ n, ok := EventName(name)
+ if !ok {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.delta(AppKey{Acct: acct, App: app}, usage.DayOf(when)).Events.Add(n, 1)
+}
+
+// Drain returns everything buffered — re-queued deltas first, then the
+// current ones, each stamped with a fresh box-monotonic seq — and resets.
+func (m *Meter) Drain() map[AppKey][]Delta {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ out := map[AppKey][]Delta{}
+ for k, ds := range m.pending {
+ out[k] = append(out[k], ds...)
+ }
+ m.pending = map[AppKey][]Delta{}
+ for k, days := range m.cur {
+ for _, d := range days {
+ m.seq++
+ d.Seq = m.seq
+ out[k] = append(out[k], *d)
+ }
+ }
+ m.cur = map[AppKey]map[string]*Delta{}
+ if m.started != "" && usage.DayOf(m.now()) != m.started {
+ m.started = "" // the restart note is only for the day it happened
+ }
+ return out
+}
+
+// Requeue holds a failed flush for the next drain. One window survives:
+// a delta already re-queued once is dropped and counted in Dropped.Flush
+// on the newest delta for that app.
+func (m *Meter) Requeue(failed map[AppKey][]Delta) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for k, ds := range failed {
+ if n := len(m.pending[k]); n > 0 {
+ dropped := int64(n)
+ m.pending[k] = nil
+ if len(ds) > 0 {
+ ds[len(ds)-1].Dropped.Flush += dropped
+ }
+ }
+ m.pending[k] = append(m.pending[k], ds...)
+ }
+}
+```
+
+Note: `Requeue` must not re-stamp `Seq`; `Drain` stamps fresh seqs only on current deltas, and re-queued ones keep the seq they were drained with (they may have landed: the day file's seq rule makes the replay a no-op). Re-check the second test: after the first failed flush, the re-queued delta (seq 1) comes back before the new delta (seq 2); on the second failure both are re-queued, `pending[k]` had 0 entries at that moment (Drain cleared it), so nothing is dropped yet — adjust the test's third expectation: the drop happens when a delta is re-queued while `pending` still holds an earlier window, i.e. `Requeue(first)`, then `Requeue(second)` without a Drain between. Rewrite `TestMeterRequeueSurvivesOnce`'s tail to: `m.Requeue(first)`; `m.Requeue(second_without_drain)` — concretely: after `second := m.Drain()`, call `m.Requeue(second)`, then immediately `m.Requeue(map[AppKey][]Delta{k: {obsDelta}})` with a hand-built delta, then `Drain()` and assert the first window is gone and `Dropped.Flush == 2` on the last delta. Write whichever exact sequence matches the implementation and state it in the report.
+
+- [ ] **Step 4: Run, PASS; gofmt; vet. Commit** "analytics: the meter the edge feeds — pageview rule, visitors, top keys, requeue (spec §3.3)".
+
+---
+
+### Task 4: Cross-box range read
+
+**Files:**
+- Create: `internal/analytics/read.go`
+- Test: `internal/analytics/read_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type DaySummary struct { Date string; PV, Bots, Bytes int64; Visitors int64; Hours [24]int64; Restarted bool }
+ type Summary struct { From, To string; Days []DaySummary; PV, Bots, Bytes, Visitors int64; Pages, Referers, Events map[string]int64; PagesApprox, ReferersApprox, EventsApprox bool; Dropped Dropped; Boxes []string }
+ func ReadRange(ctx, s release.Store, sqid, app string, from, to time.Time) (Summary, error) // inclusive UTC dates
+ func Dates(from, to time.Time) []string
+ ```
+
+- [ ] **Step 1: Failing test**
+
+```go
+package analytics
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestReadRangeMergesBoxesAndDays(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ d1 := delta("box-a", 1, 10)
+ d2 := delta("box-b", 1, 5)
+ d3 := delta("box-a", 2, 7)
+ d3.Date = "2026-09-08"
+ d3.Restarted = true
+ for _, d := range []Delta{d1, d2, d3} {
+ if err := UpsertDay(ctx, s, "bab", "web", d); err != nil {
+ t.Fatal(err)
+ }
+ }
+ from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
+ sum, err := ReadRange(ctx, s, "bab", "web", from, from.Add(24*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sum.Days) != 2 || sum.Days[0].Date != "2026-09-07" || sum.Days[0].PV != 15 || sum.Days[1].PV != 7 {
+ t.Fatalf("days = %+v", sum.Days)
+ }
+ if sum.PV != 22 || sum.Pages["/"] != 22 || sum.Days[0].Hours[9] != 15 || !sum.Days[1].Restarted || sum.Days[0].Restarted {
+ t.Fatalf("summary = %+v", sum)
+ }
+ if sum.Visitors != sum.Days[0].Visitors+sum.Days[1].Visitors || sum.Days[0].Visitors < 1 {
+ t.Fatalf("visitors must be daily sums: %+v", sum)
+ }
+ if len(sum.Boxes) != 2 {
+ t.Fatalf("boxes = %v", sum.Boxes)
+ }
+ // An app with no day files reads as an empty summary, not an error.
+ empty, err := ReadRange(ctx, s, "bab", "nothing", from, from)
+ if err != nil || empty.PV != 0 || len(empty.Days) != 1 {
+ t.Fatalf("empty = %+v %v", empty, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect compile failure**
+
+- [ ] **Step 3: Implement `read.go`**
+
+```go
+package analytics
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "math"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// DaySummary is one UTC day across every box.
+type DaySummary struct {
+ Date string
+ PV int64
+ Bots int64
+ Bytes int64
+ Visitors int64 // HLL estimate, rounded
+ Hours [24]int64
+ Restarted bool
+}
+
+// Summary is what the tab and the API render: per-day rows plus totals.
+// Visitors is the SUM of daily estimates (spec §3.3: "daily visitors,
+// summed"), never a cross-day union.
+type Summary struct {
+ From, To string
+ Days []DaySummary
+ PV, Bots int64
+ Bytes int64
+ Visitors int64
+ Pages map[string]int64
+ Referers map[string]int64
+ Events map[string]int64
+ PagesApprox bool
+ ReferersApprox bool
+ EventsApprox bool
+ Dropped Dropped
+ Boxes []string
+}
+
+// Dates lists the UTC dates from from to to inclusive.
+func Dates(from, to time.Time) []string {
+ var out []string
+ for d := from.UTC().Truncate(24 * time.Hour); !d.After(to.UTC()); d = d.Add(24 * time.Hour) {
+ out = append(out, usage.DayOf(d))
+ }
+ return out
+}
+
+// ReadRange lists the app's boxes and reads each day file in the range,
+// merging across boxes: counters sum, visitor sketches union per day,
+// top-key summaries merge and re-fold. A missing day file is an empty
+// day; a store error fails the read.
+func ReadRange(ctx context.Context, s release.Store, sqid, app string, from, to time.Time) (Summary, error) {
+ sum := Summary{From: usage.DayOf(from), To: usage.DayOf(to)}
+ boxDirs, err := s.ListDirs(ctx, Prefix(sqid, app))
+ if err != nil {
+ return sum, err
+ }
+ for _, dir := range boxDirs {
+ sum.Boxes = append(sum.Boxes, boxName(dir))
+ }
+ sort.Strings(sum.Boxes)
+ pages, refs, events := NewTopK(), NewTopK(), NewTopK()
+ for _, date := range Dates(from, to) {
+ ds := DaySummary{Date: date}
+ vis := &HLL{}
+ for _, box := range sum.Boxes {
+ raw, _, err := s.Get(ctx, DayKey(sqid, app, box, date))
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return sum, err
+ }
+ var day Day
+ if err := json.Unmarshal(raw, &day); err != nil {
+ return sum, err
+ }
+ for h, row := range day.Hours {
+ if i, err := strconv.Atoi(h); err == nil && i >= 0 && i < 24 {
+ ds.Hours[i] += row.PV
+ }
+ ds.PV += row.PV
+ ds.Bots += row.Bots
+ ds.Bytes += row.Bytes
+ }
+ if v, err := DecodeHLL(day.Visitors); err == nil {
+ vis.Merge(v)
+ }
+ pages.Merge(TopKFrom(day.Pages))
+ refs.Merge(TopKFrom(day.Referers))
+ events.Merge(TopKFrom(day.Events))
+ sum.Dropped.E = addDrops(sum.Dropped.E, day.Dropped.E)
+ sum.Dropped.A = addDrops(sum.Dropped.A, day.Dropped.A)
+ sum.Dropped.Flush += day.Dropped.Flush
+ ds.Restarted = ds.Restarted || day.Restarted
+ }
+ if !vis.Empty() {
+ ds.Visitors = int64(math.Round(vis.Count()))
+ }
+ sum.Days = append(sum.Days, ds)
+ sum.PV += ds.PV
+ sum.Bots += ds.Bots
+ sum.Bytes += ds.Bytes
+ sum.Visitors += ds.Visitors
+ }
+ sum.Pages, sum.PagesApprox = pages.Fold(), pages.Approximate()
+ sum.Referers, sum.ReferersApprox = refs.Fold(), refs.Approximate()
+ sum.Events, sum.EventsApprox = events.Fold(), events.Approximate()
+ return sum, nil
+}
+
+func boxName(dir string) string {
+ t := strings.TrimSuffix(dir, "/")
+ if i := strings.LastIndexByte(t, '/'); i >= 0 {
+ return t[i+1:]
+ }
+ return t
+}
+```
+
+- [ ] **Step 4: Run, PASS. Commit** "analytics: range read across boxes and days".
+
+---
+
+### Task 5: The edge tap and the switch map
+
+**Files:**
+- Create: `internal/edge/analytics.go`
+- Modify: `internal/edge/edge.go` (fields; one call in the observer defer)
+- Test: `internal/edge/analytics_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type AnalyticsMeter interface { Observe(analytics.Obs) }
+ func (e *Edge) EnableAnalytics(m AnalyticsMeter)
+ func (e *Edge) SetAnalyticsOn(on map[string]bool) // key sqid+"/"+app; nil = nothing on
+ func clientIP(r *http.Request) string
+ func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64)
+ ```
+
+- [ ] **Step 1: Failing tests**
+
+```go
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/analytics"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type memAnalytics struct {
+ mu sync.Mutex
+ obs []analytics.Obs
+}
+
+func (m *memAnalytics) Observe(o analytics.Obs) { m.mu.Lock(); m.obs = append(m.obs, o); m.mu.Unlock() }
+func (m *memAnalytics) snap() []analytics.Obs {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return append([]analytics.Obs(nil), m.obs...)
+}
+
+func TestClientIP(t *testing.T) {
+ r := httptest.NewRequest("GET", "http://h/", nil)
+ r.RemoteAddr = "203.0.113.5:4444"
+ r.Header.Set("X-Forwarded-For", "198.51.100.1")
+ if ip := clientIP(r); ip != "203.0.113.5" {
+ t.Fatalf("unverified: %q", ip)
+ }
+ r2 := r.WithContext(withHopVerified(r.Context())) // helper: mark ctx as a verified hop (use hopVerifiedKey)
+ r2.Header.Set("X-Forwarded-For", "10.0.0.1, 198.51.100.7")
+ if ip := clientIP(r2); ip != "198.51.100.7" {
+ t.Fatalf("verified hop: %q", ip)
+ }
+}
+
+func TestEdgeObservesOnlyWhenSwitchedOn(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte("<h1>hi</h1>"))
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ rt := registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web"}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := &memAnalytics{}
+ e.EnableAnalytics(m)
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ get := func(path string, hdr map[string]string) {
+ req, _ := http.NewRequest("GET", srv.URL+path, nil)
+ req.Host = "app.example.com"
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ }
+ get("/", nil)
+ if len(m.snap()) != 0 {
+ t.Fatal("nothing is observed while the app is off")
+ }
+ e.SetAnalyticsOn(map[string]bool{release.OpsAccount + "/web": true})
+ get("/pricing?x=1", map[string]string{"Referer": "https://news.ycombinator.com/item?id=1", "User-Agent": "Mozilla/5.0 Safari", "Sec-Fetch-Dest": "document"})
+ obs := m.snap()
+ if len(obs) != 1 {
+ t.Fatalf("observed %d", len(obs))
+ }
+ o := obs[0]
+ if o.Acct != release.OpsAccount || o.App != "web" || o.Host != "app.example.com" || o.Method != "GET" || o.Status != 200 ||
+ o.ContentType != "text/html; charset=utf-8" || o.Path != "/pricing" || o.RefererHost != "news.ycombinator.com" ||
+ o.FetchDest != "document" || o.Bytes == 0 || o.ClientIP == "" || o.When.IsZero() {
+ t.Fatalf("obs = %+v", o)
+ }
+ get("/x", map[string]string{"Purpose": "prefetch"})
+ if !m.snap()[1].Prefetch {
+ t.Fatal("prefetch header must be carried")
+ }
+ e.SetAnalyticsOn(nil)
+ get("/", nil)
+ if len(m.snap()) != 2 {
+ t.Fatal("switching off stops observation")
+ }
+}
+```
+
+If there is no `withHopVerified` helper, add a tiny unexported one in `analytics.go` (`func withHopVerified(ctx context.Context) context.Context { return context.WithValue(ctx, hopVerifiedKey{}, true) }`) matching how `acceptHop` sets the marker (read homemap.go:767-798 for the exact key and value).
+
+- [ ] **Step 2: Run, expect compile failure**
+
+- [ ] **Step 3: Implement `internal/edge/analytics.go`**
+
+```go
+package edge
+
+import (
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/analytics"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// AnalyticsMeter is where the edge files one observation per response
+// for an app whose analytics are switched on (spec §3.3). analytics.Meter
+// satisfies it; the interface keeps the dependency on the contract.
+type AnalyticsMeter interface {
+ Observe(analytics.Obs)
+}
+
+type analyticsMeterRef struct{ AnalyticsMeter }
+
+// EnableAnalytics wires the meter in; nil is a valid no-op.
+func (e *Edge) EnableAnalytics(m AnalyticsMeter) {
+ if m == nil {
+ e.analyticsMeter.Store(nil)
+ return
+ }
+ e.analyticsMeter.Store(&analyticsMeterRef{m})
+}
+
+// SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The
+// host's telemetry pass pushes it on every serial-triggered full pass and
+// every five minutes (spec §3.3, "converged by boxes on the next pass").
+func (e *Edge) SetAnalyticsOn(on map[string]bool) {
+ if on == nil {
+ e.analyticsOn.Store(nil)
+ return
+ }
+ e.analyticsOn.Store(&on)
+}
+
+func (e *Edge) analyticsEnabled(rt registry.Route) bool {
+ m := e.analyticsOn.Load()
+ if m == nil {
+ return false
+ }
+ return (*m)[release.RouteAccount(rt)+"/"+rt.App]
+}
+
+// clientIP is the address the visitor hash uses (spec §3.3): the last
+// element of the verified inbound X-Forwarded-For chain on a proven hop,
+// otherwise RemoteAddr's host. A header the client set never reaches it.
+func clientIP(r *http.Request) string {
+ if hopVerified(r.Context()) {
+ chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ")
+ if i := strings.LastIndexByte(chain, ','); i >= 0 {
+ chain = chain[i+1:]
+ }
+ if ip := strings.TrimSpace(chain); ip != "" {
+ return ip
+ }
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+// observeAnalytics files one Obs when the route's app is switched on.
+// Runs in the observer defer after the response completed; reads only.
+func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64) {
+ ref := e.analyticsMeter.Load()
+ if ref == nil || !e.analyticsEnabled(rt) {
+ return
+ }
+ refHost := ""
+ if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
+ refHost = u.Hostname()
+ }
+ prefetch := strings.EqualFold(r.Header.Get("Sec-Purpose"), "prefetch") || strings.EqualFold(r.Header.Get("Purpose"), "prefetch")
+ ref.Observe(analytics.Obs{
+ Acct: release.RouteAccount(rt), App: rt.App, Host: rt.Host,
+ Method: r.Method, Status: ob.status, ContentType: ob.ctype,
+ Path: r.URL.Path, RefererHost: refHost, UA: r.Header.Get("User-Agent"),
+ ClientIP: clientIP(r), Bytes: bytes,
+ Prefetch: prefetch, FetchDest: strings.ToLower(r.Header.Get("Sec-Fetch-Dest")),
+ When: time.Now(),
+ })
+}
+```
+
+Add to `Edge`:
+```go
+ analyticsMeter atomic.Pointer[analyticsMeterRef]
+ analyticsOn atomic.Pointer[map[string]bool]
+```
+and in the observer defer (`edge.go` ~1300), after the edge-error line:
+```go
+ e.observeAnalytics(up.route, r, ob, bytes)
+```
+`ob.status == 0` (nothing written) is never a pageview by `IsPageview` (status must be 200), and bytes are 0 — the meter still records an app-day with zero counts; acceptable.
+
+- [ ] **Step 4: Run `go test ./internal/edge/ -count=1` (TMPDIR=/tmp/g), PASS including every pre-existing test. Commit** "edge: observe responses for switched-on apps (spec §3.3)".
+
+---
+
+### Task 6: Host — switch pass, meter, flush on the usage tick
+
+**Files:**
+- Create: `internal/host/telemetry_pass.go`
+- Modify: `internal/host/usage_loop.go`, `internal/host/agent.go`
+- Test: `internal/host/telemetry_pass_test.go`, `internal/host/usage_loop_test.go` (append)
+
+**Interfaces:**
+- Produces:
+ ```go
+ func telemetrySwitches(ctx, s release.Store, reg *registry.Registry) (map[string]bool, error) // sqid/app → count==edge for the routes this box serves
+ func setAnalyticsSink(fn func(map[string]bool)) // agent.go callback registers e.SetAnalyticsOn
+ func telemetryPass(ctx, s, reg) // resolves and pushes; logs errors
+ func analyticsFlush(ctx, s release.Store, sqid... ) — folded into usageFlush: usageFlush(ctx, s, box, drain, am *analytics.Meter, now) error
+ ```
+
+- [ ] **Step 1: Failing tests**
+
+`telemetry_pass_test.go`:
+```go
+package host
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+func TestTelemetrySwitchesResolvePerRoute(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := openTestRegistry(t) // use whatever helper host tests use to open a temp registry
+ for _, rt := range []registry.Route{
+ {Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"},
+ {Host: "b.example", Kind: "service", Account: "bab", App: "api", Status: "ready"},
+ {Host: "c.example", Kind: "static", Account: "zzz", App: "site", Status: "ready"},
+ {Host: "status.example", Kind: "status", Status: "ready"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ telemetry.Update(ctx, s, telemetry.AccountKey("bab"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge} })
+ telemetry.Update(ctx, s, telemetry.AppKey("bab", "api"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountOff} })
+ on, err := telemetrySwitches(ctx, s, reg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !on["bab/web"] || on["bab/api"] || on["zzz/site"] {
+ t.Fatalf("switches = %v", on)
+ }
+ if _, ok := on["/"]; ok {
+ t.Fatal("status routes have no app and must not appear")
+ }
+}
+```
+
+Find the helper host tests use to open a temporary registry (grep `registry.Open(filepath.Join(t.TempDir()` in `internal/host/*_test.go`); use it or open one inline. The route's account column name: check `registry.Route` for the field `release.RouteAccount` reads (`Account`), and set it so `RouteAccount` returns the sqid.
+
+`usage_loop_test.go` append:
+```go
+func TestUsageFlushAlsoFlushesAnalytics(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "h", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now})
+ if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil {
+ t.Fatal(err)
+ }
+ sum, err := analytics.ReadRange(ctx, s, "bab", "web", now, now)
+ if err != nil || sum.PV != 1 {
+ t.Fatalf("flushed day: %+v %v", sum, err)
+ }
+ // A failed flush re-queues; the next flush lands it.
+ am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "h", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now})
+ _ = usageFlush(ctx, failingStore{}, "box1", func() map[string]usage.EdgeRow { return nil }, am, now)
+ if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil {
+ t.Fatal(err)
+ }
+ if sum, _ := analytics.ReadRange(ctx, s, "bab", "web", now, now); sum.PV != 2 {
+ t.Fatalf("after requeue: %d", sum.PV)
+ }
+}
+```
+(`failingStore` exists in `logship_test.go`; if its `Get` returns an error other than not-found, `UpsertDay` returns that error, which is what re-queues. Check `failingStore`'s shape and adapt.)
+
+- [ ] **Step 2: Run, expect compile failure**
+
+- [ ] **Step 3: Implement `telemetry_pass.go`**
+
+```go
+// The analytics switch pass (spec 2026-09-07 §3.3): resolve
+// telemetry.json app → account → default for every (account, app) this
+// box serves and hand the edge a fresh switch map. Rides the serial-
+// triggered full pass and the five-minute instances loop, so a console
+// write lands within seconds and the hourly floor stands underneath.
+package host
+
+import (
+ "context"
+ "log"
+ "sync/atomic"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+var analyticsSink atomic.Pointer[func(map[string]bool)]
+
+// setAnalyticsSink registers where a resolved switch map goes (the edge's
+// SetAnalyticsOn), the way setUpstreamSource publishes the prober table.
+func setAnalyticsSink(fn func(map[string]bool)) { analyticsSink.Store(&fn) }
+
+// telemetrySwitches resolves the count switch for each distinct
+// (account, app) among the box's routes. Status routes carry no app and
+// are skipped. One Resolve (two Gets) per pair; a pair that fails to
+// resolve is left OFF and logged — off is the safe side of this switch.
+func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]bool, error) {
+ routes, err := reg.List()
+ if err != nil {
+ return nil, err
+ }
+ on := map[string]bool{}
+ seen := map[string]bool{}
+ for _, rt := range routes {
+ if rt.Kind == "status" || rt.App == "" {
+ continue
+ }
+ sqid := release.RouteAccount(rt)
+ key := sqid + "/" + rt.App
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ res, err := telemetry.Resolve(ctx, s, sqid, rt.App)
+ if err != nil {
+ log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err)
+ continue
+ }
+ if res.Count == telemetry.CountEdge {
+ on[key] = true
+ }
+ }
+ return on, nil
+}
+
+// telemetryPass resolves and pushes; errors are one log line each.
+func telemetryPass(ctx context.Context, s release.Store, reg *registry.Registry) {
+ sink := analyticsSink.Load()
+ if sink == nil {
+ return
+ }
+ on, err := telemetrySwitches(ctx, s, reg)
+ if err != nil {
+ log.Printf("agent: telemetry pass: %v", err)
+ return
+ }
+ (*sink)(on)
+}
+```
+
+Confirm `reg.List()`'s signature (`edge.go:435` calls it) and the `Route` field the account lives in.
+
+- [ ] **Step 4: Wire the pass and the flush in `agent.go` and `usage_loop.go`**
+
+`usage_loop.go`:
+```go
+// usageFlush drains the edge meter and CAS-merges the deltas into the
+// box's usage/edge day object, then flushes the analytics meter's deltas
+// into their app-day objects (spec §3.3: the same tick, a second CAS
+// write). A failed analytics batch is re-queued for the next tick.
+func usageFlush(ctx context.Context, s release.Store, box string, drain func() map[string]usage.EdgeRow, am *analytics.Meter, now time.Time) error {
+ err := usage.UpsertEdgeDay(ctx, s, box, now, drain())
+ if am == nil {
+ return err
+ }
+ failed := map[analytics.AppKey][]analytics.Delta{}
+ for k, ds := range am.Drain() {
+ for _, d := range ds {
+ if uerr := analytics.UpsertDay(ctx, s, k.Acct, k.App, d); uerr != nil {
+ log.Printf("agent: analytics flush %s/%s %s: %v (re-queued)", k.Acct, k.App, d.Date, uerr)
+ failed[k] = append(failed[k], d)
+ }
+ }
+ }
+ if len(failed) > 0 {
+ am.Requeue(failed)
+ }
+ return err
+}
+```
+`usageLoop(drain, am)` passes `am` through. In `agent.go`: construct `am := analytics.NewMeter(dataDir(), status.Label(readMachineID()), time.Now)` beside the usage meter inside the `RunWithActivatorReady` callback; `e.EnableAnalytics(am)`; `setAnalyticsSink(e.SetAnalyticsOn)`; `go usageLoop(m.Drain, am)`. Note: `usageLoop` opens its own store; the analytics flush uses the same `s` and the keys are NOT rebased (they are the app's own data under `apps/…`, like logs — read `logship.go:54-61`'s reasoning; on a fleet box the door admits `analytics/` since step 1's ACL). If `usageLoop` wraps its store with `NewPrefixStore(raw, resolveStatusPrefix(...))`, the analytics writes must go to the UNPREFIXED `raw` store — pass both, or open the raw store for analytics the way `logshipLoop` does. Do that: `usageLoop` keeps `s` (prefixed) for usage and `raw` for analytics.
+
+Pass wiring: in `d.fullPass` (agent.go ~1505) add `telemetryPass(ctx, store, reg)` after `reconcileSerialized`; in `instancesLoop`'s tick add the same call after its reconcile. Both have `store` and `reg` in scope (verify; `instancesLoop(reg)` may open its own store — read its body and use that variable).
+
+- [ ] **Step 5: `go build ./... && go test ./internal/host/ -count=1` PASS. Commit** "host: resolve analytics switches per pass; flush the analytics meter on the usage tick".
+
+---
+
+### Task 7: Console — settings switches, PUT accepts analytics, serial bump
+
+**Files:**
+- Modify: `internal/console/apierrors.go` (`handleAPITelemetryPut`), `internal/console/errorsview.go` or new `internal/console/analyticsview.go` (two settings handlers), `internal/console/server.go` (routes, page fields `AnalyticsSetting telemetry.Resolved`, `AnalyticsUnread bool`, populate in both settings handlers beside `ErrLevelSetting`), `templates/app_settings.html`, `templates/account_settings.html`
+- Test: `internal/console/apianalytics_test.go` (settings part)
+
+**Interfaces:**
+- Produces: `POST /a/{sqid}/apps/{app}/analytics/setting` and `POST /a/{sqid}/analytics/setting` with form fields `count` (`edge|off`) and `door` (`on|off`), owner + CSRF, audited, serial bump; `PUT …/telemetry` accepts `{"analytics":{"count":…,"door":…}}` (validated: count ∈ {edge,off}, door ∈ {on,off}; either key optional) alongside `errors`, bumps the serial when analytics was present.
+
+- [ ] **Step 1: Failing test**
+
+```go
+func TestAPITelemetryPutSetsAnalytics(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-analytics-set")
+ op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
+ put := func(body string) (int, string) {
+ st, _, b := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, body)
+ return st, b
+ }
+ if st, b := put(`{"analytics":{"count":"edge"}}`); st != 200 {
+ t.Fatalf("count=edge: %d %s", st, b)
+ }
+ r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
+ if r.Count != "edge" || r.CountFrom != "app" || r.Door != "on" {
+ t.Fatalf("resolved = %+v", r)
+ }
+ if st, _ := put(`{"analytics":{"count":"browser"}}`); st != 400 {
+ t.Fatal("reserved mode must be refused for now")
+ }
+ if st, _ := put(`{"analytics":{"door":"sideways"}}`); st != 400 {
+ t.Fatal("bad door")
+ }
+ if st, _ := put(`{"analytics":{"door":"off"},"errors":{"log_level":"warn"}}`); st != 200 {
+ t.Fatal("both halves in one PUT")
+ }
+ r, _ = telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
+ if r.Door != "off" || r.LogLevel != "warn" || r.Count != "edge" {
+ t.Fatalf("after both: %+v", r)
+ }
+ // The serial moved: boxes converge on it.
+ if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 {
+ t.Fatalf("serial not bumped: %+v %v", doc, err)
+ }
+}
+
+func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-analytics-page")
+ fx.setUser("paul@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-analytics-signin")
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") {
+ t.Fatalf("app settings: %d", status)
+ }
+ // Post the account-level switch on; the app page shows it inherited.
+ status, _, _ = postForm(t, c, fx.u("/analytics/setting"), url.Values{"csrf": {csrfFor(t, c, fx)}, "count": {"edge"}, "door": {"on"}})
+ if status != 303 {
+ t.Fatalf("account switch: %d", status)
+ }
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "currently edge, from the account") {
+ t.Fatal("inheritance not shown")
+ }
+}
+```
+Use the form-post and CSRF helpers the existing settings tests use (grep `csrf` in `appsettings_test.go` for how a test obtains the token and posts a form; adapt the two helper names). Check `release.ReadSerial`'s return shape (`serial.go:32`).
+
+- [ ] **Step 2: Run, expect failure**
+
+- [ ] **Step 3: Implement**
+
+In `handleAPITelemetryPut`: replace the `body.Analytics != nil → 400` block with validation:
+```go
+ if a := body.Analytics; a != nil {
+ if a.Count != "" && a.Count != telemetry.CountEdge && a.Count != telemetry.CountOff {
+ jsonError(w, http.StatusBadRequest, "analytics.count: want edge or off")
+ return
+ }
+ if a.Door != "" && a.Door != telemetry.DoorOn && a.Door != telemetry.DoorOff {
+ jsonError(w, http.StatusBadRequest, "analytics.door: want on or off")
+ return
+ }
+ }
+ if body.Errors == nil && body.Analytics == nil { 400 "nothing to set" }
+ if body.Errors != nil && !telemetry.ValidLogLevel(...) { 400 as today }
+```
+and in the `Update` closure set `st.Errors` when given and merge `st.Analytics` field-by-field (only the keys present, keeping the other). After a successful write with `body.Analytics != nil`, call `s.bumpSerial(ctx)` (box-consumed; the errors level is console-read and needs none). Audit text names what changed.
+
+New handlers (in `analyticsview.go`, mirroring `handleAppErrorLevel`/`handleAccountErrorLevel` exactly, including the nil-Releases guard): read `count` and `door` form values, validate as above (empty = leave unchanged), `telemetry.Update` on `AppKey`/`AccountKey` merging into `st.Analytics`, `s.audit(...)`, `s.bumpSerial(r.Context())`, flash "Analytics counting is now edge for this app" / "…off…", redirect back to the settings page. Routes beside the errors ones.
+
+Page fields and population: beside `ErrLevelSetting`/`ErrLevelUnread` add `AnalyticsSetting telemetry.Resolved` and `AnalyticsUnread bool`; since `Resolve` already returns both halves, reuse the one call: set `p.AnalyticsSetting = p.ErrLevelSetting` and `p.AnalyticsUnread = p.ErrLevelUnread` in both settings handlers (comment that they are one object).
+
+Templates: after the Errors section in both files:
+```html
+<h2>Analytics</h2>
+{{if .AnalyticsUnread}}
+<p class="small">This app&rsquo;s analytics setting could not be read just now. Reload before concluding anything.</p>
+{{else if .IsOwner}}
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/analytics/setting" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label>Count page views at the edge:
+ <select name="count">
+ <option value="off"{{if eq .AnalyticsSetting.Count "off"}} selected{{end}}>off</option>
+ <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option>
+ </select>
+ </label>
+ <button type="submit" class="btn-sm">Save</button>
+ <span class="small">currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}</span>
+</form>
+{{else}}
+<p class="small">Analytics counting is <code>{{.AnalyticsSetting.Count}}</code> for this app, from the {{.AnalyticsSetting.CountFrom}}. Only the account owner can change it.</p>
+{{end}}
+<p class="small">When on, the edge counts HTML page views and daily visitors as it serves them: no JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes every day and cannot be joined across days. Whether that needs consent where your visitors are is your call, which is why it is off until you turn it on.</p>
+```
+The account form: same fields, action `{{.Base}}/analytics/setting`, copy "for every app without its own setting". The `door` select is NOT rendered in step 2 (no door exists yet); the handlers accept it for step 3.
+
+- [ ] **Step 4: `go test ./internal/console/ -count=1` PASS. Commit** "console: the analytics switch at both levels; telemetry PUT accepts analytics and bumps the serial".
+
+---
+
+### Task 8: Console — Analytics tab and API
+
+**Files:**
+- Create: `internal/console/analyticsview.go` (add to the file from Task 7), `internal/console/apianalytics.go`, `templates/app_analytics.html`
+- Modify: `server.go` (route `GET /a/{sqid}/apps/{app}/analytics`, page fields), `authcli.go` (cliRoute), `appnav.html`
+- Test: `internal/console/apianalytics_test.go` (tab + API parts)
+
+**Interfaces:**
+- Produces:
+ ```go
+ type analyticsWindow struct { Sum analytics.Summary; Range string; On bool; Setting telemetry.Resolved; ChartBars []chartBar; ChartMax int64 }
+ type chartBar struct { Label string; Value int64; X, Y, W, H float64 }
+ func (s *Server) readAnalytics(ctx, sqid, app, rng string, now time.Time) (analyticsWindow, error) // rng: today|7d|30d
+ func chartBars(vals []int64, labels []string, w, h float64) ([]chartBar, int64)
+ type analyticsResponse struct { Range string; From, To string; On bool; Count, CountFrom string; PV, Visitors, Bytes, Bots int64; Days []analytics.DaySummary; Pages, Referers, Events map[string]int64; PagesApprox, ReferersApprox, EventsApprox bool; Dropped analytics.Dropped } // json tags snake_case
+ ```
+ Routes: `GET /api/cli/apps/{account}/{app}/analytics?range=today|7d|30d` (Instance, Operate); page `GET /a/{sqid}/apps/{app}/analytics?range=…`.
+
+- [ ] **Step 1: Failing tests**
+
+```go
+func seedAnalytics(t *testing.T, fx *consoleFixture, now time.Time) {
+ t.Helper()
+ m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ for i := 0; i < 5; i++ {
+ m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
+ ContentType: "text/html", Path: "/pricing", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari",
+ ClientIP: "203.0.113." + strconv.Itoa(i), Bytes: 1000, When: now.Add(-time.Hour)})
+ }
+ m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
+ ContentType: "text/html", Path: "/", UA: "Googlebot/2.1", ClientIP: "1.1.1.1", Bytes: 500, When: now.Add(-time.Hour)})
+ m.Track(release.OpsAccount, "titogo", "signup", now.Add(-time.Hour))
+ for k, ds := range m.Drain() {
+ for _, d := range ds {
+ if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+}
+
+func TestAPIAnalyticsSummary(t *testing.T) {
+ fx, token := instanceFixture(t, "code-analytics-api")
+ now := time.Now().UTC()
+ seedAnalytics(t, fx, now)
+ status, _, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=today", token, "")
+ if status != 200 {
+ t.Fatalf("%d %s", status, body)
+ }
+ var resp analyticsResponse
+ json.Unmarshal([]byte(body), &resp)
+ if resp.PV != 5 || resp.Bots != 1 || resp.Bytes != 5500 || resp.Visitors < 4 || resp.Visitors > 6 ||
+ resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On {
+ t.Fatalf("resp = %+v", resp)
+ }
+ if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 {
+ t.Fatal("bad range")
+ }
+ if st, _, b := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=7d", token, ""); st != 200 {
+ t.Fatalf("7d: %d %s", st, b)
+ }
+}
+
+func TestAppAnalyticsPage(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-analytics-tab")
+ now := time.Now().UTC()
+ seedAnalytics(t, fx, now)
+ fx.setUser("paul@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-analytics-tab-signin")
+ status, _, body := get(t, c, fx.u("/apps/titogo/analytics"))
+ if status != 200 {
+ t.Fatalf("%d %s", status, body)
+ }
+ // Off: the switch and the reading, and the numbers that exist already.
+ for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "<svg"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("page missing %q", want)
+ }
+ }
+ telemetry.Update(context.Background(), fx.rel, telemetry.AppKey(fx.sqid, "titogo"), func(st *telemetry.Settings) {
+ st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge}
+ })
+ _, _, body = get(t, c, fx.u("/apps/titogo/analytics?range=7d"))
+ if strings.Contains(body, "Analytics is off") || !strings.Contains(body, "daily visitors, summed") || !strings.Contains(body, "carlos analytics --app titogo") {
+ t.Fatal("on + 7d page")
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+- [ ] **Step 3: Implement**
+
+`readAnalytics`: parse `rng` (`today` → from = today 00:00 UTC, to = now; `7d` → 6 days ago 00:00 to now; `30d` → 29 days ago), `telemetry.Resolve` for `On`/`Setting`, `analytics.ReadRange`, then chart: for `today` the 24 hourly PVs of `Days[0]` with labels `"00"…"23"`; otherwise one bar per day labelled `MM-DD`. `chartBars(vals, labels, 480, 120)` computes rect geometry: `W = 480/len - 2`, `H = v/max*120`, `Y = 120-H`, `X = i*480/len`; `ChartMax` for the axis note.
+
+`handleAppAnalytics` via `appPage(w, r, "analytics")`, range default `today`, invalid → `today`; `AnalyticsWin analyticsWindow`, `AnalyticsRange`, `AnalyticsErr` page fields. `handleAPIAnalytics` via `instanceGate`, `range` param validated (`400 range: want today, 7d or 30d`), `readAnalytics`, then `analyticsResponse` (nil maps → `{}`), `writeJSON`.
+
+Template `app_analytics.html`:
+```html
+{{define "app_analytics"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+<h2>Analytics</h2>
+{{if .AnalyticsErr}}<p class="banner error">{{.AnalyticsErr}}</p>{{end}}
+{{if not .AnalyticsWin.On}}
+<p><strong>Analytics is off for this app</strong> (from the {{.AnalyticsWin.Setting.CountFrom}}).
+When on, the edge counts HTML page views and daily visitors as it serves them: no JavaScript, no cookie,
+no stored IP. A visitor is a salted hash that changes every day. Turn it on for this app or for the whole
+account on the <a href="{{.Base}}/apps/{{.AppName}}/settings">Settings</a> tab.</p>
+{{end}}
+<form method="get" class="small">
+ <label>range
+ <select name="range">
+ <option value="today"{{if eq .AnalyticsRange "today"}} selected{{end}}>today</option>
+ <option value="7d"{{if eq .AnalyticsRange "7d"}} selected{{end}}>7 days</option>
+ <option value="30d"{{if eq .AnalyticsRange "30d"}} selected{{end}}>30 days</option>
+ </select>
+ </label>
+ <button type="submit">Update</button>
+</form>
+{{with .AnalyticsWin}}
+<div class="tablewrap"><table>
+ <tr><th>Page views</th><th>Visitors{{if ne $.AnalyticsRange "today"}} (daily visitors, summed){{end}}</th><th>Bytes served</th><th>Bots excluded</th><th>Dropped</th></tr>
+ <tr><td>{{.Sum.PV}}</td><td>{{.Sum.Visitors}}</td><td>{{.Sum.Bytes}}</td><td>{{.Sum.Bots}}</td><td>{{.Sum.Dropped.Flush}}</td></tr>
+</table></div>
+{{if .ChartBars}}
+<svg viewBox="0 0 480 140" width="100%" role="img" aria-label="Page views">
+ {{range .ChartBars}}<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}" fill="currentColor"><title>{{.Label}}: {{.Value}}</title></rect>{{end}}
+ <text x="0" y="136" class="small">{{if eq $.AnalyticsRange "today"}}per hour, UTC{{else}}per day{{end}}; peak {{.ChartMax}}</text>
+</svg>
+{{end}}
+{{range .Sum.Days}}{{if .Restarted}}<p class="small">{{.Date}}: counts since the last flush may be missing (the edge restarted that day).</p>{{end}}{{end}}
+<h3>Top pages{{if .Sum.PagesApprox}} <span class="small">approximate: more than 500 distinct keys</span>{{end}}</h3>
+{{template "kvtable" .Sum.Pages}}
+<h3>Top referers{{if .Sum.ReferersApprox}} <span class="small">approximate</span>{{end}}</h3>
+{{template "kvtable" .Sum.Referers}}
+<h3>Named events{{if .Sum.EventsApprox}} <span class="small">approximate</span>{{end}}</h3>
+{{template "kvtable" .Sum.Events}}
+{{end}}
+<p class="small">Times are UTC. The same view from a terminal: <code>carlos analytics --app {{.AppName}} --range {{.AnalyticsRange}}</code>.</p>
+{{template "bottom" .}}{{end}}
+{{define "kvtable"}}{{if .}}<div class="tablewrap"><table>{{range $k, $v := sortedKV .}}<tr><td>{{$k.Key}}</td><td>{{$k.Value}}</td></tr>{{end}}</table></div>{{else}}<p class="small">Nothing yet.</p>{{end}}{{end}}
+```
+Add a template func `sortedKV` (map → slice of `{Key string; Value int64}` sorted by value desc, `_other` last) in `server.go`'s funcMap beside `logLines`; fix the `range` variable usage accordingly (`{{range sortedKV .}}<tr><td>{{.Key}}</td><td>{{.Value}}</td></tr>{{end}}`).
+
+`appnav.html`: add `Analytics` after `Errors`. `authcli.go`: the cliRoute beside `/errors`. `svcroutes_test.go`: add the analytics door to `exemptFromLegacyPin` with the same reasoning as errors.
+
+- [ ] **Step 4: `go test ./internal/console/ -count=1` PASS. Commit** "console: Analytics tab and API (spec §3.6)".
+
+---
+
+### Task 9: `carlos analytics` and the docs heading
+
+**Files:**
+- Create: `analytics_cmd.go`, `analytics_cmd_test.go`
+- Modify: `apimode.go` (`apiAnalyticsResponse`, `analyticsGet`), `main.go` (dispatch + help line), `docs/site/cli.md` (`### carlos analytics` after `### carlos errors`)
+
+- [ ] **Step 1: Failing test** `TestPrintAnalytics` — feed an `apiAnalyticsResponse` with PV 5, Visitors 4, two pages, one referer, one event, `Range: "7d"`, and assert the output contains "5", "4", "daily visitors, summed", "/pricing", "news.ycombinator.com", "signup".
+
+- [ ] **Step 2: Implement** `runAnalytics(args)`: flags `--app`, `--range` (default `today`), `--set-count edge|off`, `--set-door on|off`, plus console/account flags as `errors_cmd.go`. `--set-*` short-circuit into `sess.telemetryPut(ctx, acct, app2, map[string]any{"analytics": map[string]string{...}})` and print "Analytics counting for X is now edge/off." Otherwise `sess.analyticsGet(ctx, acct, app2, url.Values{"range": …})` and `printAnalytics(stdout, res)`: a headline line (`page views N · visitors N (daily visitors, summed) · bytes N · bots N · dropped N`), a text sparkline of `Days` (or hours for today) using the block characters `▁▂▃▄▅▆▇█`, then the three tables sorted by value desc with `_other` last, and, when `!res.On`, a muted line "analytics is off for this app — turn it on with `carlos analytics --app X --set-count edge`". `apiAnalyticsResponse` mirrors `analyticsResponse`'s JSON tags.
+
+`docs/site/cli.md`:
+```markdown
+### carlos analytics
+
+Page views, daily visitors, bytes served, top pages, top referers and named
+events, counted at the edge as it serves your app: no JavaScript, no cookie,
+no stored IP. Off until you turn it on, for one app or for the whole account.
+
+```sh
+carlos analytics --app hello --set-count edge
+carlos analytics --app hello --range 7d
+```
+
+`--range` is `today`, `7d` or `30d`; multi-day visitor totals are daily
+visitors summed. The same view is the Analytics tab on the app's dashboard,
+and the switch is on the Settings tab too.
+```
+
+- [ ] **Step 3: `go build ./... && go test . -run TestPrintAnalytics -count=1 && go test ./internal/docsite/ -count=1`; prove the docsite gate fails with the heading removed, restore. Commit** "carlos analytics: the app's page views and visitors from a terminal, and the switch".
+
+---
+
+### Task 10: Whole-tree gate, push, branch description
+
+- [ ] `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` (TMPDIR=/tmp/g), then `GOOS=darwin GOARCH=arm64 go build ./...` and `GOOS=windows GOARCH=amd64 go build ./...`.
+- [ ] Update the spec's Status line: "steps 1 and 2 implemented (step 2 on branch analytics-step2)". Commit.
+- [ ] `git push origin analytics-step2`; `amadan branch describe carlos/platform analytics-step2 -body -` with a description that names the follow-up in `carlosframework/website` (the `getting-started` skill question: "Do you want analytics on for this account? It counts HTML page views and daily visitors at the edge, no JavaScript, no cookie, no stored IP; off by default"); `amadan ci status carlos/platform -branch analytics-step2` until passed.
+- [ ] Do not merge.
+
+## Self-review against the spec
+
+- §3.3 observing writer: already in place (step 1); the tap reads `ob.status`/`ob.ctype` — Task 5. Pageview rule, bots, `/__carlos/` exclusion — Task 3 `IsPageview`. Client IP — Task 5. Visitor hash + salt — Tasks 2–3. HLL 2¹¹ with version byte, Misra–Gries 500, 256-byte keys, re-fold and approximate label — Tasks 1, 4, 8. Page paths sanitised — Task 3. Storage key, event-time buckets, requeue-once with `dropped.flush`, per-box `seq` idempotency, `restarted` note, shape with `countries` and `dropped` — Tasks 2–3. Named events via `Track` (no caller yet) — Task 3. Accumulation on the usage tick — Task 6. Settings object, resolution, off by default, serial bump, box convergence on the pass — Tasks 6–7.
+- §3.6 tab (ranges, headline, chart in Go, three tables, off-state copy, restart note), API path and roles, CLI, settings at both levels — Tasks 7–9.
+- §4: no new activator call; Task 5's tap is in the existing defer.
+- Type consistency: `analytics.Obs` fields used in Tasks 3, 5, 6, 8 match; `Delta`/`Day` in 2, 3, 4, 6; `Summary` in 4, 8, 9; `telemetry.Resolved.Count/CountFrom/Door` in 6–9.
+- Deferred: `door` UI (step 3), `Track` callers (steps 3–4), `dropped.e/a` producers (steps 3–4), GeoIP (`countries` stays empty), the website-repo skill question.
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 b185007c9..30d307ecf 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
@@ -2,9 +2,10 @@
**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 implemented on branch
-`spec-errors-analytics`; its plan is
-`docs/superpowers/plans/2026-09-07-errors-step1-log-and-edge.md`. Every
+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,
diff --git a/internal/analytics/bots.go b/internal/analytics/bots.go
new file mode 100644
index 000000000..195bfca0c
--- /dev/null
+++ b/internal/analytics/bots.go
@@ -0,0 +1,32 @@
+package analytics
+
+import "strings"
+
+// botMarkers is the vendored user-agent list (spec §3.3): incomplete by
+// nature, so the tab shows the bots count rather than pretending. Matched
+// case-insensitively as substrings. An empty UA is a bot: no browser sends
+// none.
+var botMarkers = []string{
+ "bot", "crawler", "spider", "slurp", "curl/", "wget/", "python-requests",
+ "go-http-client", "headlesschrome", "phantomjs", "facebookexternalhit",
+ "ahrefs", "semrush", "mj12", "dotbot", "petalbot", "bytespider", "gptbot",
+ "claudebot", "ccbot", "applebot", "yandex", "baiduspider", "duckduckbot",
+ "linkedinbot", "twitterbot", "discordbot", "telegrambot", "whatsapp",
+ "pingdom", "uptimerobot", "statuscake", "site24x7", "newrelicpinger",
+ "lighthouse", "pagespeed", "gtmetrix", "httpclient", "okhttp", "java/",
+ "libwww", "scrapy", "node-fetch", "axios/", "postmanruntime", "insomnia",
+}
+
+// IsBot reports whether ua looks like an automated client.
+func IsBot(ua string) bool {
+ if ua == "" {
+ return true
+ }
+ l := strings.ToLower(ua)
+ for _, m := range botMarkers {
+ if strings.Contains(l, m) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/analytics/bots_test.go b/internal/analytics/bots_test.go
new file mode 100644
index 000000000..b01378a63
--- /dev/null
+++ b/internal/analytics/bots_test.go
@@ -0,0 +1,27 @@
+package analytics
+
+import "testing"
+
+func TestIsBot(t *testing.T) {
+ yes := []string{
+ "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
+ "Mozilla/5.0 (compatible; bingbot/2.0)", "curl/8.4.0", "Wget/1.21",
+ "python-requests/2.31", "Go-http-client/1.1", "Mozilla/5.0 HeadlessChrome/120",
+ "facebookexternalhit/1.1", "Slackbot-LinkExpanding", "AhrefsBot/7.0", "",
+ }
+ no := []string{
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
+ }
+ for _, ua := range yes {
+ if !IsBot(ua) {
+ t.Errorf("IsBot(%q) = false", ua)
+ }
+ }
+ for _, ua := range no {
+ if IsBot(ua) {
+ t.Errorf("IsBot(%q) = true", ua)
+ }
+ }
+}
diff --git a/internal/analytics/day.go b/internal/analytics/day.go
new file mode 100644
index 000000000..d016afc8f
--- /dev/null
+++ b/internal/analytics/day.go
@@ -0,0 +1,174 @@
+package analytics
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// HourRow is one UTC hour's counters.
+type HourRow struct {
+ PV int64 `json:"pv"`
+ Bots int64 `json:"bots"`
+ Bytes int64 `json:"bytes"`
+}
+
+// DropCounts is why events were dropped at a door (steps 3–4 fill them).
+type DropCounts struct {
+ Rate int64 `json:"rate"`
+ Size int64 `json:"size"`
+ Origin int64 `json:"origin"`
+ Malformed int64 `json:"malformed"`
+}
+
+// Dropped is the honesty block (spec §3.3).
+type Dropped struct {
+ E DropCounts `json:"e"`
+ A DropCounts `json:"a"`
+ Flush int64 `json:"flush"`
+}
+
+// Day is apps/<sqid>/<app>/analytics/<box>/days/<date>.json (spec §3.3):
+// one box's counting for one app for one UTC day. Additive shape.
+type Day struct {
+ V int `json:"v"`
+ Date string `json:"date"`
+ Hours map[string]HourRow `json:"hours"`
+ Visitors string `json:"visitors"`
+ Pages map[string]int64 `json:"pages"`
+ Referers map[string]int64 `json:"referers"`
+ Events map[string]int64 `json:"events"`
+ Seq map[string]int64 `json:"seq"`
+ Dropped Dropped `json:"dropped"`
+ Countries map[string]int64 `json:"countries"`
+ Restarted bool `json:"restarted,omitempty"`
+}
+
+// Delta is one flush batch from one box for one app-day.
+type Delta struct {
+ Date string
+ Box string
+ Seq int64
+ Hours map[string]HourRow
+ Visitors *HLL
+ Pages *TopK
+ Referers *TopK
+ Events *TopK
+ Dropped Dropped
+ Restarted bool
+
+ // requeued marks a delta that has already been through one failed
+ // flush (internal/analytics.Meter.Requeue). Not serialised: it never
+ // reaches the day file, only decides whether a second Requeue keeps
+ // or drops the delta.
+ requeued bool
+}
+
+// Prefix is the app's analytics/ subtree.
+func Prefix(sqid, app string) string { return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) }
+
+// BoxPrefix is one box's day files.
+func BoxPrefix(sqid, app, box string) string { return Prefix(sqid, app) + box + "/days/" }
+
+// DayKey is one day file.
+func DayKey(sqid, app, box, date string) string { return BoxPrefix(sqid, app, box) + date + ".json" }
+
+// Merge folds x into d unless x.Seq is not above the recorded seq for
+// x.Box — the idempotency rule that makes a retried conditional put safe.
+func (d *Day) Merge(x Delta) (bool, error) {
+ if x.Seq <= d.Seq[x.Box] {
+ return false, nil
+ }
+ if d.Seq == nil {
+ d.Seq = map[string]int64{}
+ }
+ d.V, d.Date = 1, x.Date
+ if d.Hours == nil {
+ d.Hours = map[string]HourRow{}
+ }
+ for h, row := range x.Hours {
+ cur := d.Hours[h]
+ cur.PV += row.PV
+ cur.Bots += row.Bots
+ cur.Bytes += row.Bytes
+ d.Hours[h] = cur
+ }
+ // A corrupt or unreadable sketch must not poison the whole day: start
+ // fresh rather than fail the merge. The bad string is overwritten by
+ // the freshly encoded sketch below either way.
+ vis, err := DecodeHLL(d.Visitors)
+ if err != nil {
+ vis = &HLL{}
+ }
+ if x.Visitors != nil {
+ vis.Merge(x.Visitors)
+ }
+ d.Visitors = vis.Encode()
+ d.Pages = mergeTop(d.Pages, x.Pages)
+ d.Referers = mergeTop(d.Referers, x.Referers)
+ d.Events = mergeTop(d.Events, x.Events)
+ d.Dropped.E = addDrops(d.Dropped.E, x.Dropped.E)
+ d.Dropped.A = addDrops(d.Dropped.A, x.Dropped.A)
+ d.Dropped.Flush += x.Dropped.Flush
+ d.Restarted = d.Restarted || x.Restarted
+ if d.Countries == nil {
+ d.Countries = map[string]int64{}
+ }
+ d.Seq[x.Box] = x.Seq
+ return true, nil
+}
+
+func mergeTop(m map[string]int64, t *TopK) map[string]int64 {
+ k := TopKFrom(m)
+ if t != nil {
+ k.Merge(t)
+ }
+ return k.Fold()
+}
+
+func addDrops(a, b DropCounts) DropCounts {
+ return DropCounts{a.Rate + b.Rate, a.Size + b.Size, a.Origin + b.Origin, a.Malformed + b.Malformed}
+}
+
+// UpsertDay CAS-merges x into the app-day object for x.Box: read (etag),
+// Merge, conditional put; on ErrCASConflict re-read and retry, three
+// attempts. A no-op merge (seq already recorded) writes nothing.
+func UpsertDay(ctx context.Context, s release.Store, sqid, app string, x Delta) error {
+ key := DayKey(sqid, app, x.Box, x.Date)
+ for attempt := 0; attempt < 3; attempt++ {
+ var day Day
+ raw, etag, err := s.Get(ctx, key)
+ switch {
+ case err == nil:
+ if uerr := json.Unmarshal(raw, &day); uerr != nil {
+ return fmt.Errorf("analytics: parse %s: %w", key, uerr)
+ }
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return err
+ }
+ applied, err := day.Merge(x)
+ if err != nil {
+ return err
+ }
+ if !applied {
+ return nil
+ }
+ out, err := json.Marshal(day)
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, out, etag); err == nil {
+ return nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ }
+ return fmt.Errorf("analytics: %s: lost CAS race three times", key)
+}
diff --git a/internal/analytics/day_test.go b/internal/analytics/day_test.go
new file mode 100644
index 000000000..f702d4000
--- /dev/null
+++ b/internal/analytics/day_test.go
@@ -0,0 +1,94 @@
+package analytics
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func delta(box string, seq int64, pv int64) Delta {
+ h := &HLL{}
+ h.Add(h64(box + itoa(int(seq))))
+ p := NewTopK()
+ p.Add("/", pv)
+ return Delta{Date: "2026-09-07", Box: box, Seq: seq, Hours: map[string]HourRow{"09": {PV: pv, Bytes: pv * 100}},
+ Visitors: h, Pages: p, Referers: NewTopK(), Events: NewTopK()}
+}
+
+func TestDayMergeIsSeqIdempotent(t *testing.T) {
+ var d Day
+ if ok, err := d.Merge(delta("box-a", 5, 10)); !ok || err != nil {
+ t.Fatalf("first merge: %v %v", ok, err)
+ }
+ if ok, _ := d.Merge(delta("box-a", 5, 10)); ok {
+ t.Fatal("replaying the same seq must be a no-op")
+ }
+ if ok, _ := d.Merge(delta("box-a", 4, 10)); ok {
+ t.Fatal("an older seq must be a no-op")
+ }
+ if ok, _ := d.Merge(delta("box-b", 1, 7)); !ok {
+ t.Fatal("another box's seq is independent")
+ }
+ if d.Hours["09"].PV != 17 || d.Pages["/"] != 17 || d.Seq["box-a"] != 5 || d.Seq["box-b"] != 1 || d.V != 1 || d.Date != "2026-09-07" {
+ t.Fatalf("merged = %+v", d)
+ }
+ vis, _ := DecodeHLL(d.Visitors)
+ if c := vis.Count(); c < 1.5 || c > 2.5 {
+ t.Fatalf("visitors ≈ 2, got %.2f", c)
+ }
+ b, _ := json.Marshal(d)
+ for _, want := range []string{`"v":1`, `"hours"`, `"visitors"`, `"pages"`, `"referers"`, `"events"`, `"seq"`, `"dropped"`, `"countries"`} {
+ if !contains(string(b), want) {
+ t.Errorf("wire missing %s in %s", want, b)
+ }
+ }
+}
+
+func TestDayMergeReplacesCorruptSketch(t *testing.T) {
+ d := Day{Visitors: "garbage!"}
+ ok, err := d.Merge(delta("box-a", 1, 5))
+ if !ok || err != nil {
+ t.Fatalf("merge with corrupt sketch: %v %v", ok, err)
+ }
+ if d.Hours["09"].PV != 5 || d.Pages["/"] != 5 {
+ t.Fatalf("day = %+v", d)
+ }
+ vis, err := DecodeHLL(d.Visitors)
+ if err != nil {
+ t.Fatalf("decode replaced sketch: %v", err)
+ }
+ if c := vis.Count(); c < 0.5 {
+ t.Fatalf("expected at least one visitor recorded, got %.2f", c)
+ }
+}
+
+func TestUpsertDayCASAcrossBoxes(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 1, 3)); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil { // retry of an ambiguous put
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, DayKey("bab", "web", "box-a", "2026-09-07"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var d Day
+ json.Unmarshal(raw, &d)
+ if d.Hours["09"].PV != 7 || d.Seq["box-a"] != 2 {
+ t.Fatalf("day = %+v", d)
+ }
+ if DayKey("bab", "web", "box-a", "2026-09-07") != "apps/bab/web/analytics/box-a/days/2026-09-07.json" {
+ t.Fatal(DayKey("bab", "web", "box-a", "2026-09-07"))
+ }
+}
+
+func contains(s, sub string) bool { return strings.Contains(s, sub) }
diff --git a/internal/analytics/hll.go b/internal/analytics/hll.go
new file mode 100644
index 000000000..43827c342
--- /dev/null
+++ b/internal/analytics/hll.go
@@ -0,0 +1,116 @@
+// Package analytics is the pure half of the Analytics tab (spec
+// 2026-09-07-errors-and-analytics-design.md §3.3): the visitor and top-key
+// sketches, the in-memory meter the edge feeds, the day-file wire shape
+// and its idempotent CAS merge, the daily salt, the bot list and the
+// cross-box range read. internal/edge observes, internal/host flushes,
+// internal/console reads — the applog/apperr split.
+package analytics
+
+import (
+ "encoding/base64"
+ "errors"
+ "math"
+ "math/bits"
+)
+
+// HLLPrecision is the register-index width: 2^11 registers, 2 KB, ~2.3%
+// standard error (spec §3.3). One sketch per app per day per box.
+const HLLPrecision = 11
+const hllRegisters = 1 << HLLPrecision
+
+// HLL is a HyperLogLog cardinality sketch over 64-bit hashes.
+type HLL struct{ reg [hllRegisters]uint8 }
+
+// mix64 is MurmurHash3's fmix64 finalizer. Add uses it to avalanche the
+// incoming hash before slicing out the register index and run bits: the
+// register index is the hash's top HLLPrecision bits, and callers' hash
+// functions (FNV-1a included — see h64 in hll_test.go) do not reliably
+// mix entropy into their high bits, especially for short, sequential
+// inputs sharing a common prefix ("v0".."v99999"), which otherwise
+// starves most registers and skews Count.
+func mix64(x uint64) uint64 {
+ x ^= x >> 33
+ x *= 0xff51afd7ed558ccd
+ x ^= x >> 33
+ x *= 0xc4ceb9fe1a85ec53
+ x ^= x >> 33
+ return x
+}
+
+// Add records one hashed element.
+func (h *HLL) Add(hash uint64) {
+ hash = mix64(hash)
+ idx := hash >> (64 - HLLPrecision)
+ rest := hash<<HLLPrecision | 1<<(HLLPrecision-1) // guard bit bounds the run
+ rank := uint8(bits.LeadingZeros64(rest)) + 1
+ if rank > h.reg[idx] {
+ h.reg[idx] = rank
+ }
+}
+
+// Merge folds o into h by register max — the union of the two sets.
+func (h *HLL) Merge(o *HLL) {
+ for i := range h.reg {
+ if o.reg[i] > h.reg[i] {
+ h.reg[i] = o.reg[i]
+ }
+ }
+}
+
+// Empty reports whether nothing was added.
+func (h *HLL) Empty() bool {
+ for _, r := range h.reg {
+ if r != 0 {
+ return false
+ }
+ }
+ return true
+}
+
+// Count estimates the cardinality (Flajolet et al. 2007 with the small-
+// range linear-counting correction).
+func (h *HLL) Count() float64 {
+ m := float64(hllRegisters)
+ alpha := 0.7213 / (1 + 1.079/m)
+ var sum float64
+ zeros := 0
+ for _, r := range h.reg {
+ sum += math.Pow(2, -float64(r))
+ if r == 0 {
+ zeros++
+ }
+ }
+ est := alpha * m * m / sum
+ if est <= 2.5*m && zeros > 0 {
+ est = m * math.Log(m/float64(zeros))
+ }
+ return est
+}
+
+const hllVersion = 1
+
+// Encode renders the sketch as base64 of a version byte plus the registers.
+func (h *HLL) Encode() string {
+ b := make([]byte, 1+hllRegisters)
+ b[0] = hllVersion
+ copy(b[1:], h.reg[:])
+ return base64.StdEncoding.EncodeToString(b)
+}
+
+// DecodeHLL parses Encode's output; "" is an empty sketch, an unknown
+// version is an error (a future shape this build cannot read).
+func DecodeHLL(s string) (*HLL, error) {
+ h := &HLL{}
+ if s == "" {
+ return h, nil
+ }
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ if len(b) != 1+hllRegisters || b[0] != hllVersion {
+ return nil, errors.New("analytics: unknown visitors sketch version or size")
+ }
+ copy(h.reg[:], b[1:])
+ return h, nil
+}
diff --git a/internal/analytics/hll_test.go b/internal/analytics/hll_test.go
new file mode 100644
index 000000000..8cb2cbe82
--- /dev/null
+++ b/internal/analytics/hll_test.go
@@ -0,0 +1,71 @@
+package analytics
+
+import (
+ "hash/fnv"
+ "math"
+ "strconv"
+ "testing"
+)
+
+func h64(s string) uint64 { f := fnv.New64a(); f.Write([]byte(s)); return f.Sum64() }
+
+func TestHLLEstimatesWithinThreeSigma(t *testing.T) {
+ for _, n := range []int{1000, 10000, 100000} {
+ var h HLL
+ for i := 0; i < n; i++ {
+ h.Add(h64("v" + itoa(i)))
+ }
+ got := h.Count()
+ sigma := 1.04 / math.Sqrt(hllRegisters) * float64(n)
+ if math.Abs(got-float64(n)) > 3*sigma {
+ t.Errorf("n=%d: estimate %.0f off by more than 3σ (%.0f)", n, got, 3*sigma)
+ }
+ }
+}
+
+func TestHLLMergeIsUnionAndIdempotent(t *testing.T) {
+ var a, b HLL
+ for i := 0; i < 5000; i++ {
+ a.Add(h64("a" + itoa(i)))
+ b.Add(h64("b" + itoa(i)))
+ }
+ var ab HLL
+ ab.Merge(&a)
+ ab.Merge(&b)
+ ab.Merge(&b) // idempotent
+ if got := ab.Count(); math.Abs(got-10000) > 3*(1.04/math.Sqrt(hllRegisters))*10000 {
+ t.Fatalf("union estimate %.0f", got)
+ }
+ var ba HLL
+ ba.Merge(&b)
+ ba.Merge(&a)
+ if ab.reg != ba.reg {
+ t.Fatal("merge must be commutative")
+ }
+}
+
+func TestHLLEncodeRoundTripAndVersion(t *testing.T) {
+ var h HLL
+ h.Add(h64("x"))
+ enc := h.Encode()
+ if enc[:2] != "AQ" { // base64 of a leading 0x01 byte
+ t.Fatalf("version prefix missing: %q", enc[:4])
+ }
+ d, err := DecodeHLL(enc)
+ if err != nil || d.reg != h.reg {
+ t.Fatalf("round trip: %v", err)
+ }
+ if e, err := DecodeHLL(""); err != nil || !e.Empty() {
+ t.Fatal("empty string must decode to an empty sketch")
+ }
+ if _, err := DecodeHLL("AgAA"); err == nil {
+ t.Fatal("unknown version must error")
+ }
+}
+
+// itoa is the brief's crude helper, replaced with strconv.Itoa: the
+// original digit-reversed encoding broke TestTopKExactBelowCapAndBoundedAbove's
+// key lookups (itoa(99) produced "990000", not "99") and had weaker
+// diversity in leading digits than strconv's ordinary decimal, which
+// stressed the HLL register-index entropy (see hll.go's mix64 note).
+func itoa(i int) string { return strconv.Itoa(i) }
diff --git a/internal/analytics/meter.go b/internal/analytics/meter.go
new file mode 100644
index 000000000..50d1d5baa
--- /dev/null
+++ b/internal/analytics/meter.go
@@ -0,0 +1,265 @@
+package analytics
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "log"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/apperr"
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// Obs is one served response as the edge saw it (spec §3.3). The edge
+// fills it from the observer defer; nothing here touches the request.
+type Obs struct {
+ Acct, App, Host string
+ Method string
+ Status int
+ ContentType string
+ Path string // raw; sanitised here
+ RefererHost string // host only, "" = direct
+ UA string
+ ClientIP string
+ Bytes int64
+ Prefetch bool // Sec-Purpose/Purpose: prefetch
+ FetchDest string // Sec-Fetch-Dest, "" when absent
+ When time.Time
+}
+
+// AppKey is the (account, app) the meter buckets by.
+type AppKey = applog.AppKey
+
+// IsPageview applies §3.3's rule: GET, 200, text/html, not prefetch, dest
+// document or absent, not under /__carlos/. A bot UA is reported
+// separately and never a pageview.
+func IsPageview(o Obs) (pageview, bot bool) {
+ if o.Method != "GET" || o.Status != 200 {
+ return false, false
+ }
+ mt := strings.ToLower(strings.TrimSpace(strings.SplitN(o.ContentType, ";", 2)[0]))
+ if mt != "text/html" {
+ return false, false
+ }
+ if o.Prefetch || (o.FetchDest != "" && o.FetchDest != "document") {
+ return false, false
+ }
+ if strings.HasPrefix(o.Path, "/__carlos/") {
+ return false, false
+ }
+ if IsBot(o.UA) {
+ return false, true
+ }
+ return true, false
+}
+
+// VisitorHash is sha256(salt ‖ host ‖ ip ‖ ua) folded to 64 bits for the
+// sketch; the platform never stores the inputs.
+func VisitorHash(salt []byte, host, ip, ua string) uint64 {
+ h := sha256.New()
+ h.Write(salt)
+ h.Write([]byte{0})
+ h.Write([]byte(host))
+ h.Write([]byte{0})
+ h.Write([]byte(ip))
+ h.Write([]byte{0})
+ h.Write([]byte(ua))
+ return binary.BigEndian.Uint64(h.Sum(nil)[:8])
+}
+
+var eventName = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
+
+// EventName normalises a named event: lowercased, ≤64 bytes of [a-z0-9_.-].
+func EventName(s string) (string, bool) {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if !eventName.MatchString(s) {
+ return "", false
+ }
+ return s, true
+}
+
+// saltRetryDelay bounds how often a failing DaySalt is retried. Visitors
+// go uncounted while it fails, but Observe must never do that I/O (or log)
+// on every request — one attempt, and one log line, per window.
+const saltRetryDelay = 30 * time.Second
+
+// Meter accumulates per-app deltas between flushes. One per edge process;
+// Observe sits on the serving path (one mutex, no I/O except the first
+// salt read of a day).
+type Meter struct {
+ box string
+ saltDir string
+ now func() time.Time
+
+ mu sync.Mutex
+ cur map[AppKey]map[string]*Delta // app → date → delta being built
+ pending map[AppKey][]Delta // re-queued after a failed flush, each marked once
+ dropPending map[AppKey]int64 // deltas dropped by a Requeue that found an already-marked one, awaiting the next delta to land on
+ seq int64
+ saltDay string
+ salt []byte
+ saltFailDay string // date DaySalt last failed for, "" once it next succeeds
+ saltFailAt time.Time // when it failed; not retried until saltRetryDelay has passed
+ started string // date the process started; "" once the wall clock has moved past it (see Drain)
+ restartedFlagged map[AppKey]bool // apps whose start-day delta has already carried Restarted
+}
+
+// NewMeter returns a meter whose salts live in saltDir (the box data dir).
+func NewMeter(saltDir, box string, now func() time.Time) *Meter {
+ return &Meter{box: box, saltDir: saltDir, now: now, cur: map[AppKey]map[string]*Delta{},
+ pending: map[AppKey][]Delta{}, dropPending: map[AppKey]int64{},
+ seq: now().UnixMilli(), started: usage.DayOf(now()), restartedFlagged: map[AppKey]bool{}}
+}
+
+func (m *Meter) saltFor(date string) []byte {
+ if m.saltDay == date && m.salt != nil {
+ return m.salt
+ }
+ if m.saltFailDay == date && m.now().Sub(m.saltFailAt) < saltRetryDelay {
+ return nil // still within the backoff window: don't retry, don't log again
+ }
+ s, err := DaySalt(m.saltDir, date)
+ if err != nil {
+ m.saltFailDay, m.saltFailAt = date, m.now()
+ log.Printf("analytics: day salt: %v (visitors not counted until it works)", err)
+ return nil
+ }
+ m.saltFailDay = ""
+ m.saltDay, m.salt = date, s
+ return s
+}
+
+// delta returns the app's delta being built for date, creating it (and
+// setting Restarted) on first touch. Restarted is per (app, day), not per
+// process: every app gets its own first-delta-of-the-start-day flag, since
+// each app's counts since the last flush may be missing regardless of
+// which app happened to be observed first. restartedFlagged stops a later
+// drain's fresh delta for the same app-day from being flagged again.
+func (m *Meter) delta(k AppKey, date string) *Delta {
+ days := m.cur[k]
+ if days == nil {
+ days = map[string]*Delta{}
+ m.cur[k] = days
+ }
+ d := days[date]
+ if d == nil {
+ restarted := date == m.started && !m.restartedFlagged[k]
+ d = &Delta{Date: date, Box: m.box, Hours: map[string]HourRow{}, Visitors: &HLL{},
+ Pages: NewTopK(), Referers: NewTopK(), Events: NewTopK(), Restarted: restarted}
+ days[date] = d
+ if restarted {
+ m.restartedFlagged[k] = true // this app's start-day note is spent; a later drain's fresh delta for the same day must not repeat it
+ }
+ }
+ return d
+}
+
+// Observe counts one response. Bytes count for every response; pageview,
+// bot, visitor, page and referer only when the rule says so.
+func (m *Meter) Observe(o Obs) {
+ pv, bot := IsPageview(o)
+ date, hour := usage.DayOf(o.When), usage.HourLabel(o.When)
+ path := apperr.SanitizePath(o.Path)
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ d := m.delta(AppKey{Acct: o.Acct, App: o.App}, date)
+ row := d.Hours[hour]
+ row.Bytes += o.Bytes
+ if bot {
+ row.Bots++
+ }
+ if pv {
+ row.PV++
+ if salt := m.saltFor(date); salt != nil {
+ d.Visitors.Add(VisitorHash(salt, o.Host, o.ClientIP, o.UA))
+ }
+ d.Pages.Add(path, 1)
+ ref := o.RefererHost
+ if ref == "" {
+ ref = "direct"
+ }
+ d.Referers.Add(strings.ToLower(ref), 1)
+ }
+ d.Hours[hour] = row
+}
+
+// Track counts one named event (the doors call it; nothing does in step 2).
+// An invalid name is not silently discarded: it counts against the app's
+// current-day Dropped.A.Malformed so the honesty block sees it.
+func (m *Meter) Track(acct, app, name string, when time.Time) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ d := m.delta(AppKey{Acct: acct, App: app}, usage.DayOf(when))
+ n, ok := EventName(name)
+ if !ok {
+ d.Dropped.A.Malformed++
+ return
+ }
+ d.Events.Add(n, 1)
+}
+
+// Drain returns everything buffered — re-queued deltas first, then the
+// current ones, each stamped with a fresh box-monotonic seq — and resets.
+// A window an earlier Requeue had to drop (because it was already marked
+// re-queued when Requeue saw it again) is not silently lost: its count is
+// added to Dropped.Flush on the first delta this drain emits for that app.
+func (m *Meter) Drain() map[AppKey][]Delta {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ out := map[AppKey][]Delta{}
+ for k, ds := range m.pending {
+ out[k] = append(out[k], ds...)
+ }
+ m.pending = map[AppKey][]Delta{}
+ for k, days := range m.cur {
+ for _, d := range days {
+ m.seq++
+ d.Seq = m.seq
+ out[k] = append(out[k], *d)
+ }
+ }
+ m.cur = map[AppKey]map[string]*Delta{}
+ for k, ds := range out {
+ if len(ds) == 0 {
+ continue
+ }
+ if dropped := m.dropPending[k]; dropped > 0 {
+ ds[0].Dropped.Flush += dropped
+ delete(m.dropPending, k)
+ }
+ }
+ if m.started != "" && usage.DayOf(m.now()) != m.started {
+ m.started = "" // the restart note is only for the day it happened
+ m.restartedFlagged = map[AppKey]bool{} // free the bookkeeping once no future delta can consult it
+ }
+ return out
+}
+
+// Requeue holds a failed flush for the next drain. Each delta may survive
+// one failed flush: Requeue marks it (an unexported field on Delta, never
+// serialised) and holds it in pending. A delta Requeue is handed that is
+// already marked — the flush carrying it forward also failed, with no
+// successful Drain landing it in between — is dropped instead, and the
+// drop is counted per app in dropPending rather than kept, so pending
+// never grows past one window's worth of history no matter how many
+// flushes in a row fail. Seq is left untouched — a re-queued delta keeps
+// the seq it was drained with, so a replay that actually landed is a
+// no-op at the day file.
+func (m *Meter) Requeue(failed map[AppKey][]Delta) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for k, ds := range failed {
+ for _, d := range ds {
+ if d.requeued {
+ m.dropPending[k]++
+ continue
+ }
+ d.requeued = true
+ m.pending[k] = append(m.pending[k], d)
+ }
+ }
+}
diff --git a/internal/analytics/meter_test.go b/internal/analytics/meter_test.go
new file mode 100644
index 000000000..a485da65e
--- /dev/null
+++ b/internal/analytics/meter_test.go
@@ -0,0 +1,239 @@
+package analytics
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func obs(method string, status int, ctype string) Obs {
+ return Obs{Acct: "bab", App: "web", Host: "h1", Method: method, Status: status, ContentType: ctype,
+ Path: "/pricing?x=1", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.5",
+ Bytes: 1200, When: time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)}
+}
+
+func TestIsPageviewRule(t *testing.T) {
+ cases := []struct {
+ name string
+ o Obs
+ pv bool
+ bot bool
+ }{
+ {"html 200", obs("GET", 200, "text/html; charset=utf-8"), true, false},
+ {"json", obs("GET", 200, "application/json"), false, false},
+ {"404", obs("GET", 404, "text/html"), false, false},
+ {"redirect", obs("GET", 302, "text/html"), false, false},
+ {"304", obs("GET", 304, ""), false, false},
+ {"head", obs("HEAD", 200, "text/html"), false, false},
+ {"post", obs("POST", 200, "text/html"), false, false},
+ {"prefetch", func() Obs { o := obs("GET", 200, "text/html"); o.Prefetch = true; return o }(), false, false},
+ {"fetch dest empty", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "empty"; return o }(), false, false},
+ {"fetch dest document", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "document"; return o }(), true, false},
+ {"carlos prefix", func() Obs { o := obs("GET", 200, "text/html"); o.Path = "/__carlos/a"; return o }(), false, false},
+ {"bot", func() Obs { o := obs("GET", 200, "text/html"); o.UA = "Googlebot/2.1"; return o }(), false, true},
+ }
+ for _, c := range cases {
+ pv, bot := IsPageview(c.o)
+ if pv != c.pv || bot != c.bot {
+ t.Errorf("%s: pv=%v bot=%v, want %v %v", c.name, pv, bot, c.pv, c.bot)
+ }
+ }
+}
+
+func TestMeterCountsAndDrains(t *testing.T) {
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ m.Observe(obs("GET", 200, "text/html"))
+ m.Observe(obs("GET", 200, "text/html"))
+ o := obs("GET", 200, "text/html")
+ o.ClientIP = "198.51.100.9"
+ m.Observe(o)
+ m.Observe(obs("GET", 200, "application/json")) // bytes only
+ b := obs("GET", 200, "text/html")
+ b.UA = "curl/8"
+ m.Observe(b)
+ m.Track("bab", "web", "Signup", now)
+ m.Track("bab", "web", "bad name!", now) // rejected
+ out := m.Drain()
+ ds := out[AppKey{Acct: "bab", App: "web"}]
+ if len(ds) != 1 {
+ t.Fatalf("deltas = %d", len(ds))
+ }
+ d := ds[0]
+ if d.Date != "2026-09-07" || d.Box != "box1" || d.Seq == 0 {
+ t.Fatalf("delta = %+v", d)
+ }
+ if h := d.Hours["09"]; h.PV != 3 || h.Bots != 1 || h.Bytes != 6000 {
+ t.Fatalf("hour = %+v", h)
+ }
+ if c := d.Visitors.Count(); c < 1.5 || c > 2.5 {
+ t.Fatalf("visitors ≈ 2, got %.2f", c)
+ }
+ if p := d.Pages.Fold(); p["/pricing"] != 3 {
+ t.Fatalf("pages = %v", p)
+ }
+ if r := d.Referers.Fold(); r["news.ycombinator.com"] != 3 {
+ t.Fatalf("referers = %v", r)
+ }
+ if e := d.Events.Fold(); e["signup"] != 1 || len(e) != 1 {
+ t.Fatalf("events = %v", e)
+ }
+ if d.Dropped.A.Malformed != 1 {
+ t.Fatalf("bad event name must count Dropped.A.Malformed, not vanish: %+v", d.Dropped)
+ }
+ if len(m.Drain()) != 0 {
+ t.Fatal("second drain must be empty")
+ }
+ // Event-time bucketing: an observation at 09:59 flushed at 10:00 lands in 09.
+ m.Observe(func() Obs { o := obs("GET", 200, "text/html"); o.When = now.Add(29 * time.Minute); return o }())
+ now = now.Add(31 * time.Minute)
+ if d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0]; d.Hours["09"].PV != 1 || d.Hours["10"].PV != 0 {
+ t.Fatalf("event-time bucket: %+v", d.Hours)
+ }
+}
+
+// TestMeterRequeueSurvivesOnce exercises the production caller's actual
+// sequence — Drain, flush, Requeue on failure, repeat — where pending is
+// always empty at the moment Requeue is called (the prior Drain just
+// cleared it). The "already re-queued once" state has to live on the
+// delta itself, not on pending's emptiness, or nothing is ever dropped
+// and pending grows without bound across repeated failures. Each delta
+// may survive exactly one failed flush: a second failure for the same
+// delta drops it and counts the drop (once the delta is marked, a second
+// Requeue drops it) on the next delta that reaches the app's bucket —
+// bounded forever at two windows per app no matter how many flushes in a
+// row fail.
+func TestMeterRequeueSurvivesOnce(t *testing.T) {
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ k := AppKey{Acct: "bab", App: "web"}
+
+ m.Observe(obs("GET", 200, "text/html"))
+ d1 := m.Drain()
+ m.Requeue(d1) // flush failed: d1's delta is marked and held
+
+ m.Observe(obs("GET", 200, "text/html"))
+ d2 := m.Drain() // = d1's delta (marked, own seq) + the new one (fresh seq)
+ if ds := d2[k]; len(ds) != 2 || ds[0].Seq >= ds[1].Seq {
+ t.Fatalf("d2 must be the requeued delta then the new one, in seq order: %+v", ds)
+ }
+ m.Requeue(d2) // failed again: d1's delta is already marked, so it drops; d2's new delta is marked and held
+
+ m.Observe(obs("GET", 200, "text/html"))
+ d3 := m.Drain()[k]
+ if len(d3) != 2 {
+ t.Fatalf("d1's window must be gone, leaving d2's surviving delta plus the new one: %+v", d3)
+ }
+ if d3[0].Dropped.Flush != 1 {
+ t.Fatalf("the drop must be reported on the delta that reaches the bucket: %+v", d3)
+ }
+ if d3[0].Seq != d2[k][1].Seq {
+ t.Fatalf("the surviving delta must keep the seq it had in d2, not be re-stamped: got %d, want %d", d3[0].Seq, d2[k][1].Seq)
+ }
+ if d3[1].Seq <= d3[0].Seq {
+ t.Fatalf("the new delta must get a fresh, higher seq: %+v", d3)
+ }
+
+ // A third failure in a row: bounded forever at two windows per app.
+ m.Requeue(map[AppKey][]Delta{k: d3})
+ m.Observe(obs("GET", 200, "text/html"))
+ d4 := m.Drain()[k]
+ if len(d4) != 2 || d4[0].Dropped.Flush != 1 {
+ t.Fatalf("repeated failures must stay bounded at two windows, each drop reported once: %+v", d4)
+ }
+}
+
+// TestMeterTrackMalformedCountsDropped: an invalid event name must not
+// vanish silently — it counts against the app's current-day
+// Dropped.A.Malformed.
+func TestMeterTrackMalformedCountsDropped(t *testing.T) {
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ m.Track("bab", "web", "not a valid name!", now)
+ m.Track("bab", "web", "also/not valid", now)
+ d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0]
+ if d.Dropped.A.Malformed != 2 {
+ t.Fatalf("Dropped.A.Malformed = %d, want 2", d.Dropped.A.Malformed)
+ }
+}
+
+// TestMeterSaltFailureIsThrottled: a persistently failing DaySalt must not
+// be retried on every request — pageviews still count, but visitors stay
+// uncounted while it fails, without blocking or panicking on the
+// serving path.
+func TestMeterSaltFailureIsThrottled(t *testing.T) {
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ saltDir := filepath.Join(t.TempDir(), "missing", "nested") // DaySalt can't create its file here
+ m := NewMeter(saltDir, "box1", func() time.Time { return now })
+ m.Observe(obs("GET", 200, "text/html")) // salt lookup fails
+ m.Observe(obs("GET", 200, "text/html")) // within the backoff window: not retried
+ now = now.Add(31 * time.Second)
+ m.Observe(obs("GET", 200, "text/html")) // backoff elapsed: retried (and fails again, saltDir still missing)
+ d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0]
+ if d.Hours["09"].PV != 3 {
+ t.Fatalf("pageviews must still count while the salt fails: %+v", d.Hours)
+ }
+ if c := d.Visitors.Count(); c != 0 {
+ t.Fatalf("visitors must stay uncounted while the salt fails, got %.2f", c)
+ }
+}
+
+// TestMeterRestartedPerAppOnStartDay: Restarted is per (app, day), not per
+// process. Every app observed on the start day gets its own first-delta
+// flag (the "counts since the last flush may be missing" note is true for
+// each app independently); a later drain of the same start day must not
+// repeat it; and it never fires once the wall clock has moved past the
+// start day.
+func TestMeterRestartedPerAppOnStartDay(t *testing.T) {
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+
+ appA := AppKey{Acct: "bab", App: "web"}
+ appB := AppKey{Acct: "other", App: "app2"}
+
+ m.Observe(obs("GET", 200, "text/html")) // app A's first delta of the start day
+ o := obs("GET", 200, "text/html")
+ o.Acct, o.App = appB.Acct, appB.App
+ m.Observe(o) // app B's first delta of the start day
+
+ out := m.Drain()
+ if !out[appA][0].Restarted {
+ t.Fatal("app A's first delta of the start day must carry Restarted")
+ }
+ if !out[appB][0].Restarted {
+ t.Fatal("app B's first delta of the start day must also carry Restarted: the flag is per app-day, not per process")
+ }
+
+ // A second drain of the same start day for app A must not repeat the note.
+ m.Observe(obs("GET", 200, "text/html"))
+ out = m.Drain()
+ if out[appA][0].Restarted {
+ t.Fatal("app A's second delta of the start day must not be flagged again")
+ }
+
+ // The next day, nothing is flagged: the note belongs to the start day.
+ now = time.Date(2026, 9, 8, 9, 30, 0, 0, time.UTC)
+ next := obs("GET", 200, "text/html")
+ next.When = now
+ m.Observe(next)
+ out = m.Drain()
+ if out[appA][0].Restarted {
+ t.Fatal("a day after the start day must never carry Restarted")
+ }
+}
+
+func TestVisitorHashAndEventName(t *testing.T) {
+ s1, s2 := []byte("salt-a"), []byte("salt-b")
+ if VisitorHash(s1, "h", "ip", "ua") != VisitorHash(s1, "h", "ip", "ua") {
+ t.Fatal("deterministic")
+ }
+ if VisitorHash(s1, "h", "ip", "ua") == VisitorHash(s2, "h", "ip", "ua") {
+ t.Fatal("salt must change the hash")
+ }
+ for in, want := range map[string]string{"Signup": "signup", "checkout.done": "checkout.done", "a b": "", "": "", "x/y": ""} {
+ got, ok := EventName(in)
+ if (want == "") == ok || got != want {
+ t.Errorf("EventName(%q) = %q,%v", in, got, ok)
+ }
+ }
+}
diff --git a/internal/analytics/read.go b/internal/analytics/read.go
new file mode 100644
index 000000000..bf80bcb68
--- /dev/null
+++ b/internal/analytics/read.go
@@ -0,0 +1,127 @@
+package analytics
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "math"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// DaySummary is one UTC day across every box.
+type DaySummary struct {
+ Date string `json:"date"`
+ PV int64 `json:"pv"`
+ Bots int64 `json:"bots"`
+ Bytes int64 `json:"bytes"`
+ Visitors int64 `json:"visitors"`
+ Hours [24]int64 `json:"hours"`
+ Restarted bool `json:"restarted"`
+}
+
+// Summary is what the tab and the API render: per-day rows plus totals.
+// Visitors is the SUM of daily estimates (spec §3.3: "daily visitors,
+// summed"), never a cross-day union.
+type Summary struct {
+ From, To string
+ Days []DaySummary
+ PV, Bots int64
+ Bytes int64
+ Visitors int64
+ Pages map[string]int64
+ Referers map[string]int64
+ Events map[string]int64
+ PagesApprox bool
+ ReferersApprox bool
+ EventsApprox bool
+ Dropped Dropped
+ Boxes []string
+}
+
+// Dates lists the UTC dates from from to to inclusive.
+func Dates(from, to time.Time) []string {
+ var out []string
+ for d := from.UTC().Truncate(24 * time.Hour); !d.After(to.UTC()); d = d.Add(24 * time.Hour) {
+ out = append(out, usage.DayOf(d))
+ }
+ return out
+}
+
+// ReadRange lists the app's boxes and reads each day file in the range,
+// merging across boxes: counters sum, visitor sketches union per day,
+// top-key summaries merge and re-fold. A missing day file is an empty
+// day; a store error fails the read.
+func ReadRange(ctx context.Context, s release.Store, sqid, app string, from, to time.Time) (Summary, error) {
+ sum := Summary{From: usage.DayOf(from), To: usage.DayOf(to)}
+ boxDirs, err := s.ListDirs(ctx, Prefix(sqid, app))
+ if err != nil {
+ return sum, err
+ }
+ for _, dir := range boxDirs {
+ sum.Boxes = append(sum.Boxes, boxName(dir))
+ }
+ sort.Strings(sum.Boxes)
+ pages, refs, events := NewTopK(), NewTopK(), NewTopK()
+ for _, date := range Dates(from, to) {
+ ds := DaySummary{Date: date}
+ vis := &HLL{}
+ for _, box := range sum.Boxes {
+ raw, _, err := s.Get(ctx, DayKey(sqid, app, box, date))
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return sum, err
+ }
+ var day Day
+ if err := json.Unmarshal(raw, &day); err != nil {
+ return sum, err
+ }
+ for h, row := range day.Hours {
+ if i, err := strconv.Atoi(h); err == nil && i >= 0 && i < 24 {
+ ds.Hours[i] += row.PV
+ }
+ ds.PV += row.PV
+ ds.Bots += row.Bots
+ ds.Bytes += row.Bytes
+ }
+ if v, err := DecodeHLL(day.Visitors); err == nil {
+ vis.Merge(v)
+ }
+ pages.Merge(TopKFrom(day.Pages))
+ refs.Merge(TopKFrom(day.Referers))
+ events.Merge(TopKFrom(day.Events))
+ sum.Dropped.E = addDrops(sum.Dropped.E, day.Dropped.E)
+ sum.Dropped.A = addDrops(sum.Dropped.A, day.Dropped.A)
+ sum.Dropped.Flush += day.Dropped.Flush
+ ds.Restarted = ds.Restarted || day.Restarted
+ }
+ if !vis.Empty() {
+ ds.Visitors = int64(math.Round(vis.Count()))
+ }
+ sum.Days = append(sum.Days, ds)
+ sum.PV += ds.PV
+ sum.Bots += ds.Bots
+ sum.Bytes += ds.Bytes
+ sum.Visitors += ds.Visitors
+ }
+ sum.Pages, sum.PagesApprox = pages.Fold(), pages.Approximate()
+ sum.Referers, sum.ReferersApprox = refs.Fold(), refs.Approximate()
+ sum.Events, sum.EventsApprox = events.Fold(), events.Approximate()
+ return sum, nil
+}
+
+func boxName(dir string) string {
+ t := strings.TrimSuffix(dir, "/")
+ if i := strings.LastIndexByte(t, '/'); i >= 0 {
+ return t[i+1:]
+ }
+ return t
+}
diff --git a/internal/analytics/read_test.go b/internal/analytics/read_test.go
new file mode 100644
index 000000000..097050f12
--- /dev/null
+++ b/internal/analytics/read_test.go
@@ -0,0 +1,46 @@
+package analytics
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestReadRangeMergesBoxesAndDays(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ d1 := delta("box-a", 1, 10)
+ d2 := delta("box-b", 1, 5)
+ d3 := delta("box-a", 2, 7)
+ d3.Date = "2026-09-08"
+ d3.Restarted = true
+ for _, d := range []Delta{d1, d2, d3} {
+ if err := UpsertDay(ctx, s, "bab", "web", d); err != nil {
+ t.Fatal(err)
+ }
+ }
+ from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
+ sum, err := ReadRange(ctx, s, "bab", "web", from, from.Add(24*time.Hour))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sum.Days) != 2 || sum.Days[0].Date != "2026-09-07" || sum.Days[0].PV != 15 || sum.Days[1].PV != 7 {
+ t.Fatalf("days = %+v", sum.Days)
+ }
+ if sum.PV != 22 || sum.Pages["/"] != 22 || sum.Days[0].Hours[9] != 15 || !sum.Days[1].Restarted || sum.Days[0].Restarted {
+ t.Fatalf("summary = %+v", sum)
+ }
+ if sum.Visitors != sum.Days[0].Visitors+sum.Days[1].Visitors || sum.Days[0].Visitors < 1 {
+ t.Fatalf("visitors must be daily sums: %+v", sum)
+ }
+ if len(sum.Boxes) != 2 {
+ t.Fatalf("boxes = %v", sum.Boxes)
+ }
+ // An app with no day files reads as an empty summary, not an error.
+ empty, err := ReadRange(ctx, s, "bab", "nothing", from, from)
+ if err != nil || empty.PV != 0 || len(empty.Days) != 1 {
+ t.Fatalf("empty = %+v %v", empty, err)
+ }
+}
diff --git a/internal/analytics/salt.go b/internal/analytics/salt.go
new file mode 100644
index 000000000..812407658
--- /dev/null
+++ b/internal/analytics/salt.go
@@ -0,0 +1,98 @@
+package analytics
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const saltPrefix = "analytics-salt-"
+const tmpInfix = ".tmp-"
+
+// DaySalt returns the box's visitor salt for date (YYYY-MM-DD), creating
+// <dir>/analytics-salt-<date> with 32 random bytes at 0600 when absent and
+// deleting any other day's file (spec §3.3: restart-stable within the day,
+// gone on rotation, never in the bucket). dir is the box data directory.
+//
+// The file is created atomically so concurrent callers never observe a
+// short or empty salt while another goroutine/process is still writing:
+// a candidate is written in full to a private
+// analytics-salt-<date>.tmp-<random> file first, then linked into place
+// with os.Link. Link fails with os.ErrExist when a sibling has already
+// won the race; the loser then polls the winner's file for up to 200ms
+// until it has settled to 32 bytes, rather than trust a possibly-partial
+// read. Filesystems without hard-link support (some Windows
+// configurations) fall back to os.Rename, which is atomic but — unlike
+// Link — can clobber a concurrent winner's file; that's an accepted
+// trade-off on those filesystems.
+func DaySalt(dir, date string) ([]byte, error) {
+ path := filepath.Join(dir, saltPrefix+date)
+ if b, err := os.ReadFile(path); err == nil && len(b) == 32 {
+ return b, nil
+ }
+
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return nil, err
+ }
+
+ var suffix [8]byte
+ if _, err := rand.Read(suffix[:]); err != nil {
+ return nil, err
+ }
+ tmp := path + tmpInfix + hex.EncodeToString(suffix[:])
+
+ f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return nil, err
+ }
+ _, werr := f.Write(b)
+ cerr := f.Close()
+ if werr != nil || cerr != nil {
+ os.Remove(tmp)
+ return nil, errors.Join(werr, cerr)
+ }
+
+ switch linkErr := os.Link(tmp, path); {
+ case linkErr == nil:
+ // We won: tmp and path now name the same inode.
+ os.Remove(tmp)
+ case errors.Is(linkErr, os.ErrExist):
+ // A sibling process/goroutine won the race.
+ os.Remove(tmp)
+ for i := 0; i < 20; i++ {
+ if got, rerr := os.ReadFile(path); rerr == nil && len(got) == 32 {
+ return got, nil
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return nil, fmt.Errorf("analytics: timed out waiting for %s", path)
+ default:
+ // No hard-link support: fall back to an atomic rename.
+ if rerr := os.Rename(tmp, path); rerr != nil {
+ os.Remove(tmp)
+ return nil, rerr
+ }
+ }
+
+ // Rotation: every other day's salt goes, so nothing on disk can join a
+ // hash across days. In-flight tmpInfix files are skipped — they also
+ // share saltPrefix (possibly even today's date, from a sibling
+ // goroutine/process still mid-write) and must not be reaped out from
+ // under their owner. A failed ReadDir here is deliberately ignored:
+ // dir is local per-box state, so a stale salt file left behind
+ // self-heals the next time rotation runs.
+ entries, _ := os.ReadDir(dir)
+ for _, e := range entries {
+ n := e.Name()
+ if strings.HasPrefix(n, saltPrefix) && n != saltPrefix+date && !strings.Contains(n, tmpInfix) {
+ os.Remove(filepath.Join(dir, n))
+ }
+ }
+ return b, nil
+}
diff --git a/internal/analytics/salt_test.go b/internal/analytics/salt_test.go
new file mode 100644
index 000000000..90dfa25c1
--- /dev/null
+++ b/internal/analytics/salt_test.go
@@ -0,0 +1,64 @@
+package analytics
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "testing"
+)
+
+func TestDaySaltIsStableAndRotates(t *testing.T) {
+ dir := t.TempDir()
+ a, err := DaySalt(dir, "2026-09-07")
+ if err != nil || len(a) != 32 {
+ t.Fatalf("salt: %v len=%d", err, len(a))
+ }
+ b, _ := DaySalt(dir, "2026-09-07")
+ if string(a) != string(b) {
+ t.Fatal("same day must return the same salt (restart-stable)")
+ }
+ if runtime.GOOS != "windows" {
+ st, _ := os.Stat(filepath.Join(dir, "analytics-salt-2026-09-07"))
+ if st.Mode().Perm() != 0o600 {
+ t.Fatalf("mode %v", st.Mode().Perm())
+ }
+ }
+ c, _ := DaySalt(dir, "2026-09-08")
+ if string(c) == string(a) {
+ t.Fatal("a new day must salt afresh")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "analytics-salt-2026-09-07")); !os.IsNotExist(err) {
+ t.Fatal("rotation must delete the previous day's salt")
+ }
+}
+
+func TestDaySaltConcurrentCallersAgree(t *testing.T) {
+ dir := t.TempDir()
+ const n = 8
+ results := make([][]byte, n)
+ errs := make([]error, n)
+ var wg sync.WaitGroup
+ wg.Add(n)
+ for i := 0; i < n; i++ {
+ i := i
+ go func() {
+ defer wg.Done()
+ results[i], errs[i] = DaySalt(dir, "2026-09-07")
+ }()
+ }
+ wg.Wait()
+ for i, err := range errs {
+ if err != nil {
+ t.Fatalf("goroutine %d: %v", i, err)
+ }
+ if len(results[i]) != 32 {
+ t.Fatalf("goroutine %d: len=%d", i, len(results[i]))
+ }
+ }
+ for i := 1; i < n; i++ {
+ if string(results[i]) != string(results[0]) {
+ t.Fatalf("goroutine %d's salt disagrees with goroutine 0's", i)
+ }
+ }
+}
diff --git a/internal/analytics/topk.go b/internal/analytics/topk.go
new file mode 100644
index 000000000..71745ee7c
--- /dev/null
+++ b/internal/analytics/topk.go
@@ -0,0 +1,106 @@
+package analytics
+
+// TopKSize bounds a top-keys summary (pages, referers, events) per app
+// per day per box (spec §3.3). Misra–Gries: exact below the cap, a lower
+// bound within the summary's error above it, mergeable, with the evicted
+// mass kept in OtherKey so totals are conserved.
+const TopKSize = 500
+
+// OtherKey is the fold-over row for keys beyond the cap.
+const OtherKey = "_other"
+
+// MaxKeyLen caps a key's bytes.
+const MaxKeyLen = 256
+
+// TopK is a Misra–Gries summary with TopKSize counters.
+type TopK struct {
+ counts map[string]int64
+ other int64
+}
+
+// NewTopK returns an empty summary.
+func NewTopK() *TopK { return &TopK{counts: make(map[string]int64)} }
+
+// Add counts n occurrences of key. When the summary is full and key is
+// new, every counter is decremented by the smallest amount that frees a
+// slot, and the decremented mass moves to other.
+func (t *TopK) Add(key string, n int64) {
+ if len(key) > MaxKeyLen {
+ key = key[:MaxKeyLen]
+ }
+ if n <= 0 {
+ return
+ }
+ if _, ok := t.counts[key]; ok || len(t.counts) < TopKSize {
+ t.counts[key] += n
+ return
+ }
+ // Full and new: take the smallest counter as the decrement.
+ min := int64(-1)
+ for _, v := range t.counts {
+ if min < 0 || v < min {
+ min = v
+ }
+ }
+ if n <= min {
+ t.other += n
+ return
+ }
+ for k, v := range t.counts {
+ if v <= min {
+ delete(t.counts, k)
+ t.other += v
+ } else {
+ t.counts[k] = v - min
+ t.other += min
+ }
+ }
+ t.counts[key] = n - min
+ t.other += min
+}
+
+// Merge folds o into t: counters add, other adds, then t re-folds to the
+// cap so a merge of two full summaries stays bounded.
+func (t *TopK) Merge(o *TopK) {
+ merged := NewTopK()
+ merged.other = t.other + o.other
+ sum := make(map[string]int64, len(t.counts)+len(o.counts))
+ for k, v := range t.counts {
+ sum[k] += v
+ }
+ for k, v := range o.counts {
+ sum[k] += v
+ }
+ for k, v := range sum { // Add re-applies the cap
+ merged.Add(k, v)
+ }
+ t.counts, t.other = merged.counts, merged.other
+}
+
+// Fold returns the counters plus OtherKey when any mass fell over.
+func (t *TopK) Fold() map[string]int64 {
+ out := make(map[string]int64, len(t.counts)+1)
+ for k, v := range t.counts {
+ out[k] = v
+ }
+ if t.other > 0 {
+ out[OtherKey] = t.other
+ }
+ return out
+}
+
+// TopKFrom rebuilds a summary from a folded map (the day file's shape).
+func TopKFrom(m map[string]int64) *TopK {
+ t := NewTopK()
+ for k, v := range m {
+ if k == OtherKey {
+ t.other += v
+ continue
+ }
+ t.Add(k, v)
+ }
+ return t
+}
+
+// Approximate reports whether counters are lower bounds (mass fell over).
+func (t *TopK) Approximate() bool { return t.other > 0 }
diff --git a/internal/analytics/topk_test.go b/internal/analytics/topk_test.go
new file mode 100644
index 000000000..516c78d92
--- /dev/null
+++ b/internal/analytics/topk_test.go
@@ -0,0 +1,57 @@
+package analytics
+
+import "testing"
+
+func TestTopKExactBelowCapAndBoundedAbove(t *testing.T) {
+ k := NewTopK()
+ for i := 0; i < 100; i++ {
+ k.Add("/p"+itoa(i), int64(i+1))
+ }
+ f := k.Fold()
+ if len(f) != 100 || f["/p99"] != 100 || k.Approximate() {
+ t.Fatalf("below cap must be exact: len=%d p99=%d approx=%v", len(f), f["/p99"], k.Approximate())
+ }
+ for i := 0; i < 2000; i++ {
+ k.Add("/q"+itoa(i), 1)
+ }
+ f = k.Fold()
+ if len(f) > TopKSize+1 || !k.Approximate() || f[OtherKey] == 0 {
+ t.Fatalf("above cap: len=%d approx=%v other=%d", len(f), k.Approximate(), f[OtherKey])
+ }
+ // Total mass is conserved between counters and _other.
+ var total int64
+ for _, v := range f {
+ total += v
+ }
+ if total != 100*101/2+2000 {
+ t.Fatalf("mass %d", total)
+ }
+ // A heavy hitter survives.
+ if f["/p99"] < 100-2000/TopKSize {
+ t.Fatalf("heavy hitter undercounted: %d", f["/p99"])
+ }
+}
+
+func TestTopKMergeRefolds(t *testing.T) {
+ a, b := NewTopK(), NewTopK()
+ for i := 0; i < 400; i++ {
+ a.Add("/a"+itoa(i), 2)
+ b.Add("/b"+itoa(i), 3)
+ }
+ a.Merge(b)
+ f := a.Fold()
+ if len(f) > TopKSize+1 || f[OtherKey] == 0 {
+ t.Fatalf("merged 800 keys must fold to ≤501: %d", len(f))
+ }
+ r := TopKFrom(f)
+ if r.other != f[OtherKey] || len(r.counts) != len(f)-1 {
+ t.Fatal("TopKFrom must invert Fold")
+ }
+ long := NewTopK()
+ long.Add(string(make([]byte, 300)), 1)
+ for key := range long.Fold() {
+ if len(key) > MaxKeyLen {
+ t.Fatal("keys must be capped at 256 bytes")
+ }
+ }
+}
diff --git a/internal/console/analyticsview.go b/internal/console/analyticsview.go
new file mode 100644
index 000000000..f9892dc57
--- /dev/null
+++ b/internal/console/analyticsview.go
@@ -0,0 +1,409 @@
+package console
+
+// The Analytics switch (spec 2026-09-07 §3.3, §3.6): the two settings
+// forms that turn edge counting on, at app and account level. The switch
+// is the whole feature's consent — counting is off until an owner turns it
+// on — so these two doors are the owner's alone, and every write moves the
+// serial: a box acts on this setting, unlike the Errors log level, which
+// only the console reads.
+
+import (
+ "context"
+ "crypto/subtle"
+ "fmt"
+ "log"
+ "math"
+ "net/http"
+ "net/url"
+ "sort"
+ "time"
+
+ "github.com/carlosframework/platform/internal/analytics"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+// validCount and validDoor name the two keys' vocabularies, the way
+// telemetry.ValidLogLevel names the errors half's. "browser" is a mode the
+// spec reserves and no box acts on yet, so it is NOT valid here: a setting
+// nothing honours must not be storable.
+func validCount(s string) bool { return s == telemetry.CountEdge || s == telemetry.CountOff }
+func validDoor(s string) bool { return s == telemetry.DoorOn || s == telemetry.DoorOff }
+
+// mergeAnalytics folds the keys a caller actually sent into st, keeping the
+// one they did not: an owner flipping counting on must not silently reset a
+// door someone set from the CLI. An empty string means "leave alone", which
+// is also how the stored shape spells "not set here" (telemetry.Analytics).
+func mergeAnalytics(st *telemetry.Settings, count, door string) {
+ if st.Analytics == nil {
+ st.Analytics = &telemetry.Analytics{}
+ }
+ if count != "" {
+ st.Analytics.Count = count
+ }
+ if door != "" {
+ st.Analytics.Door = door
+ }
+}
+
+// analyticsFormValues reads the two form fields both switches share. An
+// empty field leaves that key unchanged; a form that names neither changed
+// nothing, and says so rather than writing an empty object and bumping the
+// serial over it.
+func analyticsFormValues(r *http.Request) (count, door, bad string) {
+ count, door = r.FormValue("count"), r.FormValue("door")
+ if count == "" && door == "" {
+ return "", "", "Choose what to change."
+ }
+ if count != "" && !validCount(count) {
+ return "", "", "Choose edge or off for counting."
+ }
+ if door != "" && !validDoor(door) {
+ return "", "", "Choose on or off for the browser door."
+ }
+ return count, door, ""
+}
+
+// analyticsFlash is what the owner is told after a save: what is now true,
+// named, rather than "saved".
+func analyticsFlash(count, door, scope string) string {
+ switch {
+ case count != "" && door != "":
+ return "Analytics counting is now " + count + " and the browser door " + door + " " + scope + "."
+ case door != "":
+ return "The analytics browser door is now " + door + " " + scope + "."
+ default:
+ return "Analytics counting is now " + count + " " + scope + "."
+ }
+}
+
+// handleAppAnalyticsSetting is the app Settings tab's form: owner, CSRF,
+// the two analytics keys. It mirrors handleAppErrorLevel gate for gate —
+// the session first, then the constant-time CSRF compare, then the bare
+// 404 a non-owner gets, so nobody learns from here what they could not
+// already see.
+func (s *Server) handleAppAnalyticsSetting(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ count, door, bad := analyticsFormValues(r)
+ back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ // The setting lives in the deployment bucket, so a console without one
+ // has nowhere to put it — refused before the write rather than panicking
+ // inside it, exactly as the errors level is.
+ if s.cfg.Releases == nil {
+ s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.")
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ return
+ }
+ if bad != "" {
+ s.setFlash(w, "err", bad)
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ return
+ }
+ if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), func(st *telemetry.Settings) {
+ mergeAnalytics(st, count, door)
+ }); err != nil {
+ log.Printf("console: analytics setting %s/%s: %v", acct.Sqid, app, err)
+ s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ return
+ }
+ s.audit(acct, app, addr, "analytics-setting", "set "+analyticsAudit(count, door)+" for this app")
+ // Box-consumed: the edge starts or stops counting on the next pass, so
+ // the serial moves rather than leaving it to the hourly floor.
+ s.bumpSerial(r.Context())
+ s.setFlash(w, "ok", analyticsFlash(count, door, "for this app"))
+ http.Redirect(w, r, back, http.StatusSeeOther)
+}
+
+// handleAccountAnalyticsSetting is the account settings form: the default
+// every app without its own setting inherits.
+func (s *Server) handleAccountAnalyticsSetting(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ count, door, bad := analyticsFormValues(r)
+ back := "/a/" + url.PathEscape(acct.Sqid) + "/settings"
+ if s.cfg.Releases == nil {
+ s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.")
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ return
+ }
+ if bad != "" {
+ s.setFlash(w, "err", bad)
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ return
+ }
+ if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AccountKey(acct.Sqid), func(st *telemetry.Settings) {
+ mergeAnalytics(st, count, door)
+ }); err != nil {
+ log.Printf("console: account analytics setting %s: %v", acct.Sqid, err)
+ s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
+ http.Redirect(w, r, back, http.StatusSeeOther)
+ return
+ }
+ // Filed under the account subject rather than an empty app column, for
+ // the reason handleAccountErrorLevel spells out.
+ s.audit(acct, accountAuditSubject, addr, "analytics-setting", "set the account's "+analyticsAudit(count, door))
+ s.bumpSerial(r.Context())
+ s.setFlash(w, "ok", analyticsFlash(count, door, "for every app without its own setting"))
+ http.Redirect(w, r, back, http.StatusSeeOther)
+}
+
+// analyticsAudit names the change for the activity feed: only the keys the
+// form actually carried.
+func analyticsAudit(count, door string) string {
+ switch {
+ case count != "" && door != "":
+ return "analytics counting to " + count + " and the browser door to " + door
+ case door != "":
+ return "the analytics browser door to " + door
+ default:
+ return "analytics counting to " + count
+ }
+}
+
+// ---- the Analytics tab's read (spec §3.6) -----------------------------------
+//
+// The tab and `carlos analytics` are the same read: readAnalytics assembles
+// the window, the page renders it and apianalytics.go serialises it, exactly
+// as readErrors serves both ends of the Errors pair.
+
+// analyticsWindow is one assembled read: the summary, the range it covers,
+// the resolved setting (so the page can say counting is off, and where that
+// came from), and the chart geometry the template plots.
+type analyticsWindow struct {
+ Sum analytics.Summary
+ Range string
+ On bool
+ Setting telemetry.Resolved
+ ChartBars []chartBar
+ ChartMax int64
+}
+
+// chartBar is one plotted column: the reading, and the rectangle that draws
+// it. The geometry is computed here rather than in the template because a
+// template cannot divide, and because the scaling is the part worth testing.
+type chartBar struct {
+ Label string
+ Value int64
+ X, Y, W float64
+ H float64
+}
+
+// The three windows the tab and the API accept. Anything else is a mistake
+// worth naming at the API and worth ignoring at the page.
+const (
+ rangeToday = "today"
+ range7d = "7d"
+ range30d = "30d"
+)
+
+func validRange(s string) bool { return s == rangeToday || s == range7d || s == range30d }
+
+// analyticsSpan turns a range name into a UTC span. Truncate(24h) lands on
+// midnight because Go's zero instant is midnight UTC. The end is now, not
+// the end of the day: a window never claims to cover hours that have not
+// happened yet.
+func analyticsSpan(rng string, now time.Time) (from, to time.Time) {
+ to = now.UTC()
+ midnight := to.Truncate(24 * time.Hour)
+ switch rng {
+ case range7d:
+ return midnight.AddDate(0, 0, -6), to
+ case range30d:
+ return midnight.AddDate(0, 0, -29), to
+ default:
+ return midnight, to
+ }
+}
+
+// chartWidth and chartHeight are the plot's viewBox units — not pixels: the
+// SVG scales to whatever column it lands in, so these fix only the aspect
+// and the resolution of the geometry.
+const (
+ chartWidth = 480.0
+ chartHeight = 120.0
+)
+
+// readAnalytics assembles the window. It resolves the setting first and then
+// reads whichever way that came out: numbers already collected are still
+// shown after an owner turns counting off, because they were collected while
+// it was on and hiding them would read as data loss.
+func (s *Server) readAnalytics(ctx context.Context, sqid, app, rng string, now time.Time) (analyticsWindow, error) {
+ out := analyticsWindow{Range: rng}
+ res, err := telemetry.Resolve(ctx, s.cfg.Releases, sqid, app)
+ if err != nil {
+ return out, err
+ }
+ out.Setting, out.On = res, res.Count == telemetry.CountEdge
+ // No deployment bucket on this host: there is nothing to read, and
+ // ReadRange would be handed a nil store. The resolved defaults above are
+ // still the honest answer to "is counting on".
+ if s.cfg.Releases == nil {
+ return out, nil
+ }
+ from, to := analyticsSpan(rng, now)
+ sum, err := analytics.ReadRange(ctx, s.cfg.Releases, sqid, app, from, to)
+ if err != nil {
+ return out, err
+ }
+ out.Sum = sum
+ vals, labels := chartSeries(rng, sum)
+ out.ChartBars, out.ChartMax = chartBars(vals, labels, chartWidth, chartHeight)
+ return out, nil
+}
+
+// chartSeries picks what the bars plot: today is the 24 hourly page-view
+// counts, a multi-day window is one bar per day. A day the range covers but
+// no box wrote a file for is a zero rather than a gap — ReadRange returns
+// one DaySummary per date either way, so the axis stays evenly spaced.
+func chartSeries(rng string, sum analytics.Summary) (vals []int64, labels []string) {
+ if rng == rangeToday {
+ if len(sum.Days) == 0 {
+ return nil, nil
+ }
+ // The last row, not the first: today's span is one date, and taking
+ // the tail keeps this right if a caller ever passes a wider one.
+ d := sum.Days[len(sum.Days)-1]
+ for h := 0; h < 24; h++ {
+ vals = append(vals, d.Hours[h])
+ labels = append(labels, fmt.Sprintf("%02d", h))
+ }
+ return vals, labels
+ }
+ for _, d := range sum.Days {
+ vals = append(vals, d.PV)
+ labels = append(labels, dayLabel(d.Date))
+ }
+ return vals, labels
+}
+
+// dayLabel shortens a UTC date to MM-DD: thirty bars cannot each carry a
+// year, and the window's own dates are printed in full beside the chart.
+func dayLabel(date string) string {
+ if len(date) == len("2006-01-02") {
+ return date[5:]
+ }
+ return date
+}
+
+// chartBars scales vals onto a w x h box: one column per reading, two units
+// of gap between columns, height proportional to the largest reading in the
+// window — returned as the second value, so the template can say what the
+// peak was rather than leaving the axis unlabelled.
+//
+// Nothing to plot — no readings, or nothing but zeros — returns no bars at
+// all: a row of flat rectangles under a "peak 0" caption is noise, and the
+// template renders no chart rather than an empty one. Coordinates are
+// rounded to two decimals for the reason sparkPoints rounds its own: the
+// trailing digits of a binary fraction are bytes on every page view that
+// change nothing anyone can see.
+func chartBars(vals []int64, labels []string, w, h float64) ([]chartBar, int64) {
+ var max int64
+ for _, v := range vals {
+ if v > max {
+ max = v
+ }
+ }
+ if len(vals) == 0 || max <= 0 {
+ return nil, max
+ }
+ step := w / float64(len(vals))
+ bw := step - 2
+ if bw <= 0 {
+ bw = step // more columns than gaps will fit: draw them touching
+ }
+ out := make([]chartBar, 0, len(vals))
+ for i, v := range vals {
+ b := chartBar{Value: v, X: round2(float64(i) * step), W: round2(bw)}
+ if i < len(labels) {
+ b.Label = labels[i]
+ }
+ if v > 0 {
+ b.H = round2(float64(v) / float64(max) * h)
+ }
+ b.Y = round2(h - b.H)
+ out = append(out, b)
+ }
+ return out, max
+}
+
+func round2(f float64) float64 { return math.Round(f*100) / 100 }
+
+// kv is one row of a top-keys table.
+type kv struct {
+ Key string
+ Value int64
+}
+
+// sortedKV orders a top-keys map for rendering: biggest first, ties broken
+// by key so two runs of the same data render the same bytes, and the
+// overflow row (analytics.OtherKey — "everything past the cap", not a page
+// anybody visited) pinned to the bottom whatever its count is. It is a
+// template func because a Go template ranges a map in KEY order, which for a
+// top-pages table is the one order that hides the answer.
+func sortedKV(m map[string]int64) []kv {
+ out := make([]kv, 0, len(m))
+ for k, v := range m {
+ out = append(out, kv{Key: k, Value: v})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if a, b := out[i].Key == analytics.OtherKey, out[j].Key == analytics.OtherKey; a != b {
+ return b
+ }
+ if out[i].Value != out[j].Value {
+ return out[i].Value > out[j].Value
+ }
+ return out[i].Key < out[j].Key
+ })
+ return out
+}
+
+// handleAppAnalytics renders the Analytics tab: the same read the API
+// answers, with the window as a GET param so a view is a URL.
+func (s *Server) handleAppAnalytics(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "analytics")
+ if !ok {
+ return
+ }
+ // A page never 400s over its own query string: an unknown range falls
+ // back to today, the way the Errors tab falls back to its default window
+ // rather than refusing a URL someone mistyped.
+ p.AnalyticsRange = r.URL.Query().Get("range")
+ if !validRange(p.AnalyticsRange) {
+ p.AnalyticsRange = rangeToday
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ win, err := s.readAnalytics(ctx, acct.Sqid, p.AppName, p.AnalyticsRange, time.Now())
+ if err != nil {
+ log.Printf("console: app analytics %s/%s: %v", acct.Sqid, p.AppName, err)
+ p.AnalyticsErr = "The analytics could not be read just now — try again in a moment."
+ // A failed read leaves no resolved setting, and the page must not
+ // then say counting is off "from the " with nothing after it. The
+ // defaults are what a nil store resolves to, so ask for them rather
+ // than restating them here.
+ win.Setting, _ = telemetry.Resolve(ctx, nil, acct.Sqid, p.AppName)
+ }
+ p.AnalyticsWin = win
+ s.render(w, http.StatusOK, "app_analytics", p)
+}
diff --git a/internal/console/apianalytics.go b/internal/console/apianalytics.go
new file mode 100644
index 000000000..93a0fd399
--- /dev/null
+++ b/internal/console/apianalytics.go
@@ -0,0 +1,94 @@
+// The CLI's analytics API — the read side of `carlos analytics` (spec
+// 2026-09-07 §3.6). It shares the instance family's gate (instanceGate)
+// exactly as the errors and logs reads do: same principal proof, same
+// byte-identical 404, same rate limiter — and an app token may read its own
+// app's counts, which is the trust those two doors already extend it.
+package console
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/analytics"
+)
+
+// analyticsResponse is the wire shape `carlos analytics` renders and the tab
+// reads from the same assembly. The setting travels with the numbers because
+// "0 page views" and "counting is off" are different answers and a terminal
+// has no tab beside it to say which one it is looking at.
+type analyticsResponse struct {
+ Range string `json:"range"`
+ From string `json:"from"`
+ To string `json:"to"`
+ On bool `json:"on"`
+ Count string `json:"count"`
+ CountFrom string `json:"count_from"`
+ PV int64 `json:"pv"`
+ Visitors int64 `json:"visitors"`
+ Bytes int64 `json:"bytes"`
+ Bots int64 `json:"bots"`
+ Days []analytics.DaySummary `json:"days"`
+ Pages map[string]int64 `json:"pages"`
+ Referers map[string]int64 `json:"referers"`
+ Events map[string]int64 `json:"events"`
+ PagesApprox bool `json:"pages_approx"`
+ ReferersApprox bool `json:"referers_approx"`
+ EventsApprox bool `json:"events_approx"`
+ Dropped analytics.Dropped `json:"dropped"`
+}
+
+// handleAPIAnalytics answers `carlos analytics`: the instance family's gate,
+// one validated range, one read.
+func (s *Server) handleAPIAnalytics(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ // An absent range is the tab's default; a NAMED range that is not one of
+ // the three is a mistake the API says out loud, where the page quietly
+ // falls back — a script asking for a fortnight of counts must not be
+ // handed today's and told nothing.
+ rng := r.URL.Query().Get("range")
+ if rng == "" {
+ rng = rangeToday
+ }
+ if !validRange(rng) {
+ jsonError(w, http.StatusBadRequest, "range: want today, 7d or 30d")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ win, err := s.readAnalytics(ctx, acct.Sqid, app, rng, time.Now())
+ if err != nil {
+ log.Printf("console: api analytics %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the analytics")
+ return
+ }
+ sum := win.Sum
+ resp := analyticsResponse{
+ Range: rng, From: sum.From, To: sum.To,
+ On: win.On, Count: win.Setting.Count, CountFrom: win.Setting.CountFrom,
+ PV: sum.PV, Visitors: sum.Visitors, Bytes: sum.Bytes, Bots: sum.Bots,
+ Days: sum.Days, Pages: sum.Pages, Referers: sum.Referers, Events: sum.Events,
+ PagesApprox: sum.PagesApprox, ReferersApprox: sum.ReferersApprox, EventsApprox: sum.EventsApprox,
+ Dropped: sum.Dropped,
+ }
+ // "Nothing counted" is an empty object, not null — the same shape
+ // errorsResponse gives its groups, so a caller may index the answer
+ // without first testing it for null.
+ if resp.Days == nil {
+ resp.Days = []analytics.DaySummary{}
+ }
+ if resp.Pages == nil {
+ resp.Pages = map[string]int64{}
+ }
+ if resp.Referers == nil {
+ resp.Referers = map[string]int64{}
+ }
+ if resp.Events == nil {
+ resp.Events = map[string]int64{}
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
diff --git a/internal/console/apianalytics_test.go b/internal/console/apianalytics_test.go
new file mode 100644
index 000000000..4435324c7
--- /dev/null
+++ b/internal/console/apianalytics_test.go
@@ -0,0 +1,184 @@
+package console
+
+// The analytics switch, at both ends: the CLI's telemetry PUT (which now
+// accepts the analytics half and bumps the serial, because a box acts on
+// it) and the two settings pages' owner-only forms.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/analytics"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+func TestAPITelemetryPutSetsAnalytics(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-analytics-set")
+ op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
+ put := func(body string) (int, string) {
+ st, _, b := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, body)
+ return st, b
+ }
+ if st, b := put(`{"analytics":{"count":"edge"}}`); st != 200 {
+ t.Fatalf("count=edge: %d %s", st, b)
+ }
+ r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
+ if r.Count != "edge" || r.CountFrom != "app" || r.Door != "on" {
+ t.Fatalf("resolved = %+v", r)
+ }
+ if st, _ := put(`{"analytics":{"count":"browser"}}`); st != 400 {
+ t.Fatal("reserved mode must be refused for now")
+ }
+ if st, _ := put(`{"analytics":{"door":"sideways"}}`); st != 400 {
+ t.Fatal("bad door")
+ }
+ if st, _ := put(`{"analytics":{"door":"off"},"errors":{"log_level":"warn"}}`); st != 200 {
+ t.Fatal("both halves in one PUT")
+ }
+ r, _ = telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
+ if r.Door != "off" || r.LogLevel != "warn" || r.Count != "edge" {
+ t.Fatalf("after both: %+v", r)
+ }
+ // The serial moved: boxes converge on it.
+ if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 {
+ t.Fatalf("serial not bumped: %+v %v", doc, err)
+ }
+
+ // An errors-only PUT touches no box-acted-on setting, so it must not
+ // bump the serial: boxes would converge for no reason.
+ before, _, err := release.ReadSerial(context.Background(), fx.rel)
+ if err != nil {
+ t.Fatalf("serial before: %v", err)
+ }
+ if st, b := put(`{"errors":{"log_level":"error"}}`); st != 200 {
+ t.Fatalf("errors-only: %d %s", st, b)
+ }
+ after, _, err := release.ReadSerial(context.Background(), fx.rel)
+ if err != nil {
+ t.Fatalf("serial after: %v", err)
+ }
+ if after.Serial != before.Serial {
+ t.Fatalf("errors-only PUT bumped the serial: before %+v, after %+v", before, after)
+ }
+}
+
+func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-analytics-page")
+ fx.setUser("paul@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-analytics-signin")
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") {
+ t.Fatalf("app settings: %d", status)
+ }
+ // Post the account-level switch on; the app page shows it inherited.
+ status, _, _ = postForm(t, c, fx.u("/analytics/setting"), url.Values{"csrf": {sessionToken(t, fx, c)}, "count": {"edge"}, "door": {"on"}})
+ if status != 303 {
+ t.Fatalf("account switch: %d", status)
+ }
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ if !strings.Contains(body, "currently edge, from the account") {
+ t.Fatal("inheritance not shown")
+ }
+}
+
+// seedAnalytics writes one box's day file for the fixture's app: five page
+// views of /pricing from five addresses with a referer, one bot hit, and one
+// named event.
+//
+// The observations are stamped at MIDDAY UTC of the current day rather than
+// an hour before the run: an hour before a run that starts just after 00:00
+// UTC lands on YESTERDAY's day file, and range=today would then read an empty
+// window one hour in twenty-four. ReadRange is day-granular — `to` bounds
+// which DATES are read, never which hours inside them — so a stamp anywhere
+// in the current UTC day is counted whatever the wall clock says.
+func seedAnalytics(t *testing.T, fx *consoleFixture, when time.Time) {
+ t.Helper()
+ m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return when })
+ for i := 0; i < 5; i++ {
+ m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
+ ContentType: "text/html", Path: "/pricing", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari",
+ ClientIP: "203.0.113." + strconv.Itoa(i), Bytes: 1000, When: when})
+ }
+ m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
+ ContentType: "text/html", Path: "/", UA: "Googlebot/2.1", ClientIP: "1.1.1.1", Bytes: 500, When: when})
+ m.Track(release.OpsAccount, "titogo", "signup", when)
+ for k, ds := range m.Drain() {
+ for _, d := range ds {
+ if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+}
+
+// seedTime is the stamp seedAnalytics uses — midday of the current UTC day.
+func seedTime() time.Time { return time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) }
+
+func TestAPIAnalyticsSummary(t *testing.T) {
+ fx, token := instanceFixture(t, "code-analytics-api")
+ seedAnalytics(t, fx, seedTime())
+ status, _, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=today", token, "")
+ if status != 200 {
+ t.Fatalf("%d %s", status, body)
+ }
+ var resp analyticsResponse
+ if err := json.Unmarshal([]byte(body), &resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.PV != 5 || resp.Bots != 1 || resp.Bytes != 5500 || resp.Visitors < 4 || resp.Visitors > 6 ||
+ resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On {
+ t.Fatalf("resp = %+v", resp)
+ }
+ if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 {
+ t.Fatal("bad range")
+ }
+ if st, _, b := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=7d", token, ""); st != 200 {
+ t.Fatalf("7d: %d %s", st, b)
+ }
+ // Verify snake_case JSON tags
+ if !strings.Contains(body, "\"days\":[{\"date\":") {
+ t.Fatal("missing snake_case days array with date field")
+ }
+ if !strings.Contains(body, "\"pv\":") {
+ t.Fatal("missing snake_case pv field")
+ }
+ if strings.Contains(body, "\"PV\":") {
+ t.Fatal("found PascalCase PV field (should be pv)")
+ }
+}
+
+func TestAppAnalyticsPage(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-analytics-tab")
+ seedAnalytics(t, fx, seedTime())
+ fx.setUser("paul@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-analytics-tab-signin")
+ status, _, body := get(t, c, fx.u("/apps/titogo/analytics"))
+ if status != 200 {
+ t.Fatalf("%d %s", status, body)
+ }
+ // Off: the switch and the reading, and the numbers that exist already.
+ for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "<svg"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("page missing %q", want)
+ }
+ }
+ if err := telemetry.Update(context.Background(), fx.rel, telemetry.AppKey(fx.sqid, "titogo"), func(st *telemetry.Settings) {
+ st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge}
+ }); err != nil {
+ t.Fatal(err)
+ }
+ _, _, body = get(t, c, fx.u("/apps/titogo/analytics?range=7d"))
+ if strings.Contains(body, "Analytics is off") || !strings.Contains(body, "daily visitors, summed") || !strings.Contains(body, "carlos analytics --app titogo") {
+ t.Fatal("on + 7d page")
+ }
+}
diff --git a/internal/console/apierrors.go b/internal/console/apierrors.go
index 66332fa38..150159381 100644
--- a/internal/console/apierrors.go
+++ b/internal/console/apierrors.go
@@ -11,6 +11,7 @@ import (
"encoding/json"
"log"
"net/http"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/apperr"
@@ -56,9 +57,9 @@ func (s *Server) handleAPIErrors(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
-// handleAPITelemetryPut sets the app-level telemetry object: today the
-// errors log level; the analytics keys arrive with step 2. Operate only —
-// an app's own token must not widen what its owner sees.
+// handleAPITelemetryPut sets the app-level telemetry object: the errors log
+// level, the analytics keys, or both in one call. Operate only — an app's
+// own token must not widen what its owner sees.
func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) {
principal, acct, app, ok := s.instanceGate(w, r)
if !ok {
@@ -69,31 +70,86 @@ func (s *Server) handleAPITelemetryPut(w http.ResponseWriter, r *http.Request) {
// populate Role), which is why this reads the kind first.
p, _ := principalFrom(r.Context())
if p.Kind == PrincipalUser && acct.Role != RoleOwner {
- jsonError(w, http.StatusForbidden, "only an account owner can change the Errors log level")
+ jsonError(w, http.StatusForbidden, "only an account owner can change an app's telemetry settings")
return
}
var body telemetry.Settings
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
- jsonError(w, http.StatusBadRequest, "body: want {\"errors\":{\"log_level\":\"error|warn\"}}")
+ jsonError(w, http.StatusBadRequest, "body: want {\"errors\":{\"log_level\":\"error|warn\"}} or {\"analytics\":{\"count\":\"edge|off\",\"door\":\"on|off\"}}")
return
}
- if body.Errors == nil || !telemetry.ValidLogLevel(body.Errors.LogLevel) {
- jsonError(w, http.StatusBadRequest, "errors.log_level: want error or warn")
+ // Either half may be absent — a caller setting the log level must not
+ // have to restate the analytics keys, and vice versa — but a body that
+ // sets nothing is a mistake worth naming rather than a silent no-op.
+ if body.Errors == nil && body.Analytics == nil {
+ jsonError(w, http.StatusBadRequest, "nothing to set: want errors, analytics or both")
return
}
- if body.Analytics != nil {
- jsonError(w, http.StatusBadRequest, "analytics: not settable yet")
+ if body.Errors != nil && !telemetry.ValidLogLevel(body.Errors.LogLevel) {
+ jsonError(w, http.StatusBadRequest, "errors.log_level: want error or warn")
return
}
+ if a := body.Analytics; a != nil {
+ if a.Count == "" && a.Door == "" {
+ jsonError(w, http.StatusBadRequest, "analytics: want count, door or both")
+ return
+ }
+ // An empty key means "leave this one alone", so only a value that is
+ // present is checked — and "browser", the mode step 3 reserves, is
+ // not one of them yet: a setting no box acts on must not be storable.
+ if a.Count != "" && !validCount(a.Count) {
+ jsonError(w, http.StatusBadRequest, "analytics.count: want edge or off")
+ return
+ }
+ if a.Door != "" && !validDoor(a.Door) {
+ jsonError(w, http.StatusBadRequest, "analytics.door: want on or off")
+ return
+ }
+ }
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
if err := telemetry.Update(ctx, s.cfg.Releases, telemetry.AppKey(acct.Sqid, app), func(st *telemetry.Settings) {
- st.Errors = &telemetry.Errors{LogLevel: body.Errors.LogLevel}
+ if body.Errors != nil {
+ st.Errors = &telemetry.Errors{LogLevel: body.Errors.LogLevel}
+ }
+ if body.Analytics != nil {
+ mergeAnalytics(st, body.Analytics.Count, body.Analytics.Door)
+ }
}); err != nil {
log.Printf("console: api telemetry %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusBadGateway, "could not write the setting")
return
}
- s.audit(acct, app, principal, "errors-level", "set the Errors log level to "+body.Errors.LogLevel)
- writeJSON(w, http.StatusOK, map[string]string{"log_level": body.Errors.LogLevel})
+ // The audit row names what THIS call changed: a PUT that moved the log
+ // level and a PUT that turned counting on must not read alike in the
+ // activity feed, and a half nobody sent must not be claimed as set.
+ resp, action, changed := map[string]string{}, "", []string(nil)
+ if body.Errors != nil {
+ action = "errors-level"
+ resp["log_level"] = body.Errors.LogLevel
+ changed = append(changed, "the Errors log level to "+body.Errors.LogLevel)
+ }
+ if body.Analytics != nil {
+ if action == "" {
+ action = "analytics-setting"
+ } else {
+ action = "telemetry" // both halves in one call
+ }
+ if c := body.Analytics.Count; c != "" {
+ resp["count"] = c
+ changed = append(changed, "analytics counting to "+c)
+ }
+ if d := body.Analytics.Door; d != "" {
+ resp["door"] = d
+ changed = append(changed, "the analytics browser door to "+d)
+ }
+ }
+ s.audit(acct, app, principal, action, "set "+strings.Join(changed, " and "))
+ if body.Analytics != nil {
+ // The analytics switch is BOX-consumed: the edge counts or does not,
+ // so every box has to hear about the change rather than wait for the
+ // hourly floor. The errors level is console-read and needs no bump.
+ s.bumpSerial(ctx)
+ }
+ writeJSON(w, http.StatusOK, resp)
}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 88e10b135..ef15687ea 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -598,6 +598,12 @@ func (s *Server) registerCLIRoutes() {
// and an app token may read its own app's errors for the same reason it
// may read its own logs.
s.cliRoute("GET /api/cli/apps/{account}/{app}/errors", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIErrors)
+ // The analytics read (`carlos analytics`, apianalytics.go) — the third of
+ // the same family, on the same footing: an app token that may read every
+ // line of its own logs is not learning anything new from a count of its
+ // own page views. The SETTING that decides whether those counts exist is
+ // the telemetry PUT below, and that stays Operate-only.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/analytics", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAnalytics)
// The telemetry SETTINGS write, Operate only and deliberately narrower
// than the read beside it: this decides what counts as an error for the
// app's owner, and a credential the app itself holds must not be able to
diff --git a/internal/console/server.go b/internal/console/server.go
index e30629be7..f9a80409a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -123,6 +123,12 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{
// (logsview.go). The padding is computed across the whole window, so it
// has to be one call over all the records, not one per row.
"logLines": logLines,
+ // sortedKV: the Analytics tab's three top-key tables (analyticsview.go).
+ // A Go template ranges a map in key order, which would sort a top-pages
+ // table alphabetically — the one order that hides which page is the
+ // popular one. This puts the biggest first and leaves the overflow
+ // bucket last, wherever its count happens to land.
+ "sortedKV": sortedKV,
// bytesOrDash renders a byte count in the smallest unit that keeps it
// readable; nil (no reading, from a unit that reported CPU but not
// memory/IO that tick) is "—", never a fabricated 0 B.
@@ -1039,6 +1045,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)
s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings)
s.mux.HandleFunc("POST /a/{sqid}/errors/level", s.handleAccountErrorLevel)
+ s.mux.HandleFunc("POST /a/{sqid}/analytics/setting", s.handleAccountAnalyticsSetting)
// Go 1.22's mux prefers this literal over apps/{app} below — exactly why
// "new" must be a reserved app name (claimApp): an app so named could
// never be viewed.
@@ -1054,6 +1061,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// (CSRF, then owner, then the byte-identical 404).
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/errors", s.handleAppErrors)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/errors/level", s.handleAppErrorLevel)
+ // The Analytics tab (analyticsview.go) and the switch beside it. The tab
+ // is member-readable like every other app tab; the switch rides the same
+ // gate the errors level does at both levels, and bumps the serial where
+ // the errors level does not: a box acts on whether the edge counts.
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/analytics", s.handleAppAnalytics)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/analytics/setting", s.handleAppAnalyticsSetting)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)
// The pipeline settings page (pipelinepage.go, release pipelines v2 §5):
@@ -1404,6 +1417,23 @@ type page struct {
ErrLevelSetting telemetry.Resolved
ErrLevelUnread bool
+ // AnalyticsSetting is the same resolved object, under the name the
+ // Analytics section of those two pages reads it by — telemetry.Resolve
+ // returns both halves at once, so the pages fill these from the ONE call
+ // they already make. AnalyticsUnread carries the same refusal to show
+ // defaults as if they were the setting.
+ AnalyticsSetting telemetry.Resolved
+ AnalyticsUnread bool
+
+ // App Analytics (analyticsview.go's tab, apianalytics.go's sibling): the
+ // assembled window plus the range the form re-renders with. A zero
+ // AnalyticsWin with AnalyticsErr "" is the normal nothing-counted-yet
+ // state, never an error — and the window carries its own resolved
+ // setting, because the tab has to say whether counting is on.
+ AnalyticsWin analyticsWindow
+ AnalyticsRange string // "today" | "7d" | "30d"
+ AnalyticsErr string
+
// Action confirm interstitial (confirm.go): what is about to happen,
// carried fields and all. Nil everywhere except the confirm page.
Confirm *ConfirmView
@@ -2397,11 +2427,14 @@ func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) {
// is said rather than defaulted (see ErrLevelUnread), and never fails
// the page.
if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, ""); err != nil {
- log.Printf("console: account errors level %s: %v", acct.Sqid, err)
+ log.Printf("console: account telemetry %s: %v", acct.Sqid, err)
p.ErrLevelUnread = true
} else {
p.ErrLevelSetting = res
}
+ // The analytics switch is the OTHER half of that same resolved object —
+ // one settings file, one read — under the name its own section uses.
+ p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread
s.render(w, http.StatusOK, "account_settings", p)
}
@@ -2800,11 +2833,14 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
// rather than defaulted (see ErrLevelUnread), and never fails a page
// that is mostly about something else.
if res, err := telemetry.Resolve(r.Context(), s.cfg.Releases, acct.Sqid, p.AppName); err != nil {
- log.Printf("console: errors level %s/%s: %v", acct.Sqid, p.AppName, err)
+ log.Printf("console: telemetry %s/%s: %v", acct.Sqid, p.AppName, err)
p.ErrLevelUnread = true
} else {
p.ErrLevelSetting = res
}
+ // The analytics switch is the OTHER half of that same resolved object —
+ // one settings file, one read — under the name its own section uses.
+ p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread
s.render(w, http.StatusOK, "app_settings", p)
}
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index b11f70e9a..921ac1b17 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -293,9 +293,20 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) {
// gains nothing by reading the error-level ones grouped. The telemetry PUT
// registered beside it is Operate-only and no instance credential reaches it
// — TestAPITelemetryPutSetsLogLevel pins that refusal.
+// GET .../analytics is the ninth (spec 2026-09-07 §3.6), and it stands on
+// the errors door's footing exactly. It did not exist before service
+// principals, so listing it in `want` would make the legacy map lie. The
+// instance role reaches it DELIBERATELY, for the reason the logs door in
+// `want` is reached: the counts are the app's own traffic, aggregated and
+// stripped of addresses, and a credential that may read every line of its
+// own app's logs is not learning anything new from a tally of its own page
+// views. The telemetry PUT that decides whether those counts are collected
+// at all is Operate-only and no instance credential reaches it —
+// TestAPITelemetryPutSetsAnalytics pins that half.
var exemptFromLegacyPin = map[string]bool{
"GET /api/cli/apps": true,
"GET /api/cli/apps/{account}/{app}/errors": true,
+ "GET /api/cli/apps/{account}/{app}/analytics": true,
"GET /api/cli/apps/{account}/{app}/domains/{host}": true,
"PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
"GET /api/cli/apps/{account}/{app}/features": true,
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html
index 55237a630..e0f10a9c6 100644
--- a/internal/console/templates/account_settings.html
+++ b/internal/console/templates/account_settings.html
@@ -153,6 +153,29 @@ overrides. Widening one look to warn is always available on an app&rsquo;s Error
nothing here.</p>
{{end}}
+<h2>Analytics</h2>
+{{if .AnalyticsUnread}}
+<p class="small">This account&rsquo;s analytics setting could not be read just now. Reload before
+concluding anything.</p>
+{{else}}
+<form class="signin" method="post" action="{{.Base}}/analytics/setting">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label>Count page views at the edge:
+ <select name="count">
+ <option value="off"{{if eq .AnalyticsSetting.Count "off"}} selected{{end}}>off</option>
+ <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option>
+ </select>
+ </label>
+ <button type="submit">Save</button>
+</form>
+<p class="small">Currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}
+&mdash; for every app without its own setting, which an app&rsquo;s own Settings tab overrides.</p>
+{{end}}
+<p class="small">When on, the edge counts HTML page views and daily visitors as it serves them: no
+JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes every day and cannot be
+joined across days. Whether that needs consent where your visitors are is your call, which is why it
+is off until you turn it on.</p>
+
<h2>Delete this account</h2>
{{if .HasApps}}
<p class="small">This account has apps, so it can't be deleted. Delete or move the apps first;
diff --git a/internal/console/templates/app_analytics.html b/internal/console/templates/app_analytics.html
new file mode 100644
index 000000000..9ebee65d3
--- /dev/null
+++ b/internal/console/templates/app_analytics.html
@@ -0,0 +1,45 @@
+{{define "app_analytics"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+<h2>Analytics</h2>
+{{if .AnalyticsErr}}<p class="banner error">{{.AnalyticsErr}}</p>{{end}}
+{{if not .AnalyticsWin.On}}
+<p><strong>Analytics is off for this app</strong> (from the {{.AnalyticsWin.Setting.CountFrom}}).
+When on, the edge counts HTML page views and daily visitors as it serves them: no JavaScript, no cookie,
+no stored IP. A visitor is a salted hash that changes every day. Turn it on for this app or for the whole
+account on the <a href="{{.Base}}/apps/{{.AppName}}/settings">Settings</a> tab.</p>
+{{end}}
+<form method="get" class="small">
+ <label>range
+ <select name="range">
+ <option value="today"{{if eq .AnalyticsRange "today"}} selected{{end}}>today</option>
+ <option value="7d"{{if eq .AnalyticsRange "7d"}} selected{{end}}>7 days</option>
+ <option value="30d"{{if eq .AnalyticsRange "30d"}} selected{{end}}>30 days</option>
+ </select>
+ </label>
+ <button type="submit">Update</button>
+</form>
+{{with .AnalyticsWin}}
+<div class="tablewrap"><table>
+ <tr><th>Page views</th><th>Visitors{{if ne $.AnalyticsRange "today"}} (daily visitors, summed){{end}}</th><th>Bytes served</th><th>Bots excluded</th><th>Dropped</th></tr>
+ <tr><td>{{.Sum.PV}}</td><td>{{.Sum.Visitors}}</td><td>{{.Sum.Bytes}}</td><td>{{.Sum.Bots}}</td><td>{{.Sum.Dropped.Flush}}</td></tr>
+</table></div>
+{{if .ChartBars}}
+<svg viewBox="0 0 480 140" width="100%" role="img" aria-label="Page views">
+ {{range .ChartBars}}<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}" fill="currentColor"><title>{{.Label}}: {{.Value}}</title></rect>{{end}}
+ <text x="0" y="136" class="small">{{if eq $.AnalyticsRange "today"}}per hour, UTC{{else}}per day{{end}}; peak {{.ChartMax}}</text>
+</svg>
+{{end}}
+{{range .Sum.Days}}{{if .Restarted}}<p class="small">{{.Date}}: counts since the last flush may be missing (the edge restarted that day).</p>{{end}}{{end}}
+<h3>Top pages{{if .Sum.PagesApprox}} <span class="small">approximate: more than 500 distinct keys</span>{{end}}</h3>
+{{template "kvtable" .Sum.Pages}}
+<h3>Top referers{{if .Sum.ReferersApprox}} <span class="small">approximate</span>{{end}}</h3>
+{{template "kvtable" .Sum.Referers}}
+<h3>Named events{{if .Sum.EventsApprox}} <span class="small">approximate</span>{{end}}</h3>
+{{template "kvtable" .Sum.Events}}
+{{end}}
+<p class="small">Times are UTC. The same view from a terminal: <code>carlos analytics --app {{.AppName}} --range {{.AnalyticsRange}}</code>.</p>
+{{template "bottom" .}}{{end}}
+{{define "kvtable"}}{{if .}}<div class="tablewrap"><table>{{range sortedKV .}}<tr><td>{{.Key}}</td><td>{{.Value}}</td></tr>{{end}}</table></div>{{else}}<p class="small">Nothing yet.</p>{{end}}{{end}}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index ce8245a86..f14df31e8 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -189,6 +189,31 @@ change it.</p>
<p class="small">The <a href="{{.Base}}/apps/{{.Detail.App}}/errors">Errors tab</a> always lets you
widen one look to warn without changing this.</p>
+<h2>Analytics</h2>
+{{if .AnalyticsUnread}}
+<p class="small">This app&rsquo;s analytics setting could not be read just now. Reload before
+concluding anything.</p>
+{{else if .IsOwner}}
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/analytics/setting" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label>Count page views at the edge:
+ <select name="count">
+ <option value="off"{{if eq .AnalyticsSetting.Count "off"}} selected{{end}}>off</option>
+ <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option>
+ </select>
+ </label>
+ <button type="submit" class="btn-sm">Save</button>
+ <span class="small">currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}</span>
+</form>
+{{else}}
+<p class="small">Analytics counting is <code>{{.AnalyticsSetting.Count}}</code> for this app, from
+the {{.AnalyticsSetting.CountFrom}}. Only the account owner can change it.</p>
+{{end}}
+<p class="small">When on, the edge counts HTML page views and daily visitors as it serves them: no
+JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes every day and cannot be
+joined across days. Whether that needs consent where your visitors are is your call, which is why it
+is off until you turn it on.</p>
+
{{if .Settings}}{{with .Settings.Features}}
<h2>Feature flags</h2>
{{if .Flags}}
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html
index c19afaaf7..6ab0ca34e 100644
--- a/internal/console/templates/appnav.html
+++ b/internal/console/templates/appnav.html
@@ -9,6 +9,7 @@
<a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a>
<a href="{{.Nav}}/apps/{{.AppName}}/logs"{{if eq .ActiveTab "logs"}} aria-current="page"{{end}}>Logs</a>
<a href="{{.Nav}}/apps/{{.AppName}}/errors"{{if eq .ActiveTab "errors"}} aria-current="page"{{end}}>Errors</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/analytics"{{if eq .ActiveTab "analytics"}} aria-current="page"{{end}}>Analytics</a>
<a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
<a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
</nav>
diff --git a/internal/edge/analytics.go b/internal/edge/analytics.go
new file mode 100644
index 000000000..16c1f1df9
--- /dev/null
+++ b/internal/edge/analytics.go
@@ -0,0 +1,127 @@
+package edge
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/analytics"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// AnalyticsMeter is where the edge files one observation per response
+// for an app whose analytics are switched on (spec §3.3). analytics.Meter
+// satisfies it; the interface keeps the dependency on the contract.
+type AnalyticsMeter interface {
+ Observe(analytics.Obs)
+}
+
+type analyticsMeterRef struct{ AnalyticsMeter }
+
+// EnableAnalytics wires the meter in; nil is a valid no-op.
+func (e *Edge) EnableAnalytics(m AnalyticsMeter) {
+ if m == nil {
+ e.analyticsMeter.Store(nil)
+ return
+ }
+ e.analyticsMeter.Store(&analyticsMeterRef{m})
+}
+
+// SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The
+// host's telemetry pass pushes it on every serial-triggered full pass and
+// every five minutes (spec §3.3, "converged by boxes on the next pass").
+// The map is COPIED before it is stored, so a caller may keep and reuse
+// the map it passed: the stored copy is read by every serving goroutine
+// without a lock, and a caller that went on writing to a shared map would
+// turn that into Go's fatal concurrent map read-and-write — an edge-wide
+// crash, not a race one recovers from.
+func (e *Edge) SetAnalyticsOn(on map[string]bool) {
+ if on == nil {
+ e.analyticsOn.Store(nil)
+ return
+ }
+ cp := make(map[string]bool, len(on))
+ for k, v := range on {
+ cp[k] = v
+ }
+ e.analyticsOn.Store(&cp)
+}
+
+func (e *Edge) analyticsEnabled(rt registry.Route) bool {
+ m := e.analyticsOn.Load()
+ if m == nil {
+ return false
+ }
+ return (*m)[release.RouteAccount(rt)+"/"+rt.App]
+}
+
+// withHopVerified marks ctx as a request that arrived over a proven mesh
+// hop, exactly as acceptHop does (homemap.go). Test-only in practice —
+// nothing on the serving path may put this mark on a request, which is the
+// whole property clientIP's X-Forwarded-For trust rests on.
+func withHopVerified(ctx context.Context) context.Context {
+ return context.WithValue(ctx, hopVerifiedKey{}, true)
+}
+
+// clientIP is the address the visitor hash uses (spec §3.3): the last
+// element of the verified inbound X-Forwarded-For chain on a proven hop,
+// otherwise RemoteAddr's host. X-Forwarded-For is read ONLY under a proven
+// hop, whose mark nothing outside acceptHop can put on a request; on every
+// other request the header is ignored whether it is present or not, so a
+// header a client set can never reach the visitor hash.
+func clientIP(r *http.Request) string {
+ if hopVerified(r.Context()) {
+ chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ")
+ if i := strings.LastIndexByte(chain, ','); i >= 0 {
+ chain = chain[i+1:]
+ }
+ if ip := strings.TrimSpace(chain); ip != "" {
+ return ip
+ }
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+// isPrefetchPurpose reports whether a Sec-Purpose (or legacy Purpose)
+// header value says this request is a prefetch. The value is a token list:
+// Chrome sends a bare "prefetch" for link prefetches and
+// "prefetch;prerender" for a speculation-rules prerender, which also
+// carries Sec-Fetch-Dest: document and so would otherwise be counted as a
+// pageview nobody looked at. Only the first token decides.
+func isPrefetchPurpose(v string) bool {
+ first, _, _ := strings.Cut(v, ";")
+ return strings.EqualFold(strings.TrimSpace(first), "prefetch")
+}
+
+// observeAnalytics files one Obs when the route's app is switched on. It
+// runs inside the observer defer in ServeHTTP, after the response has
+// completed, and reads only: the latched status and Content-Type off the
+// counting writer, the byte count, and the request's own headers. No
+// wrapper is added for it and no path here can wake an instance.
+func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64) {
+ ref := e.analyticsMeter.Load()
+ if ref == nil || !e.analyticsEnabled(rt) {
+ return
+ }
+ refHost := ""
+ if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
+ refHost = u.Hostname()
+ }
+ prefetch := isPrefetchPurpose(r.Header.Get("Sec-Purpose")) || isPrefetchPurpose(r.Header.Get("Purpose"))
+ ref.Observe(analytics.Obs{
+ Acct: release.RouteAccount(rt), App: rt.App, Host: rt.Host,
+ Method: r.Method, Status: ob.status, ContentType: ob.ctype,
+ Path: r.URL.Path, RefererHost: refHost, UA: r.Header.Get("User-Agent"),
+ ClientIP: clientIP(r), Bytes: bytes,
+ Prefetch: prefetch, FetchDest: strings.ToLower(r.Header.Get("Sec-Fetch-Dest")),
+ When: time.Now(),
+ })
+}
diff --git a/internal/edge/analytics_test.go b/internal/edge/analytics_test.go
new file mode 100644
index 000000000..33e46c3be
--- /dev/null
+++ b/internal/edge/analytics_test.go
@@ -0,0 +1,111 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/analytics"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type memAnalytics struct {
+ mu sync.Mutex
+ obs []analytics.Obs
+}
+
+func (m *memAnalytics) Observe(o analytics.Obs) { m.mu.Lock(); m.obs = append(m.obs, o); m.mu.Unlock() }
+func (m *memAnalytics) snap() []analytics.Obs {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return append([]analytics.Obs(nil), m.obs...)
+}
+
+func TestClientIP(t *testing.T) {
+ r := httptest.NewRequest("GET", "http://h/", nil)
+ r.RemoteAddr = "203.0.113.5:4444"
+ r.Header.Set("X-Forwarded-For", "198.51.100.1")
+ if ip := clientIP(r); ip != "203.0.113.5" {
+ t.Fatalf("unverified: %q", ip)
+ }
+ r2 := httptest.NewRequest("GET", "http://h/", nil)
+ r2 = r2.WithContext(withHopVerified(r2.Context()))
+ r2.RemoteAddr = "203.0.113.5:4444"
+ r2.Header.Set("X-Forwarded-For", "10.0.0.1, 198.51.100.7")
+ if ip := clientIP(r2); ip != "198.51.100.7" {
+ t.Fatalf("verified hop: %q", ip)
+ }
+}
+
+func TestEdgeObservesOnlyWhenSwitchedOn(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte("<h1>hi</h1>"))
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ rt := registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web"}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := &memAnalytics{}
+ e.EnableAnalytics(m)
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ get := func(path string, hdr map[string]string) {
+ req, _ := http.NewRequest("GET", srv.URL+path, nil)
+ req.Host = "app.example.com"
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ }
+ get("/", nil)
+ if len(m.snap()) != 0 {
+ t.Fatal("nothing is observed while the app is off")
+ }
+ on := map[string]bool{release.OpsAccount + "/web": true}
+ e.SetAnalyticsOn(on)
+ // The switch map is copied, so a caller may keep writing to its own:
+ // the edge reads its copy from every serving goroutine without a lock,
+ // and a shared map would be a fatal concurrent map read-and-write.
+ delete(on, release.OpsAccount+"/web")
+ on["scribble"] = true
+ get("/pricing?x=1", map[string]string{"Referer": "https://news.ycombinator.com/item?id=1", "User-Agent": "Mozilla/5.0 Safari", "Sec-Fetch-Dest": "document"})
+ obs := m.snap()
+ if len(obs) != 1 {
+ t.Fatalf("observed %d", len(obs))
+ }
+ o := obs[0]
+ if o.Acct != release.OpsAccount || o.App != "web" || o.Host != "app.example.com" || o.Method != "GET" || o.Status != 200 ||
+ o.ContentType != "text/html; charset=utf-8" || o.Path != "/pricing" || o.RefererHost != "news.ycombinator.com" ||
+ o.FetchDest != "document" || o.Bytes == 0 || o.ClientIP == "" || o.When.IsZero() {
+ t.Fatalf("obs = %+v", o)
+ }
+ get("/x", map[string]string{"Purpose": "prefetch"})
+ if !m.snap()[1].Prefetch {
+ t.Fatal("prefetch header must be carried")
+ }
+ // A speculation-rules prerender arrives as Sec-Purpose:
+ // prefetch;prerender with Sec-Fetch-Dest: document — a pageview
+ // nobody looked at unless the first token is matched.
+ get("/y", map[string]string{"Sec-Purpose": "prefetch;prerender", "Sec-Fetch-Dest": "document"})
+ if !m.snap()[2].Prefetch {
+ t.Fatal("prefetch;prerender is a prefetch")
+ }
+ e.SetAnalyticsOn(nil)
+ get("/", nil)
+ if len(m.snap()) != 3 {
+ t.Fatal("switching off stops observation")
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 1a372d47a..cbd40ad2f 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -125,6 +125,16 @@ type Edge struct {
// errSink, when set, receives edge-observed errors (an upstream 5xx)
// — spec 2026-09-07 §3.2. Nil disables emission; nothing else changes.
errSink atomic.Pointer[errorSinkRef]
+ // analyticsMeter, when set, receives one observation per completed
+ // response for an app whose analytics are switched on (analytics.go,
+ // spec 2026-09-07 §3.3), and analyticsOn is that switch map keyed
+ // sqid+"/"+app. Both are atomic for the same reason usageMeter is: the
+ // agent role arms the meter after construction, and the telemetry pass
+ // replaces the switch map while serving is underway. Nil meter or nil
+ // map = the edge observes nothing, which is every `carlos edge` box and
+ // every app nobody has switched on.
+ analyticsMeter atomic.Pointer[analyticsMeterRef]
+ analyticsOn atomic.Pointer[map[string]bool]
// tunnels, when non-nil, is the reverse-channel hub (tunnel.go): remote
// hosts register on its reserved hostname and routes with Tunnel set are
// proxied over the channel they name. nil = tunnel serving not configured
@@ -1305,6 +1315,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if ob.status >= 500 && (answered == nil || !*answered) {
e.emitEdgeError(up.route, r, ob.status)
}
+ e.observeAnalytics(up.route, r, ob, bytes)
}()
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 14d1385a2..79b9b90a4 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -165,6 +165,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/analytics"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/apperr"
"github.com/carlosframework/platform/internal/applog"
@@ -446,7 +447,14 @@ func RunAgent(args []string) {
if errSink != nil {
e.EnableErrors(errSink)
}
- go usageLoop(m.Drain)
+ // The analytics meter (spec 2026-09-07 §3.3): the edge counts
+ // pageviews per (account, app) while telemetryPass (fullPass and
+ // instancesLoop) keeps its switch map current, and usageLoop
+ // drains it into day objects on the same 5-minute tick as usage.
+ am := analytics.NewMeter(dataDir(), status.Label(readMachineID()), time.Now)
+ e.EnableAnalytics(am)
+ setAnalyticsSink(e.SetAnalyticsOn)
+ go usageLoop(m.Drain, am)
},
// Certificate issuance is a deployment-global duty, so it runs where
// the housekeeper runs (housekeeper.go). The gate goes DOWN into the
@@ -1506,6 +1514,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
if err := reconcileSerialized(ctx, store, reg, provisionTickConfig(os.Getenv)); err != nil {
log.Printf("agent: fast converge reconcile: %v", err)
}
+ telemetryPass(ctx, store, reg)
runMovesPass(ctx, store)
// Folded once per pass and handed in, never re-read inside the
// loop: this leg runs on every serial bump (a seconds cadence),
@@ -2005,6 +2014,7 @@ func instancesLoop(reg *registry.Registry) {
if err := reconcileSerialized(ctx, store, reg, cfg); err != nil {
log.Printf("agent: instances pass: %v", err)
}
+ telemetryPass(ctx, store, reg)
runMovesPass(ctx, store)
deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now())
if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil {
diff --git a/internal/host/telemetry_pass.go b/internal/host/telemetry_pass.go
new file mode 100644
index 000000000..1891c8d19
--- /dev/null
+++ b/internal/host/telemetry_pass.go
@@ -0,0 +1,69 @@
+// The analytics switch pass (spec 2026-09-07 §3.3): resolve
+// telemetry.json app → account → default for every (account, app) this
+// box serves and hand the edge a fresh switch map. Rides the serial-
+// triggered full pass and the five-minute instances loop, so a console
+// write lands within seconds and the hourly floor stands underneath.
+package host
+
+import (
+ "context"
+ "log"
+ "sync/atomic"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+var analyticsSink atomic.Pointer[func(map[string]bool)]
+
+// setAnalyticsSink registers where a resolved switch map goes (the edge's
+// SetAnalyticsOn), the way setUpstreamSource publishes the prober table.
+func setAnalyticsSink(fn func(map[string]bool)) { analyticsSink.Store(&fn) }
+
+// telemetrySwitches resolves the count switch for each distinct
+// (account, app) among the box's routes. Status routes carry no app and
+// are skipped. One Resolve (two Gets) per pair; a pair that fails to
+// resolve is left OFF and logged — off is the safe side of this switch.
+func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]bool, error) {
+ routes, err := reg.List()
+ if err != nil {
+ return nil, err
+ }
+ on := map[string]bool{}
+ seen := map[string]bool{}
+ for _, rt := range routes {
+ if rt.Kind == "status" || rt.App == "" {
+ continue
+ }
+ sqid := release.RouteAccount(rt)
+ key := sqid + "/" + rt.App
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ res, err := telemetry.Resolve(ctx, s, sqid, rt.App)
+ if err != nil {
+ log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err)
+ continue
+ }
+ if res.Count == telemetry.CountEdge {
+ on[key] = true
+ }
+ }
+ return on, nil
+}
+
+// telemetryPass resolves and pushes; errors are one log line each.
+func telemetryPass(ctx context.Context, s release.Store, reg *registry.Registry) {
+ sink := analyticsSink.Load()
+ if sink == nil {
+ return
+ }
+ on, err := telemetrySwitches(ctx, s, reg)
+ if err != nil {
+ log.Printf("agent: telemetry pass: %v", err)
+ return
+ }
+ (*sink)(on)
+}
diff --git a/internal/host/telemetry_pass_test.go b/internal/host/telemetry_pass_test.go
new file mode 100644
index 000000000..04b43afda
--- /dev/null
+++ b/internal/host/telemetry_pass_test.go
@@ -0,0 +1,38 @@
+package host
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/telemetry"
+)
+
+func TestTelemetrySwitchesResolvePerRoute(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := edgesTestRegistry(t)
+ for _, rt := range []registry.Route{
+ {Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"},
+ {Host: "b.example", Kind: "service", Account: "bab", App: "api", Status: "ready"},
+ {Host: "c.example", Kind: "static", Account: "zzz", App: "site", Status: "ready"},
+ {Host: "status.example", Kind: "status", Status: "ready"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ telemetry.Update(ctx, s, telemetry.AccountKey("bab"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge} })
+ telemetry.Update(ctx, s, telemetry.AppKey("bab", "api"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountOff} })
+ on, err := telemetrySwitches(ctx, s, reg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !on["bab/web"] || on["bab/api"] || on["zzz/site"] {
+ t.Fatalf("switches = %v", on)
+ }
+ if _, ok := on["/"]; ok {
+ t.Fatal("status routes have no app and must not appear")
+ }
+}
diff --git a/internal/host/usage_loop.go b/internal/host/usage_loop.go
index b3af91669..9ec1d81d8 100644
--- a/internal/host/usage_loop.go
+++ b/internal/host/usage_loop.go
@@ -7,14 +7,38 @@ import (
"os"
"time"
+ "github.com/carlosframework/platform/internal/analytics"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/usage"
)
// usageFlush drains the edge meter and CAS-merges the deltas into the box's
-// usage/edge day object. One flush; the loop below owns cadence.
-func usageFlush(ctx context.Context, s release.Store, box string, drain func() map[string]usage.EdgeRow, now time.Time) error {
- return usage.UpsertEdgeDay(ctx, s, box, now, drain())
+// usage/edge day object, then (when am is non-nil) drains the analytics
+// meter's deltas into their app-day objects (spec §3.3: the same tick, a
+// second CAS write per app). Usage writes go to s (rebased under
+// CARLOS_STATUS_PREFIX on a fleet box); analytics writes go to raw — a day
+// file lives under apps/<sqid>/<app>/analytics/, the app's own data, and
+// must NOT be rebased (same reasoning as log shipping — logship.go:54-61).
+// A failed analytics batch is re-queued for the next tick; a failed usage
+// write is returned but does not block the analytics flush.
+func usageFlush(ctx context.Context, s, raw release.Store, box string, drain func() map[string]usage.EdgeRow, am *analytics.Meter, now time.Time) error {
+ err := usage.UpsertEdgeDay(ctx, s, box, now, drain())
+ if am == nil {
+ return err
+ }
+ failed := map[analytics.AppKey][]analytics.Delta{}
+ for k, ds := range am.Drain() {
+ for _, d := range ds {
+ if uerr := analytics.UpsertDay(ctx, raw, k.Acct, k.App, d); uerr != nil {
+ log.Printf("agent: analytics flush %s/%s %s: %v (re-queued)", k.Acct, k.App, d.Date, uerr)
+ failed[k] = append(failed[k], d)
+ }
+ }
+ }
+ if len(failed) > 0 {
+ am.Requeue(failed)
+ }
+ return err
}
// usageLoop flushes edge usage every 5 minutes, forever — the metricsLoop
@@ -23,8 +47,9 @@ func usageFlush(ctx context.Context, s release.Store, box string, drain func() m
// distinction at metrics_loop.go:773-779, rather than the brief's single log
// line, so the two loops read the same way in the logs), errors logged and
// never fatal (the drained window is lost, the next one is not; modelling
-// grade tolerates the gap).
-func usageLoop(drain func() map[string]usage.EdgeRow) {
+// grade tolerates the gap). am is the analytics meter (nil disables the
+// analytics half of the flush); its deltas are folded in on the same tick.
+func usageLoop(drain func() map[string]usage.EdgeRow, am *analytics.Meter) {
defer func() {
if r := recover(); r != nil {
log.Printf("agent: usage loop panic, edge metering stopped for this process: %v", r)
@@ -48,7 +73,7 @@ func usageLoop(drain func() map[string]usage.EdgeRow) {
log.Printf("agent: usage loop armed (5min flush), publishing to %s", target)
for {
time.Sleep(5 * time.Minute)
- if err := usageFlush(ctx, s, box, drain, time.Now()); err != nil {
+ if err := usageFlush(ctx, s, raw, box, drain, am, time.Now()); err != nil {
log.Printf("agent: usage flush: %v", err)
}
}
diff --git a/internal/host/usage_loop_test.go b/internal/host/usage_loop_test.go
index 9208f6f64..d8b5fabb3 100644
--- a/internal/host/usage_loop_test.go
+++ b/internal/host/usage_loop_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/analytics"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/usage"
)
@@ -17,10 +18,10 @@ func TestUsageFlushWritesAndReflushesMerge(t *testing.T) {
rows := map[string]usage.EdgeRow{
"a.example.com": {Account: "bab", App: "hello", Requests: 3, BytesOut: 300},
}
- if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at); err != nil {
+ if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return rows }, nil, at); err != nil {
t.Fatal(err)
}
- if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at.Add(5*time.Minute)); err != nil {
+ if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return rows }, nil, at.Add(5*time.Minute)); err != nil {
t.Fatal(err)
}
raw, _, err := s.Get(ctx, usage.EdgeDayKey("box1", "2026-08-11"))
@@ -38,7 +39,7 @@ func TestUsageFlushWritesAndReflushesMerge(t *testing.T) {
func TestUsageFlushEmptyDrainWritesNothing(t *testing.T) {
s := release.NewDirStore(t.TempDir())
- err := usageFlush(context.Background(), s, "box1", func() map[string]usage.EdgeRow { return nil }, time.Now().UTC())
+ err := usageFlush(context.Background(), s, s, "box1", func() map[string]usage.EdgeRow { return nil }, nil, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
@@ -46,3 +47,30 @@ func TestUsageFlushEmptyDrainWritesNothing(t *testing.T) {
t.Fatalf("wrote %v", keys)
}
}
+
+// TestUsageFlushAlsoFlushesAnalytics: the same tick that flushes edge usage
+// also drains the analytics meter into its app-day objects (spec §3.3). A
+// batch that fails to write is re-queued and lands on the next flush.
+func TestUsageFlushAlsoFlushesAnalytics(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
+ am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "h", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now})
+ if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil {
+ t.Fatal(err)
+ }
+ sum, err := analytics.ReadRange(ctx, s, "bab", "web", now, now)
+ if err != nil || sum.PV != 1 {
+ t.Fatalf("flushed day: %+v %v", sum, err)
+ }
+ // A failed analytics flush re-queues; the next flush lands it.
+ am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "h", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now})
+ _ = usageFlush(ctx, s, failingStore{Store: s}, "box1", func() map[string]usage.EdgeRow { return nil }, am, now)
+ if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil {
+ t.Fatal(err)
+ }
+ if sum, _ := analytics.ReadRange(ctx, s, "bab", "web", now, now); sum.PV != 2 {
+ t.Fatalf("after requeue: %d", sum.PV)
+ }
+}
diff --git a/main.go b/main.go
index 1a6ff88b0..4f8de37b9 100644
--- a/main.go
+++ b/main.go
@@ -81,6 +81,8 @@ func main() {
runLogs(args)
case "errors":
runErrors(args)
+ case "analytics":
+ runAnalytics(args)
case "channels":
runChannels(args)
case "pipeline":
@@ -200,6 +202,8 @@ Your apps — ship and run software on a CARLOS deployment
events (wakes, restarts, failures); -f follows live
errors error groups for an app: log lines at the set level plus
5xx the edge saw
+ analytics an app's page views and visitors from a terminal, counted at
+ the edge as it serves your app
ledger open ledgers: append | head | list | publish | blob | verify
vet check a release against the platform contract
update update this carlos binary to the latest release (or name