carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Analytics, step 2: counting at the edge — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** 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`.
6
7**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`.
8
9**Tech Stack:** Go 1.22+, `html/template`, inline SVG computed in Go, `release.Store` bucket, sqlite registry; no new module dependencies.
10
11**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.
12
13## Global Constraints
14
15- **Defaults (ruling):** `analytics.count` = `off`, `analytics.door` = `on`. Nothing is counted for an app until an account or app object says `count: edge`.
16- **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.
17- **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.
18- **clientIP(r)** = last element of the joined `X-Forwarded-For` when `hopVerified(ctx)`, else host of `RemoteAddr`.
19- **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.
20- **Page path keys** are `apperr.SanitizePath(path)`. Referer keys are host-only; empty → `direct`.
21- **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.
22- **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).
23- **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.
24- **Accumulation:** flushes ride the existing `usageLoop` tick (five minutes) as a second CAS write.
25- **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.
26- **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.
27- **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.
28- 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).
29- 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).
30- 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`.
31- 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.
32
33## File structure
34
35| File | Responsibility |
36|---|---|
37| `internal/analytics/hll.go` | `HLL` sketch: add, merge, count, encode/decode |
38| `internal/analytics/topk.go` | `TopK` Misra–Gries summary: add, merge, fold |
39| `internal/analytics/bots.go` | `IsBot(ua)` vendored substring list |
40| `internal/analytics/salt.go` | `DaySalt(dir, date)`: 0600 file, rotation |
41| `internal/analytics/day.go` | `Day` wire shape, keys, `Delta`, `Merge`, `UpsertDay` |
42| `internal/analytics/meter.go` | `Meter`: `Obs`, `Observe`, `Track`, `Drain`, `Requeue` |
43| `internal/analytics/read.go` | `ReadRange` across boxes, `Summary` |
44| `internal/edge/analytics.go` | `AnalyticsMeter` iface, `EnableAnalytics`, `SetAnalyticsOn`, `clientIP`, `observeAnalytics` |
45| `internal/edge/edge.go` | one call in the observer defer |
46| `internal/host/telemetry_pass.go` | resolve switches for the box's routes, push to the edge |
47| `internal/host/usage_loop.go`, `agent.go` | meter construction, flush on the usage tick, pass wiring |
48| `internal/console/analyticsview.go` | `readAnalytics`, chart points, page + settings handlers |
49| `internal/console/apianalytics.go` | `GET …/analytics`; `apierrors.go` PUT accepts analytics |
50| `internal/console/templates/app_analytics.html`, `appnav.html`, `app_settings.html`, `account_settings.html` | tab, nav, switches |
51| `analytics_cmd.go`, `apimode.go`, `main.go`, `docs/site/cli.md` | CLI |
52
53---
54
55### Task 1: HyperLogLog and Misra–Gries sketches
56
57**Files:**
58- Create: `internal/analytics/hll.go`, `internal/analytics/topk.go`
59- Test: `internal/analytics/hll_test.go`, `internal/analytics/topk_test.go`
60
61**Interfaces:**
62- Produces:
63 ```go
64 const HLLPrecision = 11; const hllRegisters = 1 << HLLPrecision
65 type HLL struct { reg [hllRegisters]uint8 }
66 func (h *HLL) Add(hash uint64)
67 func (h *HLL) Merge(o *HLL)
68 func (h *HLL) Count() float64
69 func (h *HLL) Empty() bool
70 func (h *HLL) Encode() string // base64(std) of [1, reg...]
71 func DecodeHLL(s string) (*HLL, error) // "" → empty sketch
72 const TopKSize = 500; const OtherKey = "_other"; const MaxKeyLen = 256
73 type TopK struct { counts map[string]int64; other int64 }
74 func NewTopK() *TopK
75 func (t *TopK) Add(key string, n int64)
76 func (t *TopK) Merge(o *TopK)
77 func (t *TopK) Fold() map[string]int64 // ≤500 keys plus OtherKey when other>0
78 func TopKFrom(m map[string]int64) *TopK // inverse of Fold (OtherKey → other)
79 func (t *TopK) Approximate() bool // other > 0
80 ```
81
82- [ ] **Step 1: Failing tests**
83
84`hll_test.go`:
85```go
86package analytics
87
88import (
89 "hash/fnv"
90 "math"
91 "testing"
92)
93
94func h64(s string) uint64 { f := fnv.New64a(); f.Write([]byte(s)); return f.Sum64() }
95
96func TestHLLEstimatesWithinThreeSigma(t *testing.T) {
97 for _, n := range []int{1000, 10000, 100000} {
98 var h HLL
99 for i := 0; i < n; i++ {
100 h.Add(h64("v" + itoa(i)))
101 }
102 got := h.Count()
103 sigma := 1.04 / math.Sqrt(hllRegisters) * float64(n)
104 if math.Abs(got-float64(n)) > 3*sigma {
105 t.Errorf("n=%d: estimate %.0f off by more than 3σ (%.0f)", n, got, 3*sigma)
106 }
107 }
108}
109
110func TestHLLMergeIsUnionAndIdempotent(t *testing.T) {
111 var a, b HLL
112 for i := 0; i < 5000; i++ {
113 a.Add(h64("a" + itoa(i)))
114 b.Add(h64("b" + itoa(i)))
115 }
116 var ab HLL
117 ab.Merge(&a)
118 ab.Merge(&b)
119 ab.Merge(&b) // idempotent
120 if got := ab.Count(); math.Abs(got-10000) > 3*(1.04/math.Sqrt(hllRegisters))*10000 {
121 t.Fatalf("union estimate %.0f", got)
122 }
123 var ba HLL
124 ba.Merge(&b)
125 ba.Merge(&a)
126 if ab.reg != ba.reg {
127 t.Fatal("merge must be commutative")
128 }
129}
130
131func TestHLLEncodeRoundTripAndVersion(t *testing.T) {
132 var h HLL
133 h.Add(h64("x"))
134 enc := h.Encode()
135 if enc[:2] != "AQ" { // base64 of a leading 0x01 byte
136 t.Fatalf("version prefix missing: %q", enc[:4])
137 }
138 d, err := DecodeHLL(enc)
139 if err != nil || d.reg != h.reg {
140 t.Fatalf("round trip: %v", err)
141 }
142 if e, err := DecodeHLL(""); err != nil || !e.Empty() {
143 t.Fatal("empty string must decode to an empty sketch")
144 }
145 if _, err := DecodeHLL("AgAA"); err == nil {
146 t.Fatal("unknown version must error")
147 }
148}
149
150func 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)) }
151```
152
153`topk_test.go`:
154```go
155package analytics
156
157import "testing"
158
159func TestTopKExactBelowCapAndBoundedAbove(t *testing.T) {
160 k := NewTopK()
161 for i := 0; i < 100; i++ {
162 k.Add("/p"+itoa(i), int64(i+1))
163 }
164 f := k.Fold()
165 if len(f) != 100 || f["/p99"] != 100 || k.Approximate() {
166 t.Fatalf("below cap must be exact: len=%d p99=%d approx=%v", len(f), f["/p99"], k.Approximate())
167 }
168 for i := 0; i < 2000; i++ {
169 k.Add("/q"+itoa(i), 1)
170 }
171 f = k.Fold()
172 if len(f) > TopKSize+1 || !k.Approximate() || f[OtherKey] == 0 {
173 t.Fatalf("above cap: len=%d approx=%v other=%d", len(f), k.Approximate(), f[OtherKey])
174 }
175 // Total mass is conserved between counters and _other.
176 var total int64
177 for _, v := range f {
178 total += v
179 }
180 if total != 100*101/2+2000 {
181 t.Fatalf("mass %d", total)
182 }
183 // A heavy hitter survives.
184 if f["/p99"] < 100-2000/TopKSize {
185 t.Fatalf("heavy hitter undercounted: %d", f["/p99"])
186 }
187}
188
189func TestTopKMergeRefolds(t *testing.T) {
190 a, b := NewTopK(), NewTopK()
191 for i := 0; i < 400; i++ {
192 a.Add("/a"+itoa(i), 2)
193 b.Add("/b"+itoa(i), 3)
194 }
195 a.Merge(b)
196 f := a.Fold()
197 if len(f) > TopKSize+1 || f[OtherKey] == 0 {
198 t.Fatalf("merged 800 keys must fold to ≤501: %d", len(f))
199 }
200 r := TopKFrom(f)
201 if r.other != f[OtherKey] || len(r.counts) != len(f)-1 {
202 t.Fatal("TopKFrom must invert Fold")
203 }
204 long := NewTopK()
205 long.Add(string(make([]byte, 300)), 1)
206 for key := range long.Fold() {
207 if len(key) > MaxKeyLen {
208 t.Fatal("keys must be capped at 256 bytes")
209 }
210 }
211}
212```
213
214- [ ] **Step 2: Run, expect compile failure**
215
216- [ ] **Step 3: Implement `hll.go`**
217
218```go
219// Package analytics is the pure half of the Analytics tab (spec
220// 2026-09-07-errors-and-analytics-design.md §3.3): the visitor and top-key
221// sketches, the in-memory meter the edge feeds, the day-file wire shape
222// and its idempotent CAS merge, the daily salt, the bot list and the
223// cross-box range read. internal/edge observes, internal/host flushes,
224// internal/console reads — the applog/apperr split.
225package analytics
226
227import (
228 "encoding/base64"
229 "errors"
230 "math"
231 "math/bits"
232)
233
234// HLLPrecision is the register-index width: 2^11 registers, 2 KB, ~2.3%
235// standard error (spec §3.3). One sketch per app per day per box.
236const HLLPrecision = 11
237const hllRegisters = 1 << HLLPrecision
238
239// HLL is a HyperLogLog cardinality sketch over 64-bit hashes.
240type HLL struct{ reg [hllRegisters]uint8 }
241
242// Add records one hashed element.
243func (h *HLL) Add(hash uint64) {
244 idx := hash >> (64 - HLLPrecision)
245 rest := hash<<HLLPrecision | 1<<(HLLPrecision-1) // guard bit bounds the run
246 rank := uint8(bits.LeadingZeros64(rest)) + 1
247 if rank > h.reg[idx] {
248 h.reg[idx] = rank
249 }
250}
251
252// Merge folds o into h by register max — the union of the two sets.
253func (h *HLL) Merge(o *HLL) {
254 for i := range h.reg {
255 if o.reg[i] > h.reg[i] {
256 h.reg[i] = o.reg[i]
257 }
258 }
259}
260
261// Empty reports whether nothing was added.
262func (h *HLL) Empty() bool {
263 for _, r := range h.reg {
264 if r != 0 {
265 return false
266 }
267 }
268 return true
269}
270
271// Count estimates the cardinality (Flajolet et al. 2007 with the small-
272// range linear-counting correction).
273func (h *HLL) Count() float64 {
274 m := float64(hllRegisters)
275 alpha := 0.7213 / (1 + 1.079/m)
276 var sum float64
277 zeros := 0
278 for _, r := range h.reg {
279 sum += math.Pow(2, -float64(r))
280 if r == 0 {
281 zeros++
282 }
283 }
284 est := alpha * m * m / sum
285 if est <= 2.5*m && zeros > 0 {
286 est = m * math.Log(m/float64(zeros))
287 }
288 return est
289}
290
291const hllVersion = 1
292
293// Encode renders the sketch as base64 of a version byte plus the registers.
294func (h *HLL) Encode() string {
295 b := make([]byte, 1+hllRegisters)
296 b[0] = hllVersion
297 copy(b[1:], h.reg[:])
298 return base64.StdEncoding.EncodeToString(b)
299}
300
301// DecodeHLL parses Encode's output; "" is an empty sketch, an unknown
302// version is an error (a future shape this build cannot read).
303func DecodeHLL(s string) (*HLL, error) {
304 h := &HLL{}
305 if s == "" {
306 return h, nil
307 }
308 b, err := base64.StdEncoding.DecodeString(s)
309 if err != nil {
310 return nil, err
311 }
312 if len(b) != 1+hllRegisters || b[0] != hllVersion {
313 return nil, errors.New("analytics: unknown visitors sketch version or size")
314 }
315 copy(h.reg[:], b[1:])
316 return h, nil
317}
318```
319
320- [ ] **Step 4: Implement `topk.go`**
321
322```go
323package analytics
324
325// TopKSize bounds a top-keys summary (pages, referers, events) per app
326// per day per box (spec §3.3). Misra–Gries: exact below the cap, a lower
327// bound within the summary's error above it, mergeable, with the evicted
328// mass kept in OtherKey so totals are conserved.
329const TopKSize = 500
330
331// OtherKey is the fold-over row for keys beyond the cap.
332const OtherKey = "_other"
333
334// MaxKeyLen caps a key's bytes.
335const MaxKeyLen = 256
336
337// TopK is a Misra–Gries summary with TopKSize counters.
338type TopK struct {
339 counts map[string]int64
340 other int64
341}
342
343// NewTopK returns an empty summary.
344func NewTopK() *TopK { return &TopK{counts: make(map[string]int64)} }
345
346// Add counts n occurrences of key. When the summary is full and key is
347// new, every counter is decremented by the smallest amount that frees a
348// slot, and the decremented mass moves to other.
349func (t *TopK) Add(key string, n int64) {
350 if len(key) > MaxKeyLen {
351 key = key[:MaxKeyLen]
352 }
353 if n <= 0 {
354 return
355 }
356 if _, ok := t.counts[key]; ok || len(t.counts) < TopKSize {
357 t.counts[key] += n
358 return
359 }
360 // Full and new: take the smallest counter as the decrement.
361 min := int64(-1)
362 for _, v := range t.counts {
363 if min < 0 || v < min {
364 min = v
365 }
366 }
367 if n <= min {
368 t.other += n
369 return
370 }
371 for k, v := range t.counts {
372 if v <= min {
373 delete(t.counts, k)
374 t.other += v
375 } else {
376 t.counts[k] = v - min
377 t.other += min
378 }
379 }
380 t.counts[key] = n - min
381 t.other += min
382}
383
384// Merge folds o into t: counters add, other adds, then t re-folds to the
385// cap so a merge of two full summaries stays bounded.
386func (t *TopK) Merge(o *TopK) {
387 merged := NewTopK()
388 merged.other = t.other + o.other
389 sum := make(map[string]int64, len(t.counts)+len(o.counts))
390 for k, v := range t.counts {
391 sum[k] += v
392 }
393 for k, v := range o.counts {
394 sum[k] += v
395 }
396 for k, v := range sum { // Add re-applies the cap
397 merged.Add(k, v)
398 }
399 t.counts, t.other = merged.counts, merged.other
400}
401
402// Fold returns the counters plus OtherKey when any mass fell over.
403func (t *TopK) Fold() map[string]int64 {
404 out := make(map[string]int64, len(t.counts)+1)
405 for k, v := range t.counts {
406 out[k] = v
407 }
408 if t.other > 0 {
409 out[OtherKey] = t.other
410 }
411 return out
412}
413
414// TopKFrom rebuilds a summary from a folded map (the day file's shape).
415func TopKFrom(m map[string]int64) *TopK {
416 t := NewTopK()
417 for k, v := range m {
418 if k == OtherKey {
419 t.other += v
420 continue
421 }
422 t.Add(k, v)
423 }
424 return t
425}
426
427// Approximate reports whether counters are lower bounds (mass fell over).
428func (t *TopK) Approximate() bool { return t.other > 0 }
429```
430
431- [ ] **Step 5: Run `go test ./internal/analytics/ -count=1 -v`, PASS; gofmt; vet. Commit** "analytics: HyperLogLog and Misra–Gries sketches (spec §3.3)".
432
433---
434
435### Task 2: Bot list, day salt, day-file shape and idempotent CAS merge
436
437**Files:**
438- Create: `internal/analytics/bots.go`, `internal/analytics/salt.go`, `internal/analytics/day.go`
439- Test: `internal/analytics/bots_test.go`, `internal/analytics/salt_test.go`, `internal/analytics/day_test.go`
440
441**Interfaces:**
442- Produces:
443 ```go
444 func IsBot(ua string) bool
445 func DaySalt(dir, date string) ([]byte, error) // 32 bytes; creates 0600 <dir>/analytics-salt-<date>; removes other analytics-salt-* files
446 type HourRow struct { PV int64 `json:"pv"`; Bots int64 `json:"bots"`; Bytes int64 `json:"bytes"` }
447 type Dropped struct { E DropCounts `json:"e"`; A DropCounts `json:"a"`; Flush int64 `json:"flush"` }
448 type DropCounts struct { Rate, Size, Origin, Malformed int64 } // json tags rate,size,origin,malformed
449 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"` }
450 type Delta struct { Date string; Box string; Seq int64; Hours map[string]HourRow; Visitors *HLL; Pages, Referers, Events *TopK; Dropped Dropped; Restarted bool }
451 func Prefix(sqid, app string) string // applog.FamilyPrefix(FamilyAnalytics, …)
452 func BoxPrefix(sqid, app, box string) string // Prefix + box + "/days/"
453 func DayKey(sqid, app, box, date string) string
454 func (d *Day) Merge(x Delta) (applied bool, err error) // seq ≤ recorded → false,nil
455 func UpsertDay(ctx, s release.Store, sqid, app string, x Delta) error // CAS ×3
456 ```
457
458- [ ] **Step 1: Failing tests**
459
460`bots_test.go`:
461```go
462package analytics
463
464import "testing"
465
466func TestIsBot(t *testing.T) {
467 yes := []string{
468 "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
469 "Mozilla/5.0 (compatible; bingbot/2.0)", "curl/8.4.0", "Wget/1.21",
470 "python-requests/2.31", "Go-http-client/1.1", "Mozilla/5.0 HeadlessChrome/120",
471 "facebookexternalhit/1.1", "Slackbot-LinkExpanding", "AhrefsBot/7.0", "",
472 }
473 no := []string{
474 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
475 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
476 "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
477 }
478 for _, ua := range yes {
479 if !IsBot(ua) {
480 t.Errorf("IsBot(%q) = false", ua)
481 }
482 }
483 for _, ua := range no {
484 if IsBot(ua) {
485 t.Errorf("IsBot(%q) = true", ua)
486 }
487 }
488}
489```
490
491`salt_test.go`:
492```go
493package analytics
494
495import (
496 "os"
497 "path/filepath"
498 "runtime"
499 "testing"
500)
501
502func TestDaySaltIsStableAndRotates(t *testing.T) {
503 dir := t.TempDir()
504 a, err := DaySalt(dir, "2026-09-07")
505 if err != nil || len(a) != 32 {
506 t.Fatalf("salt: %v len=%d", err, len(a))
507 }
508 b, _ := DaySalt(dir, "2026-09-07")
509 if string(a) != string(b) {
510 t.Fatal("same day must return the same salt (restart-stable)")
511 }
512 if runtime.GOOS != "windows" {
513 st, _ := os.Stat(filepath.Join(dir, "analytics-salt-2026-09-07"))
514 if st.Mode().Perm() != 0o600 {
515 t.Fatalf("mode %v", st.Mode().Perm())
516 }
517 }
518 c, _ := DaySalt(dir, "2026-09-08")
519 if string(c) == string(a) {
520 t.Fatal("a new day must salt afresh")
521 }
522 if _, err := os.Stat(filepath.Join(dir, "analytics-salt-2026-09-07")); !os.IsNotExist(err) {
523 t.Fatal("rotation must delete the previous day's salt")
524 }
525}
526```
527
528`day_test.go`:
529```go
530package analytics
531
532import (
533 "context"
534 "encoding/json"
535 "testing"
536
537 "github.com/carlosframework/platform/internal/release"
538)
539
540func delta(box string, seq int64, pv int64) Delta {
541 h := &HLL{}
542 h.Add(h64(box + itoa(int(seq))))
543 p := NewTopK()
544 p.Add("/", pv)
545 return Delta{Date: "2026-09-07", Box: box, Seq: seq, Hours: map[string]HourRow{"09": {PV: pv, Bytes: pv * 100}},
546 Visitors: h, Pages: p, Referers: NewTopK(), Events: NewTopK()}
547}
548
549func TestDayMergeIsSeqIdempotent(t *testing.T) {
550 var d Day
551 if ok, err := d.Merge(delta("box-a", 5, 10)); !ok || err != nil {
552 t.Fatalf("first merge: %v %v", ok, err)
553 }
554 if ok, _ := d.Merge(delta("box-a", 5, 10)); ok {
555 t.Fatal("replaying the same seq must be a no-op")
556 }
557 if ok, _ := d.Merge(delta("box-a", 4, 10)); ok {
558 t.Fatal("an older seq must be a no-op")
559 }
560 if ok, _ := d.Merge(delta("box-b", 1, 7)); !ok {
561 t.Fatal("another box's seq is independent")
562 }
563 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" {
564 t.Fatalf("merged = %+v", d)
565 }
566 vis, _ := DecodeHLL(d.Visitors)
567 if c := vis.Count(); c < 1.5 || c > 2.5 {
568 t.Fatalf("visitors ≈ 2, got %.2f", c)
569 }
570 b, _ := json.Marshal(d)
571 for _, want := range []string{`"v":1`, `"hours"`, `"visitors"`, `"pages"`, `"referers"`, `"events"`, `"seq"`, `"dropped"`, `"countries"`} {
572 if !contains(string(b), want) {
573 t.Errorf("wire missing %s in %s", want, b)
574 }
575 }
576}
577
578func TestUpsertDayCASAcrossBoxes(t *testing.T) {
579 ctx := context.Background()
580 s := release.NewDirStore(t.TempDir())
581 if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 1, 3)); err != nil {
582 t.Fatal(err)
583 }
584 if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil {
585 t.Fatal(err)
586 }
587 if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil { // retry of an ambiguous put
588 t.Fatal(err)
589 }
590 raw, _, err := s.Get(ctx, DayKey("bab", "web", "box-a", "2026-09-07"))
591 if err != nil {
592 t.Fatal(err)
593 }
594 var d Day
595 json.Unmarshal(raw, &d)
596 if d.Hours["09"].PV != 7 || d.Seq["box-a"] != 2 {
597 t.Fatalf("day = %+v", d)
598 }
599 if DayKey("bab", "web", "box-a", "2026-09-07") != "apps/bab/web/analytics/box-a/days/2026-09-07.json" {
600 t.Fatal(DayKey("bab", "web", "box-a", "2026-09-07"))
601 }
602}
603
604func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0) }
605func indexOf(s, sub string) int {
606 for i := 0; i+len(sub) <= len(s); i++ {
607 if s[i:i+len(sub)] == sub {
608 return i
609 }
610 }
611 return -1
612}
613```
614
615- [ ] **Step 2: Run, expect compile failure**
616
617- [ ] **Step 3: Implement `bots.go`**
618
619```go
620package analytics
621
622import "strings"
623
624// botMarkers is the vendored user-agent list (spec §3.3): incomplete by
625// nature, so the tab shows the bots count rather than pretending. Matched
626// case-insensitively as substrings. An empty UA is a bot: no browser sends
627// none.
628var botMarkers = []string{
629 "bot", "crawler", "spider", "slurp", "curl/", "wget/", "python-requests",
630 "go-http-client", "headlesschrome", "phantomjs", "facebookexternalhit",
631 "ahrefs", "semrush", "mj12", "dotbot", "petalbot", "bytespider", "gptbot",
632 "claudebot", "ccbot", "applebot", "yandex", "baiduspider", "duckduckbot",
633 "linkedinbot", "twitterbot", "discordbot", "telegrambot", "whatsapp",
634 "pingdom", "uptimerobot", "statuscake", "site24x7", "newrelicpinger",
635 "lighthouse", "pagespeed", "gtmetrix", "httpclient", "okhttp", "java/",
636 "libwww", "scrapy", "node-fetch", "axios/", "postmanruntime", "insomnia",
637}
638
639// IsBot reports whether ua looks like an automated client.
640func IsBot(ua string) bool {
641 if ua == "" {
642 return true
643 }
644 l := strings.ToLower(ua)
645 for _, m := range botMarkers {
646 if strings.Contains(l, m) {
647 return true
648 }
649 }
650 return false
651}
652```
653
654- [ ] **Step 4: Implement `salt.go`**
655
656```go
657package analytics
658
659import (
660 "crypto/rand"
661 "errors"
662 "os"
663 "path/filepath"
664 "strings"
665)
666
667const saltPrefix = "analytics-salt-"
668
669// DaySalt returns the box's visitor salt for date (YYYY-MM-DD), creating
670// <dir>/analytics-salt-<date> with 32 random bytes at 0600 when absent and
671// deleting any other day's file (spec §3.3: restart-stable within the day,
672// gone on rotation, never in the bucket). dir is the box data directory.
673func DaySalt(dir, date string) ([]byte, error) {
674 path := filepath.Join(dir, saltPrefix+date)
675 if b, err := os.ReadFile(path); err == nil && len(b) == 32 {
676 return b, nil
677 }
678 b := make([]byte, 32)
679 if _, err := rand.Read(b); err != nil {
680 return nil, err
681 }
682 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
683 if err != nil {
684 if errors.Is(err, os.ErrExist) { // a sibling process won the race
685 return os.ReadFile(path)
686 }
687 return nil, err
688 }
689 _, werr := f.Write(b)
690 cerr := f.Close()
691 if werr != nil || cerr != nil {
692 os.Remove(path)
693 return nil, errors.Join(werr, cerr)
694 }
695 // Rotation: every other day's salt goes, so nothing on disk can join a
696 // hash across days.
697 entries, _ := os.ReadDir(dir)
698 for _, e := range entries {
699 if n := e.Name(); strings.HasPrefix(n, saltPrefix) && n != saltPrefix+date {
700 os.Remove(filepath.Join(dir, n))
701 }
702 }
703 return b, nil
704}
705```
706
707- [ ] **Step 5: Implement `day.go`**
708
709```go
710package analytics
711
712import (
713 "context"
714 "encoding/json"
715 "errors"
716 "fmt"
717 "os"
718
719 "github.com/carlosframework/platform/internal/applog"
720 "github.com/carlosframework/platform/internal/release"
721)
722
723// HourRow is one UTC hour's counters.
724type HourRow struct {
725 PV int64 `json:"pv"`
726 Bots int64 `json:"bots"`
727 Bytes int64 `json:"bytes"`
728}
729
730// DropCounts is why events were dropped at a door (steps 3–4 fill them).
731type DropCounts struct {
732 Rate int64 `json:"rate"`
733 Size int64 `json:"size"`
734 Origin int64 `json:"origin"`
735 Malformed int64 `json:"malformed"`
736}
737
738// Dropped is the honesty block (spec §3.3).
739type Dropped struct {
740 E DropCounts `json:"e"`
741 A DropCounts `json:"a"`
742 Flush int64 `json:"flush"`
743}
744
745// Day is apps/<sqid>/<app>/analytics/<box>/days/<date>.json (spec §3.3):
746// one box's counting for one app for one UTC day. Additive shape.
747type Day struct {
748 V int `json:"v"`
749 Date string `json:"date"`
750 Hours map[string]HourRow `json:"hours"`
751 Visitors string `json:"visitors"`
752 Pages map[string]int64 `json:"pages"`
753 Referers map[string]int64 `json:"referers"`
754 Events map[string]int64 `json:"events"`
755 Seq map[string]int64 `json:"seq"`
756 Dropped Dropped `json:"dropped"`
757 Countries map[string]int64 `json:"countries"`
758 Restarted bool `json:"restarted,omitempty"`
759}
760
761// Delta is one flush batch from one box for one app-day.
762type Delta struct {
763 Date string
764 Box string
765 Seq int64
766 Hours map[string]HourRow
767 Visitors *HLL
768 Pages *TopK
769 Referers *TopK
770 Events *TopK
771 Dropped Dropped
772 Restarted bool
773}
774
775// Prefix is the app's analytics/ subtree.
776func Prefix(sqid, app string) string { return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) }
777
778// BoxPrefix is one box's day files.
779func BoxPrefix(sqid, app, box string) string { return Prefix(sqid, app) + box + "/days/" }
780
781// DayKey is one day file.
782func DayKey(sqid, app, box, date string) string { return BoxPrefix(sqid, app, box) + date + ".json" }
783
784// Merge folds x into d unless x.Seq is not above the recorded seq for
785// x.Box — the idempotency rule that makes a retried conditional put safe.
786func (d *Day) Merge(x Delta) (bool, error) {
787 if d.Seq == nil {
788 d.Seq = map[string]int64{}
789 }
790 if x.Seq <= d.Seq[x.Box] {
791 return false, nil
792 }
793 d.V, d.Date = 1, x.Date
794 if d.Hours == nil {
795 d.Hours = map[string]HourRow{}
796 }
797 for h, row := range x.Hours {
798 cur := d.Hours[h]
799 cur.PV += row.PV
800 cur.Bots += row.Bots
801 cur.Bytes += row.Bytes
802 d.Hours[h] = cur
803 }
804 vis, err := DecodeHLL(d.Visitors)
805 if err != nil {
806 return false, err
807 }
808 if x.Visitors != nil {
809 vis.Merge(x.Visitors)
810 }
811 d.Visitors = vis.Encode()
812 d.Pages = mergeTop(d.Pages, x.Pages)
813 d.Referers = mergeTop(d.Referers, x.Referers)
814 d.Events = mergeTop(d.Events, x.Events)
815 d.Dropped.E = addDrops(d.Dropped.E, x.Dropped.E)
816 d.Dropped.A = addDrops(d.Dropped.A, x.Dropped.A)
817 d.Dropped.Flush += x.Dropped.Flush
818 d.Restarted = d.Restarted || x.Restarted
819 if d.Countries == nil {
820 d.Countries = map[string]int64{}
821 }
822 d.Seq[x.Box] = x.Seq
823 return true, nil
824}
825
826func mergeTop(m map[string]int64, t *TopK) map[string]int64 {
827 k := TopKFrom(m)
828 if t != nil {
829 k.Merge(t)
830 }
831 return k.Fold()
832}
833
834func addDrops(a, b DropCounts) DropCounts {
835 return DropCounts{a.Rate + b.Rate, a.Size + b.Size, a.Origin + b.Origin, a.Malformed + b.Malformed}
836}
837
838// UpsertDay CAS-merges x into the app-day object for x.Box: read (etag),
839// Merge, conditional put; on ErrCASConflict re-read and retry, three
840// attempts. A no-op merge (seq already recorded) writes nothing.
841func UpsertDay(ctx context.Context, s release.Store, sqid, app string, x Delta) error {
842 key := DayKey(sqid, app, x.Box, x.Date)
843 for attempt := 0; attempt < 3; attempt++ {
844 var day Day
845 raw, etag, err := s.Get(ctx, key)
846 switch {
847 case err == nil:
848 if uerr := json.Unmarshal(raw, &day); uerr != nil {
849 return fmt.Errorf("analytics: parse %s: %w", key, uerr)
850 }
851 case errors.Is(err, os.ErrNotExist):
852 etag = ""
853 default:
854 return err
855 }
856 applied, err := day.Merge(x)
857 if err != nil {
858 return err
859 }
860 if !applied {
861 return nil
862 }
863 out, err := json.Marshal(day)
864 if err != nil {
865 return err
866 }
867 if _, err := s.Put(ctx, key, out, etag); err == nil {
868 return nil
869 } else if !errors.Is(err, release.ErrCASConflict) {
870 return err
871 }
872 }
873 return fmt.Errorf("analytics: %s: lost CAS race three times", key)
874}
875```
876
877Ensure `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).
878
879- [ ] **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)".
880
881---
882
883### Task 3: The meter the edge feeds
884
885**Files:**
886- Create: `internal/analytics/meter.go`
887- Test: `internal/analytics/meter_test.go`
888
889**Interfaces:**
890- Produces:
891 ```go
892 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 }
893 type AppKey = applog.AppKey
894 func NewMeter(saltDir, box string, now func() time.Time) *Meter
895 func (m *Meter) Observe(o Obs) // decides pageview/bot; counts bytes for every response
896 func (m *Meter) Track(acct, app, name string, when time.Time) // named events (steps 3–4 call it)
897 func (m *Meter) Drain() map[AppKey][]Delta // one Delta per app-day touched, seq stamped
898 func (m *Meter) Requeue(pending map[AppKey][]Delta) // failed flush: kept once, then dropped with Dropped.Flush++
899 func IsPageview(o Obs) (pageview bool, bot bool) // pure rule from §3.3
900 func VisitorHash(salt []byte, host, ip, ua string) uint64
901 func EventName(s string) (string, bool) // lowercased, ≤64 bytes of [a-z0-9_.-]
902 ```
903
904- [ ] **Step 1: Failing tests**
905
906```go
907package analytics
908
909import (
910 "testing"
911 "time"
912)
913
914func obs(method string, status int, ctype string) Obs {
915 return Obs{Acct: "bab", App: "web", Host: "h1", Method: method, Status: status, ContentType: ctype,
916 Path: "/pricing?x=1", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.5",
917 Bytes: 1200, When: time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)}
918}
919
920func TestIsPageviewRule(t *testing.T) {
921 cases := []struct {
922 name string
923 o Obs
924 pv bool
925 bot bool
926 }{
927 {"html 200", obs("GET", 200, "text/html; charset=utf-8"), true, false},
928 {"json", obs("GET", 200, "application/json"), false, false},
929 {"404", obs("GET", 404, "text/html"), false, false},
930 {"redirect", obs("GET", 302, "text/html"), false, false},
931 {"304", obs("GET", 304, ""), false, false},
932 {"head", obs("HEAD", 200, "text/html"), false, false},
933 {"post", obs("POST", 200, "text/html"), false, false},
934 {"prefetch", func() Obs { o := obs("GET", 200, "text/html"); o.Prefetch = true; return o }(), false, false},
935 {"fetch dest empty", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "empty"; return o }(), false, false},
936 {"fetch dest document", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "document"; return o }(), true, false},
937 {"carlos prefix", func() Obs { o := obs("GET", 200, "text/html"); o.Path = "/__carlos/a"; return o }(), false, false},
938 {"bot", func() Obs { o := obs("GET", 200, "text/html"); o.UA = "Googlebot/2.1"; return o }(), false, true},
939 }
940 for _, c := range cases {
941 pv, bot := IsPageview(c.o)
942 if pv != c.pv || bot != c.bot {
943 t.Errorf("%s: pv=%v bot=%v, want %v %v", c.name, pv, bot, c.pv, c.bot)
944 }
945 }
946}
947
948func TestMeterCountsAndDrains(t *testing.T) {
949 now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
950 m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
951 m.Observe(obs("GET", 200, "text/html"))
952 m.Observe(obs("GET", 200, "text/html"))
953 o := obs("GET", 200, "text/html")
954 o.ClientIP = "198.51.100.9"
955 m.Observe(o)
956 m.Observe(obs("GET", 200, "application/json")) // bytes only
957 b := obs("GET", 200, "text/html")
958 b.UA = "curl/8"
959 m.Observe(b)
960 m.Track("bab", "web", "Signup", now)
961 m.Track("bab", "web", "bad name!", now) // rejected
962 out := m.Drain()
963 ds := out[AppKey{Acct: "bab", App: "web"}]
964 if len(ds) != 1 {
965 t.Fatalf("deltas = %d", len(ds))
966 }
967 d := ds[0]
968 if d.Date != "2026-09-07" || d.Box != "box1" || d.Seq == 0 {
969 t.Fatalf("delta = %+v", d)
970 }
971 if h := d.Hours["09"]; h.PV != 3 || h.Bots != 1 || h.Bytes != 6000 {
972 t.Fatalf("hour = %+v", h)
973 }
974 if c := d.Visitors.Count(); c < 1.5 || c > 2.5 {
975 t.Fatalf("visitors ≈ 2, got %.2f", c)
976 }
977 if p := d.Pages.Fold(); p["/pricing"] != 3 {
978 t.Fatalf("pages = %v", p)
979 }
980 if r := d.Referers.Fold(); r["news.ycombinator.com"] != 3 {
981 t.Fatalf("referers = %v", r)
982 }
983 if e := d.Events.Fold(); e["signup"] != 1 || len(e) != 1 {
984 t.Fatalf("events = %v", e)
985 }
986 if len(m.Drain()) != 0 {
987 t.Fatal("second drain must be empty")
988 }
989 // Event-time bucketing: an observation at 09:59 flushed at 10:00 lands in 09.
990 m.Observe(func() Obs { o := obs("GET", 200, "text/html"); o.When = now.Add(29 * time.Minute); return o }())
991 now = now.Add(31 * time.Minute)
992 if d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0]; d.Hours["09"].PV != 1 || d.Hours["10"].PV != 0 {
993 t.Fatalf("event-time bucket: %+v", d.Hours)
994 }
995}
996
997func TestMeterRequeueSurvivesOnce(t *testing.T) {
998 now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
999 m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
1000 m.Observe(obs("GET", 200, "text/html"))
1001 first := m.Drain()
1002 m.Requeue(first) // flush failed
1003 m.Observe(obs("GET", 200, "text/html"))
1004 second := m.Drain()
1005 ds := second[AppKey{Acct: "bab", App: "web"}]
1006 if len(ds) != 2 || ds[0].Seq >= ds[1].Seq {
1007 t.Fatalf("requeued delta must come back first with its own seq: %+v", ds)
1008 }
1009 m.Requeue(second) // failed again: the older window drops
1010 m.Observe(obs("GET", 200, "text/html"))
1011 third := m.Drain()[AppKey{Acct: "bab", App: "web"}]
1012 if len(third) != 2 || third[1].Dropped.Flush != 1 {
1013 t.Fatalf("second failure must drop the oldest and count it: %+v", third)
1014 }
1015}
1016
1017func TestVisitorHashAndEventName(t *testing.T) {
1018 s1, s2 := []byte("salt-a"), []byte("salt-b")
1019 if VisitorHash(s1, "h", "ip", "ua") != VisitorHash(s1, "h", "ip", "ua") {
1020 t.Fatal("deterministic")
1021 }
1022 if VisitorHash(s1, "h", "ip", "ua") == VisitorHash(s2, "h", "ip", "ua") {
1023 t.Fatal("salt must change the hash")
1024 }
1025 for in, want := range map[string]string{"Signup": "signup", "checkout.done": "checkout.done", "a b": "", "": "", "x/y": ""} {
1026 got, ok := EventName(in)
1027 if (want == "") == ok || got != want {
1028 t.Errorf("EventName(%q) = %q,%v", in, got, ok)
1029 }
1030 }
1031}
1032```
1033
1034- [ ] **Step 2: Run, expect compile failure**
1035
1036- [ ] **Step 3: Implement `meter.go`**
1037
1038```go
1039package analytics
1040
1041import (
1042 "crypto/sha256"
1043 "encoding/binary"
1044 "log"
1045 "regexp"
1046 "strings"
1047 "sync"
1048 "time"
1049
1050 "github.com/carlosframework/platform/internal/apperr"
1051 "github.com/carlosframework/platform/internal/applog"
1052 "github.com/carlosframework/platform/internal/usage"
1053)
1054
1055// Obs is one served response as the edge saw it (spec §3.3). The edge
1056// fills it from the observer defer; nothing here touches the request.
1057type Obs struct {
1058 Acct, App, Host string
1059 Method string
1060 Status int
1061 ContentType string
1062 Path string // raw; sanitised here
1063 RefererHost string // host only, "" = direct
1064 UA string
1065 ClientIP string
1066 Bytes int64
1067 Prefetch bool // Sec-Purpose/Purpose: prefetch
1068 FetchDest string // Sec-Fetch-Dest, "" when absent
1069 When time.Time
1070}
1071
1072// AppKey is the (account, app) the meter buckets by.
1073type AppKey = applog.AppKey
1074
1075// IsPageview applies §3.3's rule: GET, 200, text/html, not prefetch, dest
1076// document or absent, not under /__carlos/. A bot UA is reported
1077// separately and never a pageview.
1078func IsPageview(o Obs) (pageview, bot bool) {
1079 if o.Method != "GET" || o.Status != 200 {
1080 return false, false
1081 }
1082 mt := strings.ToLower(strings.TrimSpace(strings.SplitN(o.ContentType, ";", 2)[0]))
1083 if mt != "text/html" {
1084 return false, false
1085 }
1086 if o.Prefetch || (o.FetchDest != "" && o.FetchDest != "document") {
1087 return false, false
1088 }
1089 if strings.HasPrefix(o.Path, "/__carlos/") {
1090 return false, false
1091 }
1092 if IsBot(o.UA) {
1093 return false, true
1094 }
1095 return true, false
1096}
1097
1098// VisitorHash is sha256(salt ‖ host ‖ ip ‖ ua) folded to 64 bits for the
1099// sketch; the platform never stores the inputs.
1100func VisitorHash(salt []byte, host, ip, ua string) uint64 {
1101 h := sha256.New()
1102 h.Write(salt)
1103 h.Write([]byte{0})
1104 h.Write([]byte(host))
1105 h.Write([]byte{0})
1106 h.Write([]byte(ip))
1107 h.Write([]byte{0})
1108 h.Write([]byte(ua))
1109 return binary.BigEndian.Uint64(h.Sum(nil)[:8])
1110}
1111
1112var eventName = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
1113
1114// EventName normalises a named event: lowercased, 64 bytes of [a-z0-9_.-].
1115func EventName(s string) (string, bool) {
1116 s = strings.ToLower(strings.TrimSpace(s))
1117 if !eventName.MatchString(s) {
1118 return "", false
1119 }
1120 return s, true
1121}
1122
1123// Meter accumulates per-app deltas between flushes. One per edge process;
1124// Observe sits on the serving path (one mutex, no I/O except the first
1125// salt read of a day).
1126type Meter struct {
1127 box string
1128 saltDir string
1129 now func() time.Time
1130
1131 mu sync.Mutex
1132 cur map[AppKey]map[string]*Delta // app → date → delta being built
1133 pending map[AppKey][]Delta // re-queued after a failed flush (one window)
1134 seq int64
1135 saltDay string
1136 salt []byte
1137 started string // date the process started: Restarted is set on that day's first delta
1138}
1139
1140// NewMeter returns a meter whose salts live in saltDir (the box data dir).
1141func NewMeter(saltDir, box string, now func() time.Time) *Meter {
1142 return &Meter{box: box, saltDir: saltDir, now: now, cur: map[AppKey]map[string]*Delta{},
1143 pending: map[AppKey][]Delta{}, seq: now().UnixMilli(), started: usage.DayOf(now())}
1144}
1145
1146func (m *Meter) saltFor(date string) []byte {
1147 if m.saltDay == date && m.salt != nil {
1148 return m.salt
1149 }
1150 s, err := DaySalt(m.saltDir, date)
1151 if err != nil {
1152 log.Printf("analytics: day salt: %v (visitors not counted until it works)", err)
1153 return nil
1154 }
1155 m.saltDay, m.salt = date, s
1156 return s
1157}
1158
1159func (m *Meter) delta(k AppKey, date string) *Delta {
1160 days := m.cur[k]
1161 if days == nil {
1162 days = map[string]*Delta{}
1163 m.cur[k] = days
1164 }
1165 d := days[date]
1166 if d == nil {
1167 d = &Delta{Date: date, Box: m.box, Hours: map[string]HourRow{}, Visitors: &HLL{},
1168 Pages: NewTopK(), Referers: NewTopK(), Events: NewTopK(), Restarted: date == m.started}
1169 days[date] = d
1170 }
1171 return d
1172}
1173
1174// Observe counts one response. Bytes count for every response; pageview,
1175// bot, visitor, page and referer only when the rule says so.
1176func (m *Meter) Observe(o Obs) {
1177 pv, bot := IsPageview(o)
1178 date, hour := usage.DayOf(o.When), usage.HourLabel(o.When)
1179 path := apperr.SanitizePath(o.Path)
1180 m.mu.Lock()
1181 defer m.mu.Unlock()
1182 d := m.delta(AppKey{Acct: o.Acct, App: o.App}, date)
1183 row := d.Hours[hour]
1184 row.Bytes += o.Bytes
1185 if bot {
1186 row.Bots++
1187 }
1188 if pv {
1189 row.PV++
1190 if salt := m.saltFor(date); salt != nil {
1191 d.Visitors.Add(VisitorHash(salt, o.Host, o.ClientIP, o.UA))
1192 }
1193 d.Pages.Add(path, 1)
1194 ref := o.RefererHost
1195 if ref == "" {
1196 ref = "direct"
1197 }
1198 d.Referers.Add(strings.ToLower(ref), 1)
1199 }
1200 d.Hours[hour] = row
1201}
1202
1203// Track counts one named event (the doors call it; nothing does in step 2).
1204func (m *Meter) Track(acct, app, name string, when time.Time) {
1205 n, ok := EventName(name)
1206 if !ok {
1207 return
1208 }
1209 m.mu.Lock()
1210 defer m.mu.Unlock()
1211 m.delta(AppKey{Acct: acct, App: app}, usage.DayOf(when)).Events.Add(n, 1)
1212}
1213
1214// Drain returns everything buffered — re-queued deltas first, then the
1215// current ones, each stamped with a fresh box-monotonic seq — and resets.
1216func (m *Meter) Drain() map[AppKey][]Delta {
1217 m.mu.Lock()
1218 defer m.mu.Unlock()
1219 out := map[AppKey][]Delta{}
1220 for k, ds := range m.pending {
1221 out[k] = append(out[k], ds...)
1222 }
1223 m.pending = map[AppKey][]Delta{}
1224 for k, days := range m.cur {
1225 for _, d := range days {
1226 m.seq++
1227 d.Seq = m.seq
1228 out[k] = append(out[k], *d)
1229 }
1230 }
1231 m.cur = map[AppKey]map[string]*Delta{}
1232 if m.started != "" && usage.DayOf(m.now()) != m.started {
1233 m.started = "" // the restart note is only for the day it happened
1234 }
1235 return out
1236}
1237
1238// Requeue holds a failed flush for the next drain. One window survives:
1239// a delta already re-queued once is dropped and counted in Dropped.Flush
1240// on the newest delta for that app.
1241func (m *Meter) Requeue(failed map[AppKey][]Delta) {
1242 m.mu.Lock()
1243 defer m.mu.Unlock()
1244 for k, ds := range failed {
1245 if n := len(m.pending[k]); n > 0 {
1246 dropped := int64(n)
1247 m.pending[k] = nil
1248 if len(ds) > 0 {
1249 ds[len(ds)-1].Dropped.Flush += dropped
1250 }
1251 }
1252 m.pending[k] = append(m.pending[k], ds...)
1253 }
1254}
1255```
1256
1257Note: `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.
1258
1259- [ ] **Step 4: Run, PASS; gofmt; vet. Commit** "analytics: the meter the edge feeds — pageview rule, visitors, top keys, requeue (spec §3.3)".
1260
1261---
1262
1263### Task 4: Cross-box range read
1264
1265**Files:**
1266- Create: `internal/analytics/read.go`
1267- Test: `internal/analytics/read_test.go`
1268
1269**Interfaces:**
1270- Produces:
1271 ```go
1272 type DaySummary struct { Date string; PV, Bots, Bytes int64; Visitors int64; Hours [24]int64; Restarted bool }
1273 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 }
1274 func ReadRange(ctx, s release.Store, sqid, app string, from, to time.Time) (Summary, error) // inclusive UTC dates
1275 func Dates(from, to time.Time) []string
1276 ```
1277
1278- [ ] **Step 1: Failing test**
1279
1280```go
1281package analytics
1282
1283import (
1284 "context"
1285 "testing"
1286 "time"
1287
1288 "github.com/carlosframework/platform/internal/release"
1289)
1290
1291func TestReadRangeMergesBoxesAndDays(t *testing.T) {
1292 ctx := context.Background()
1293 s := release.NewDirStore(t.TempDir())
1294 d1 := delta("box-a", 1, 10)
1295 d2 := delta("box-b", 1, 5)
1296 d3 := delta("box-a", 2, 7)
1297 d3.Date = "2026-09-08"
1298 d3.Restarted = true
1299 for _, d := range []Delta{d1, d2, d3} {
1300 if err := UpsertDay(ctx, s, "bab", "web", d); err != nil {
1301 t.Fatal(err)
1302 }
1303 }
1304 from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)
1305 sum, err := ReadRange(ctx, s, "bab", "web", from, from.Add(24*time.Hour))
1306 if err != nil {
1307 t.Fatal(err)
1308 }
1309 if len(sum.Days) != 2 || sum.Days[0].Date != "2026-09-07" || sum.Days[0].PV != 15 || sum.Days[1].PV != 7 {
1310 t.Fatalf("days = %+v", sum.Days)
1311 }
1312 if sum.PV != 22 || sum.Pages["/"] != 22 || sum.Days[0].Hours[9] != 15 || !sum.Days[1].Restarted || sum.Days[0].Restarted {
1313 t.Fatalf("summary = %+v", sum)
1314 }
1315 if sum.Visitors != sum.Days[0].Visitors+sum.Days[1].Visitors || sum.Days[0].Visitors < 1 {
1316 t.Fatalf("visitors must be daily sums: %+v", sum)
1317 }
1318 if len(sum.Boxes) != 2 {
1319 t.Fatalf("boxes = %v", sum.Boxes)
1320 }
1321 // An app with no day files reads as an empty summary, not an error.
1322 empty, err := ReadRange(ctx, s, "bab", "nothing", from, from)
1323 if err != nil || empty.PV != 0 || len(empty.Days) != 1 {
1324 t.Fatalf("empty = %+v %v", empty, err)
1325 }
1326}
1327```
1328
1329- [ ] **Step 2: Run, expect compile failure**
1330
1331- [ ] **Step 3: Implement `read.go`**
1332
1333```go
1334package analytics
1335
1336import (
1337 "context"
1338 "encoding/json"
1339 "errors"
1340 "math"
1341 "os"
1342 "sort"
1343 "strconv"
1344 "strings"
1345 "time"
1346
1347 "github.com/carlosframework/platform/internal/release"
1348 "github.com/carlosframework/platform/internal/usage"
1349)
1350
1351// DaySummary is one UTC day across every box.
1352type DaySummary struct {
1353 Date string
1354 PV int64
1355 Bots int64
1356 Bytes int64
1357 Visitors int64 // HLL estimate, rounded
1358 Hours [24]int64
1359 Restarted bool
1360}
1361
1362// Summary is what the tab and the API render: per-day rows plus totals.
1363// Visitors is the SUM of daily estimates (spec §3.3: "daily visitors,
1364// summed"), never a cross-day union.
1365type Summary struct {
1366 From, To string
1367 Days []DaySummary
1368 PV, Bots int64
1369 Bytes int64
1370 Visitors int64
1371 Pages map[string]int64
1372 Referers map[string]int64
1373 Events map[string]int64
1374 PagesApprox bool
1375 ReferersApprox bool
1376 EventsApprox bool
1377 Dropped Dropped
1378 Boxes []string
1379}
1380
1381// Dates lists the UTC dates from from to to inclusive.
1382func Dates(from, to time.Time) []string {
1383 var out []string
1384 for d := from.UTC().Truncate(24 * time.Hour); !d.After(to.UTC()); d = d.Add(24 * time.Hour) {
1385 out = append(out, usage.DayOf(d))
1386 }
1387 return out
1388}
1389
1390// ReadRange lists the app's boxes and reads each day file in the range,
1391// merging across boxes: counters sum, visitor sketches union per day,
1392// top-key summaries merge and re-fold. A missing day file is an empty
1393// day; a store error fails the read.
1394func ReadRange(ctx context.Context, s release.Store, sqid, app string, from, to time.Time) (Summary, error) {
1395 sum := Summary{From: usage.DayOf(from), To: usage.DayOf(to)}
1396 boxDirs, err := s.ListDirs(ctx, Prefix(sqid, app))
1397 if err != nil {
1398 return sum, err
1399 }
1400 for _, dir := range boxDirs {
1401 sum.Boxes = append(sum.Boxes, boxName(dir))
1402 }
1403 sort.Strings(sum.Boxes)
1404 pages, refs, events := NewTopK(), NewTopK(), NewTopK()
1405 for _, date := range Dates(from, to) {
1406 ds := DaySummary{Date: date}
1407 vis := &HLL{}
1408 for _, box := range sum.Boxes {
1409 raw, _, err := s.Get(ctx, DayKey(sqid, app, box, date))
1410 if errors.Is(err, os.ErrNotExist) {
1411 continue
1412 }
1413 if err != nil {
1414 return sum, err
1415 }
1416 var day Day
1417 if err := json.Unmarshal(raw, &day); err != nil {
1418 return sum, err
1419 }
1420 for h, row := range day.Hours {
1421 if i, err := strconv.Atoi(h); err == nil && i >= 0 && i < 24 {
1422 ds.Hours[i] += row.PV
1423 }
1424 ds.PV += row.PV
1425 ds.Bots += row.Bots
1426 ds.Bytes += row.Bytes
1427 }
1428 if v, err := DecodeHLL(day.Visitors); err == nil {
1429 vis.Merge(v)
1430 }
1431 pages.Merge(TopKFrom(day.Pages))
1432 refs.Merge(TopKFrom(day.Referers))
1433 events.Merge(TopKFrom(day.Events))
1434 sum.Dropped.E = addDrops(sum.Dropped.E, day.Dropped.E)
1435 sum.Dropped.A = addDrops(sum.Dropped.A, day.Dropped.A)
1436 sum.Dropped.Flush += day.Dropped.Flush
1437 ds.Restarted = ds.Restarted || day.Restarted
1438 }
1439 if !vis.Empty() {
1440 ds.Visitors = int64(math.Round(vis.Count()))
1441 }
1442 sum.Days = append(sum.Days, ds)
1443 sum.PV += ds.PV
1444 sum.Bots += ds.Bots
1445 sum.Bytes += ds.Bytes
1446 sum.Visitors += ds.Visitors
1447 }
1448 sum.Pages, sum.PagesApprox = pages.Fold(), pages.Approximate()
1449 sum.Referers, sum.ReferersApprox = refs.Fold(), refs.Approximate()
1450 sum.Events, sum.EventsApprox = events.Fold(), events.Approximate()
1451 return sum, nil
1452}
1453
1454func boxName(dir string) string {
1455 t := strings.TrimSuffix(dir, "/")
1456 if i := strings.LastIndexByte(t, '/'); i >= 0 {
1457 return t[i+1:]
1458 }
1459 return t
1460}
1461```
1462
1463- [ ] **Step 4: Run, PASS. Commit** "analytics: range read across boxes and days".
1464
1465---
1466
1467### Task 5: The edge tap and the switch map
1468
1469**Files:**
1470- Create: `internal/edge/analytics.go`
1471- Modify: `internal/edge/edge.go` (fields; one call in the observer defer)
1472- Test: `internal/edge/analytics_test.go`
1473
1474**Interfaces:**
1475- Produces:
1476 ```go
1477 type AnalyticsMeter interface { Observe(analytics.Obs) }
1478 func (e *Edge) EnableAnalytics(m AnalyticsMeter)
1479 func (e *Edge) SetAnalyticsOn(on map[string]bool) // key sqid+"/"+app; nil = nothing on
1480 func clientIP(r *http.Request) string
1481 func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64)
1482 ```
1483
1484- [ ] **Step 1: Failing tests**
1485
1486```go
1487package edge
1488
1489import (
1490 "net/http"
1491 "net/http/httptest"
1492 "sync"
1493 "testing"
1494
1495 "github.com/carlosframework/platform/internal/analytics"
1496 "github.com/carlosframework/platform/internal/registry"
1497 "github.com/carlosframework/platform/internal/release"
1498)
1499
1500type memAnalytics struct {
1501 mu sync.Mutex
1502 obs []analytics.Obs
1503}
1504
1505func (m *memAnalytics) Observe(o analytics.Obs) { m.mu.Lock(); m.obs = append(m.obs, o); m.mu.Unlock() }
1506func (m *memAnalytics) snap() []analytics.Obs {
1507 m.mu.Lock()
1508 defer m.mu.Unlock()
1509 return append([]analytics.Obs(nil), m.obs...)
1510}
1511
1512func TestClientIP(t *testing.T) {
1513 r := httptest.NewRequest("GET", "http://h/", nil)
1514 r.RemoteAddr = "203.0.113.5:4444"
1515 r.Header.Set("X-Forwarded-For", "198.51.100.1")
1516 if ip := clientIP(r); ip != "203.0.113.5" {
1517 t.Fatalf("unverified: %q", ip)
1518 }
1519 r2 := r.WithContext(withHopVerified(r.Context())) // helper: mark ctx as a verified hop (use hopVerifiedKey)
1520 r2.Header.Set("X-Forwarded-For", "10.0.0.1, 198.51.100.7")
1521 if ip := clientIP(r2); ip != "198.51.100.7" {
1522 t.Fatalf("verified hop: %q", ip)
1523 }
1524}
1525
1526func TestEdgeObservesOnlyWhenSwitchedOn(t *testing.T) {
1527 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1528 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1529 w.Write([]byte("<h1>hi</h1>"))
1530 }))
1531 t.Cleanup(backend.Close)
1532 reg := openReg(t)
1533 rt := registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web"}
1534 if err := reg.Upsert(rt); err != nil {
1535 t.Fatal(err)
1536 }
1537 e, err := New(reg, nil)
1538 if err != nil {
1539 t.Fatal(err)
1540 }
1541 m := &memAnalytics{}
1542 e.EnableAnalytics(m)
1543 srv := httptest.NewServer(e)
1544 t.Cleanup(srv.Close)
1545 get := func(path string, hdr map[string]string) {
1546 req, _ := http.NewRequest("GET", srv.URL+path, nil)
1547 req.Host = "app.example.com"
1548 for k, v := range hdr {
1549 req.Header.Set(k, v)
1550 }
1551 resp, err := http.DefaultClient.Do(req)
1552 if err != nil {
1553 t.Fatal(err)
1554 }
1555 resp.Body.Close()
1556 }
1557 get("/", nil)
1558 if len(m.snap()) != 0 {
1559 t.Fatal("nothing is observed while the app is off")
1560 }
1561 e.SetAnalyticsOn(map[string]bool{release.OpsAccount + "/web": true})
1562 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"})
1563 obs := m.snap()
1564 if len(obs) != 1 {
1565 t.Fatalf("observed %d", len(obs))
1566 }
1567 o := obs[0]
1568 if o.Acct != release.OpsAccount || o.App != "web" || o.Host != "app.example.com" || o.Method != "GET" || o.Status != 200 ||
1569 o.ContentType != "text/html; charset=utf-8" || o.Path != "/pricing" || o.RefererHost != "news.ycombinator.com" ||
1570 o.FetchDest != "document" || o.Bytes == 0 || o.ClientIP == "" || o.When.IsZero() {
1571 t.Fatalf("obs = %+v", o)
1572 }
1573 get("/x", map[string]string{"Purpose": "prefetch"})
1574 if !m.snap()[1].Prefetch {
1575 t.Fatal("prefetch header must be carried")
1576 }
1577 e.SetAnalyticsOn(nil)
1578 get("/", nil)
1579 if len(m.snap()) != 2 {
1580 t.Fatal("switching off stops observation")
1581 }
1582}
1583```
1584
1585If 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).
1586
1587- [ ] **Step 2: Run, expect compile failure**
1588
1589- [ ] **Step 3: Implement `internal/edge/analytics.go`**
1590
1591```go
1592package edge
1593
1594import (
1595 "net"
1596 "net/http"
1597 "net/url"
1598 "strings"
1599 "sync/atomic"
1600 "time"
1601
1602 "github.com/carlosframework/platform/internal/analytics"
1603 "github.com/carlosframework/platform/internal/registry"
1604 "github.com/carlosframework/platform/internal/release"
1605)
1606
1607// AnalyticsMeter is where the edge files one observation per response
1608// for an app whose analytics are switched on (spec §3.3). analytics.Meter
1609// satisfies it; the interface keeps the dependency on the contract.
1610type AnalyticsMeter interface {
1611 Observe(analytics.Obs)
1612}
1613
1614type analyticsMeterRef struct{ AnalyticsMeter }
1615
1616// EnableAnalytics wires the meter in; nil is a valid no-op.
1617func (e *Edge) EnableAnalytics(m AnalyticsMeter) {
1618 if m == nil {
1619 e.analyticsMeter.Store(nil)
1620 return
1621 }
1622 e.analyticsMeter.Store(&analyticsMeterRef{m})
1623}
1624
1625// SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The
1626// host's telemetry pass pushes it on every serial-triggered full pass and
1627// every five minutes (spec §3.3, "converged by boxes on the next pass").
1628func (e *Edge) SetAnalyticsOn(on map[string]bool) {
1629 if on == nil {
1630 e.analyticsOn.Store(nil)
1631 return
1632 }
1633 e.analyticsOn.Store(&on)
1634}
1635
1636func (e *Edge) analyticsEnabled(rt registry.Route) bool {
1637 m := e.analyticsOn.Load()
1638 if m == nil {
1639 return false
1640 }
1641 return (*m)[release.RouteAccount(rt)+"/"+rt.App]
1642}
1643
1644// clientIP is the address the visitor hash uses (spec §3.3): the last
1645// element of the verified inbound X-Forwarded-For chain on a proven hop,
1646// otherwise RemoteAddr's host. A header the client set never reaches it.
1647func clientIP(r *http.Request) string {
1648 if hopVerified(r.Context()) {
1649 chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ")
1650 if i := strings.LastIndexByte(chain, ','); i >= 0 {
1651 chain = chain[i+1:]
1652 }
1653 if ip := strings.TrimSpace(chain); ip != "" {
1654 return ip
1655 }
1656 }
1657 host, _, err := net.SplitHostPort(r.RemoteAddr)
1658 if err != nil {
1659 return r.RemoteAddr
1660 }
1661 return host
1662}
1663
1664// observeAnalytics files one Obs when the route's app is switched on.
1665// Runs in the observer defer after the response completed; reads only.
1666func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64) {
1667 ref := e.analyticsMeter.Load()
1668 if ref == nil || !e.analyticsEnabled(rt) {
1669 return
1670 }
1671 refHost := ""
1672 if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
1673 refHost = u.Hostname()
1674 }
1675 prefetch := strings.EqualFold(r.Header.Get("Sec-Purpose"), "prefetch") || strings.EqualFold(r.Header.Get("Purpose"), "prefetch")
1676 ref.Observe(analytics.Obs{
1677 Acct: release.RouteAccount(rt), App: rt.App, Host: rt.Host,
1678 Method: r.Method, Status: ob.status, ContentType: ob.ctype,
1679 Path: r.URL.Path, RefererHost: refHost, UA: r.Header.Get("User-Agent"),
1680 ClientIP: clientIP(r), Bytes: bytes,
1681 Prefetch: prefetch, FetchDest: strings.ToLower(r.Header.Get("Sec-Fetch-Dest")),
1682 When: time.Now(),
1683 })
1684}
1685```
1686
1687Add to `Edge`:
1688```go
1689 analyticsMeter atomic.Pointer[analyticsMeterRef]
1690 analyticsOn atomic.Pointer[map[string]bool]
1691```
1692and in the observer defer (`edge.go` ~1300), after the edge-error line:
1693```go
1694 e.observeAnalytics(up.route, r, ob, bytes)
1695```
1696`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.
1697
1698- [ ] **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)".
1699
1700---
1701
1702### Task 6: Host — switch pass, meter, flush on the usage tick
1703
1704**Files:**
1705- Create: `internal/host/telemetry_pass.go`
1706- Modify: `internal/host/usage_loop.go`, `internal/host/agent.go`
1707- Test: `internal/host/telemetry_pass_test.go`, `internal/host/usage_loop_test.go` (append)
1708
1709**Interfaces:**
1710- Produces:
1711 ```go
1712 func telemetrySwitches(ctx, s release.Store, reg *registry.Registry) (map[string]bool, error) // sqid/app → count==edge for the routes this box serves
1713 func setAnalyticsSink(fn func(map[string]bool)) // agent.go callback registers e.SetAnalyticsOn
1714 func telemetryPass(ctx, s, reg) // resolves and pushes; logs errors
1715 func analyticsFlush(ctx, s release.Store, sqid... ) — folded into usageFlush: usageFlush(ctx, s, box, drain, am *analytics.Meter, now) error
1716 ```
1717
1718- [ ] **Step 1: Failing tests**
1719
1720`telemetry_pass_test.go`:
1721```go
1722package host
1723
1724import (
1725 "context"
1726 "testing"
1727
1728 "github.com/carlosframework/platform/internal/registry"
1729 "github.com/carlosframework/platform/internal/release"
1730 "github.com/carlosframework/platform/internal/telemetry"
1731)
1732
1733func TestTelemetrySwitchesResolvePerRoute(t *testing.T) {
1734 ctx := context.Background()
1735 s := release.NewDirStore(t.TempDir())
1736 reg := openTestRegistry(t) // use whatever helper host tests use to open a temp registry
1737 for _, rt := range []registry.Route{
1738 {Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"},
1739 {Host: "b.example", Kind: "service", Account: "bab", App: "api", Status: "ready"},
1740 {Host: "c.example", Kind: "static", Account: "zzz", App: "site", Status: "ready"},
1741 {Host: "status.example", Kind: "status", Status: "ready"},
1742 } {
1743 if err := reg.Upsert(rt); err != nil {
1744 t.Fatal(err)
1745 }
1746 }
1747 telemetry.Update(ctx, s, telemetry.AccountKey("bab"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge} })
1748 telemetry.Update(ctx, s, telemetry.AppKey("bab", "api"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountOff} })
1749 on, err := telemetrySwitches(ctx, s, reg)
1750 if err != nil {
1751 t.Fatal(err)
1752 }
1753 if !on["bab/web"] || on["bab/api"] || on["zzz/site"] {
1754 t.Fatalf("switches = %v", on)
1755 }
1756 if _, ok := on["/"]; ok {
1757 t.Fatal("status routes have no app and must not appear")
1758 }
1759}
1760```
1761
1762Find 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.
1763
1764`usage_loop_test.go` append:
1765```go
1766func TestUsageFlushAlsoFlushesAnalytics(t *testing.T) {
1767 ctx := context.Background()
1768 s := release.NewDirStore(t.TempDir())
1769 now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)
1770 am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now })
1771 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})
1772 if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil {
1773 t.Fatal(err)
1774 }
1775 sum, err := analytics.ReadRange(ctx, s, "bab", "web", now, now)
1776 if err != nil || sum.PV != 1 {
1777 t.Fatalf("flushed day: %+v %v", sum, err)
1778 }
1779 // A failed flush re-queues; the next flush lands it.
1780 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})
1781 _ = usageFlush(ctx, failingStore{}, "box1", func() map[string]usage.EdgeRow { return nil }, am, now)
1782 if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil {
1783 t.Fatal(err)
1784 }
1785 if sum, _ := analytics.ReadRange(ctx, s, "bab", "web", now, now); sum.PV != 2 {
1786 t.Fatalf("after requeue: %d", sum.PV)
1787 }
1788}
1789```
1790(`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.)
1791
1792- [ ] **Step 2: Run, expect compile failure**
1793
1794- [ ] **Step 3: Implement `telemetry_pass.go`**
1795
1796```go
1797// The analytics switch pass (spec 2026-09-07 §3.3): resolve
1798// telemetry.json app → account → default for every (account, app) this
1799// box serves and hand the edge a fresh switch map. Rides the serial-
1800// triggered full pass and the five-minute instances loop, so a console
1801// write lands within seconds and the hourly floor stands underneath.
1802package host
1803
1804import (
1805 "context"
1806 "log"
1807 "sync/atomic"
1808
1809 "github.com/carlosframework/platform/internal/registry"
1810 "github.com/carlosframework/platform/internal/release"
1811 "github.com/carlosframework/platform/internal/telemetry"
1812)
1813
1814var analyticsSink atomic.Pointer[func(map[string]bool)]
1815
1816// setAnalyticsSink registers where a resolved switch map goes (the edge's
1817// SetAnalyticsOn), the way setUpstreamSource publishes the prober table.
1818func setAnalyticsSink(fn func(map[string]bool)) { analyticsSink.Store(&fn) }
1819
1820// telemetrySwitches resolves the count switch for each distinct
1821// (account, app) among the box's routes. Status routes carry no app and
1822// are skipped. One Resolve (two Gets) per pair; a pair that fails to
1823// resolve is left OFF and logged — off is the safe side of this switch.
1824func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]bool, error) {
1825 routes, err := reg.List()
1826 if err != nil {
1827 return nil, err
1828 }
1829 on := map[string]bool{}
1830 seen := map[string]bool{}
1831 for _, rt := range routes {
1832 if rt.Kind == "status" || rt.App == "" {
1833 continue
1834 }
1835 sqid := release.RouteAccount(rt)
1836 key := sqid + "/" + rt.App
1837 if seen[key] {
1838 continue
1839 }
1840 seen[key] = true
1841 res, err := telemetry.Resolve(ctx, s, sqid, rt.App)
1842 if err != nil {
1843 log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err)
1844 continue
1845 }
1846 if res.Count == telemetry.CountEdge {
1847 on[key] = true
1848 }
1849 }
1850 return on, nil
1851}
1852
1853// telemetryPass resolves and pushes; errors are one log line each.
1854func telemetryPass(ctx context.Context, s release.Store, reg *registry.Registry) {
1855 sink := analyticsSink.Load()
1856 if sink == nil {
1857 return
1858 }
1859 on, err := telemetrySwitches(ctx, s, reg)
1860 if err != nil {
1861 log.Printf("agent: telemetry pass: %v", err)
1862 return
1863 }
1864 (*sink)(on)
1865}
1866```
1867
1868Confirm `reg.List()`'s signature (`edge.go:435` calls it) and the `Route` field the account lives in.
1869
1870- [ ] **Step 4: Wire the pass and the flush in `agent.go` and `usage_loop.go`**
1871
1872`usage_loop.go`:
1873```go
1874// usageFlush drains the edge meter and CAS-merges the deltas into the
1875// box's usage/edge day object, then flushes the analytics meter's deltas
1876// into their app-day objects (spec §3.3: the same tick, a second CAS
1877// write). A failed analytics batch is re-queued for the next tick.
1878func usageFlush(ctx context.Context, s release.Store, box string, drain func() map[string]usage.EdgeRow, am *analytics.Meter, now time.Time) error {
1879 err := usage.UpsertEdgeDay(ctx, s, box, now, drain())
1880 if am == nil {
1881 return err
1882 }
1883 failed := map[analytics.AppKey][]analytics.Delta{}
1884 for k, ds := range am.Drain() {
1885 for _, d := range ds {
1886 if uerr := analytics.UpsertDay(ctx, s, k.Acct, k.App, d); uerr != nil {
1887 log.Printf("agent: analytics flush %s/%s %s: %v (re-queued)", k.Acct, k.App, d.Date, uerr)
1888 failed[k] = append(failed[k], d)
1889 }
1890 }
1891 }
1892 if len(failed) > 0 {
1893 am.Requeue(failed)
1894 }
1895 return err
1896}
1897```
1898`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.
1899
1900Pass 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).
1901
1902- [ ] **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".
1903
1904---
1905
1906### Task 7: Console — settings switches, PUT accepts analytics, serial bump
1907
1908**Files:**
1909- 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`
1910- Test: `internal/console/apianalytics_test.go` (settings part)
1911
1912**Interfaces:**
1913- 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.
1914
1915- [ ] **Step 1: Failing test**
1916
1917```go
1918func TestAPITelemetryPutSetsAnalytics(t *testing.T) {
1919 fx, _ := instanceFixture(t, "code-analytics-set")
1920 op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
1921 put := func(body string) (int, string) {
1922 st, _, b := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, body)
1923 return st, b
1924 }
1925 if st, b := put(`{"analytics":{"count":"edge"}}`); st != 200 {
1926 t.Fatalf("count=edge: %d %s", st, b)
1927 }
1928 r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
1929 if r.Count != "edge" || r.CountFrom != "app" || r.Door != "on" {
1930 t.Fatalf("resolved = %+v", r)
1931 }
1932 if st, _ := put(`{"analytics":{"count":"browser"}}`); st != 400 {
1933 t.Fatal("reserved mode must be refused for now")
1934 }
1935 if st, _ := put(`{"analytics":{"door":"sideways"}}`); st != 400 {
1936 t.Fatal("bad door")
1937 }
1938 if st, _ := put(`{"analytics":{"door":"off"},"errors":{"log_level":"warn"}}`); st != 200 {
1939 t.Fatal("both halves in one PUT")
1940 }
1941 r, _ = telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo")
1942 if r.Door != "off" || r.LogLevel != "warn" || r.Count != "edge" {
1943 t.Fatalf("after both: %+v", r)
1944 }
1945 // The serial moved: boxes converge on it.
1946 if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 {
1947 t.Fatalf("serial not bumped: %+v %v", doc, err)
1948 }
1949}
1950
1951func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) {
1952 fx, _ := instanceFixture(t, "code-analytics-page")
1953 fx.setUser("paul@keymail.dev")
1954 c := newBrowser(t)
1955 signUp(t, fx, c, "code-analytics-signin")
1956 status, _, body := get(t, c, fx.u("/apps/titogo/settings"))
1957 if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") {
1958 t.Fatalf("app settings: %d", status)
1959 }
1960 // Post the account-level switch on; the app page shows it inherited.
1961 status, _, _ = postForm(t, c, fx.u("/analytics/setting"), url.Values{"csrf": {csrfFor(t, c, fx)}, "count": {"edge"}, "door": {"on"}})
1962 if status != 303 {
1963 t.Fatalf("account switch: %d", status)
1964 }
1965 _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
1966 if !strings.Contains(body, "currently edge, from the account") {
1967 t.Fatal("inheritance not shown")
1968 }
1969}
1970```
1971Use 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`).
1972
1973- [ ] **Step 2: Run, expect failure**
1974
1975- [ ] **Step 3: Implement**
1976
1977In `handleAPITelemetryPut`: replace the `body.Analytics != nil → 400` block with validation:
1978```go
1979 if a := body.Analytics; a != nil {
1980 if a.Count != "" && a.Count != telemetry.CountEdge && a.Count != telemetry.CountOff {
1981 jsonError(w, http.StatusBadRequest, "analytics.count: want edge or off")
1982 return
1983 }
1984 if a.Door != "" && a.Door != telemetry.DoorOn && a.Door != telemetry.DoorOff {
1985 jsonError(w, http.StatusBadRequest, "analytics.door: want on or off")
1986 return
1987 }
1988 }
1989 if body.Errors == nil && body.Analytics == nil { 400 "nothing to set" }
1990 if body.Errors != nil && !telemetry.ValidLogLevel(...) { 400 as today }
1991```
1992and 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.
1993
1994New 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.
1995
1996Page 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).
1997
1998Templates: after the Errors section in both files:
1999```html
2000<h2>Analytics</h2>
2001{{if .AnalyticsUnread}}
2002<p class="small">This app&rsquo;s analytics setting could not be read just now. Reload before concluding anything.</p>
2003{{else if .IsOwner}}
2004<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/analytics/setting" class="settings-row">
2005 <input type="hidden" name="csrf" value="{{.CSRF}}">
2006 <label>Count page views at the edge:
2007 <select name="count">
2008 <option value="off"{{if eq .AnalyticsSetting.Count "off"}} selected{{end}}>off</option>
2009 <option value="edge"{{if eq .AnalyticsSetting.Count "edge"}} selected{{end}}>on (edge)</option>
2010 </select>
2011 </label>
2012 <button type="submit" class="btn-sm">Save</button>
2013 <span class="small">currently {{.AnalyticsSetting.Count}}, from the {{.AnalyticsSetting.CountFrom}}</span>
2014</form>
2015{{else}}
2016<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>
2017{{end}}
2018<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>
2019```
2020The 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.
2021
2022- [ ] **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".
2023
2024---
2025
2026### Task 8: Console — Analytics tab and API
2027
2028**Files:**
2029- Create: `internal/console/analyticsview.go` (add to the file from Task 7), `internal/console/apianalytics.go`, `templates/app_analytics.html`
2030- Modify: `server.go` (route `GET /a/{sqid}/apps/{app}/analytics`, page fields), `authcli.go` (cliRoute), `appnav.html`
2031- Test: `internal/console/apianalytics_test.go` (tab + API parts)
2032
2033**Interfaces:**
2034- Produces:
2035 ```go
2036 type analyticsWindow struct { Sum analytics.Summary; Range string; On bool; Setting telemetry.Resolved; ChartBars []chartBar; ChartMax int64 }
2037 type chartBar struct { Label string; Value int64; X, Y, W, H float64 }
2038 func (s *Server) readAnalytics(ctx, sqid, app, rng string, now time.Time) (analyticsWindow, error) // rng: today|7d|30d
2039 func chartBars(vals []int64, labels []string, w, h float64) ([]chartBar, int64)
2040 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
2041 ```
2042 Routes: `GET /api/cli/apps/{account}/{app}/analytics?range=today|7d|30d` (Instance, Operate); page `GET /a/{sqid}/apps/{app}/analytics?range=…`.
2043
2044- [ ] **Step 1: Failing tests**
2045
2046```go
2047func seedAnalytics(t *testing.T, fx *consoleFixture, now time.Time) {
2048 t.Helper()
2049 m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now })
2050 for i := 0; i < 5; i++ {
2051 m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
2052 ContentType: "text/html", Path: "/pricing", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari",
2053 ClientIP: "203.0.113." + strconv.Itoa(i), Bytes: 1000, When: now.Add(-time.Hour)})
2054 }
2055 m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200,
2056 ContentType: "text/html", Path: "/", UA: "Googlebot/2.1", ClientIP: "1.1.1.1", Bytes: 500, When: now.Add(-time.Hour)})
2057 m.Track(release.OpsAccount, "titogo", "signup", now.Add(-time.Hour))
2058 for k, ds := range m.Drain() {
2059 for _, d := range ds {
2060 if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil {
2061 t.Fatal(err)
2062 }
2063 }
2064 }
2065}
2066
2067func TestAPIAnalyticsSummary(t *testing.T) {
2068 fx, token := instanceFixture(t, "code-analytics-api")
2069 now := time.Now().UTC()
2070 seedAnalytics(t, fx, now)
2071 status, _, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=today", token, "")
2072 if status != 200 {
2073 t.Fatalf("%d %s", status, body)
2074 }
2075 var resp analyticsResponse
2076 json.Unmarshal([]byte(body), &resp)
2077 if resp.PV != 5 || resp.Bots != 1 || resp.Bytes != 5500 || resp.Visitors < 4 || resp.Visitors > 6 ||
2078 resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On {
2079 t.Fatalf("resp = %+v", resp)
2080 }
2081 if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 {
2082 t.Fatal("bad range")
2083 }
2084 if st, _, b := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=7d", token, ""); st != 200 {
2085 t.Fatalf("7d: %d %s", st, b)
2086 }
2087}
2088
2089func TestAppAnalyticsPage(t *testing.T) {
2090 fx, _ := instanceFixture(t, "code-analytics-tab")
2091 now := time.Now().UTC()
2092 seedAnalytics(t, fx, now)
2093 fx.setUser("paul@keymail.dev")
2094 c := newBrowser(t)
2095 signUp(t, fx, c, "code-analytics-tab-signin")
2096 status, _, body := get(t, c, fx.u("/apps/titogo/analytics"))
2097 if status != 200 {
2098 t.Fatalf("%d %s", status, body)
2099 }
2100 // Off: the switch and the reading, and the numbers that exist already.
2101 for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "<svg"} {
2102 if !strings.Contains(body, want) {
2103 t.Errorf("page missing %q", want)
2104 }
2105 }
2106 telemetry.Update(context.Background(), fx.rel, telemetry.AppKey(fx.sqid, "titogo"), func(st *telemetry.Settings) {
2107 st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge}
2108 })
2109 _, _, body = get(t, c, fx.u("/apps/titogo/analytics?range=7d"))
2110 if strings.Contains(body, "Analytics is off") || !strings.Contains(body, "daily visitors, summed") || !strings.Contains(body, "carlos analytics --app titogo") {
2111 t.Fatal("on + 7d page")
2112 }
2113}
2114```
2115
2116- [ ] **Step 2: Run, expect failure**
2117
2118- [ ] **Step 3: Implement**
2119
2120`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.
2121
2122`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`.
2123
2124Template `app_analytics.html`:
2125```html
2126{{define "app_analytics"}}{{template "top" .}}
2127{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
2128{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
2129{{template "appnav" .}}
2130<h2>Analytics</h2>
2131{{if .AnalyticsErr}}<p class="banner error">{{.AnalyticsErr}}</p>{{end}}
2132{{if not .AnalyticsWin.On}}
2133<p><strong>Analytics is off for this app</strong> (from the {{.AnalyticsWin.Setting.CountFrom}}).
2134When on, the edge counts HTML page views and daily visitors as it serves them: no JavaScript, no cookie,
2135no stored IP. A visitor is a salted hash that changes every day. Turn it on for this app or for the whole
2136account on the <a href="{{.Base}}/apps/{{.AppName}}/settings">Settings</a> tab.</p>
2137{{end}}
2138<form method="get" class="small">
2139 <label>range
2140 <select name="range">
2141 <option value="today"{{if eq .AnalyticsRange "today"}} selected{{end}}>today</option>
2142 <option value="7d"{{if eq .AnalyticsRange "7d"}} selected{{end}}>7 days</option>
2143 <option value="30d"{{if eq .AnalyticsRange "30d"}} selected{{end}}>30 days</option>
2144 </select>
2145 </label>
2146 <button type="submit">Update</button>
2147</form>
2148{{with .AnalyticsWin}}
2149<div class="tablewrap"><table>
2150 <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>
2151 <tr><td>{{.Sum.PV}}</td><td>{{.Sum.Visitors}}</td><td>{{.Sum.Bytes}}</td><td>{{.Sum.Bots}}</td><td>{{.Sum.Dropped.Flush}}</td></tr>
2152</table></div>
2153{{if .ChartBars}}
2154<svg viewBox="0 0 480 140" width="100%" role="img" aria-label="Page views">
2155 {{range .ChartBars}}<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}" fill="currentColor"><title>{{.Label}}: {{.Value}}</title></rect>{{end}}
2156 <text x="0" y="136" class="small">{{if eq $.AnalyticsRange "today"}}per hour, UTC{{else}}per day{{end}}; peak {{.ChartMax}}</text>
2157</svg>
2158{{end}}
2159{{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}}
2160<h3>Top pages{{if .Sum.PagesApprox}} <span class="small">approximate: more than 500 distinct keys</span>{{end}}</h3>
2161{{template "kvtable" .Sum.Pages}}
2162<h3>Top referers{{if .Sum.ReferersApprox}} <span class="small">approximate</span>{{end}}</h3>
2163{{template "kvtable" .Sum.Referers}}
2164<h3>Named events{{if .Sum.EventsApprox}} <span class="small">approximate</span>{{end}}</h3>
2165{{template "kvtable" .Sum.Events}}
2166{{end}}
2167<p class="small">Times are UTC. The same view from a terminal: <code>carlos analytics --app {{.AppName}} --range {{.AnalyticsRange}}</code>.</p>
2168{{template "bottom" .}}{{end}}
2169{{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}}
2170```
2171Add 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}}`).
2172
2173`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.
2174
2175- [ ] **Step 4: `go test ./internal/console/ -count=1` PASS. Commit** "console: Analytics tab and API (spec §3.6)".
2176
2177---
2178
2179### Task 9: `carlos analytics` and the docs heading
2180
2181**Files:**
2182- Create: `analytics_cmd.go`, `analytics_cmd_test.go`
2183- Modify: `apimode.go` (`apiAnalyticsResponse`, `analyticsGet`), `main.go` (dispatch + help line), `docs/site/cli.md` (`### carlos analytics` after `### carlos errors`)
2184
2185- [ ] **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".
2186
2187- [ ] **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.
2188
2189`docs/site/cli.md`:
2190```markdown
2191### carlos analytics
2192
2193Page views, daily visitors, bytes served, top pages, top referers and named
2194events, counted at the edge as it serves your app: no JavaScript, no cookie,
2195no stored IP. Off until you turn it on, for one app or for the whole account.
2196
2197```sh
2198carlos analytics --app hello --set-count edge
2199carlos analytics --app hello --range 7d
2200```
2201
2202`--range` is `today`, `7d` or `30d`; multi-day visitor totals are daily
2203visitors summed. The same view is the Analytics tab on the app's dashboard,
2204and the switch is on the Settings tab too.
2205```
2206
2207- [ ] **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".
2208
2209---
2210
2211### Task 10: Whole-tree gate, push, branch description
2212
2213- [ ] `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 ./...`.
2214- [ ] Update the spec's Status line: "steps 1 and 2 implemented (step 2 on branch analytics-step2)". Commit.
2215- [ ] `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.
2216- [ ] Do not merge.
2217
2218## Self-review against the spec
2219
2220- §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.
2221- §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.
2222- §4: no new activator call; Task 5's tap is in the existing defer.
2223- 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.
2224- 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.
2225