| 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 |
| 86 | package analytics |
| 87 | |
| 88 | import ( |
| 89 | "hash/fnv" |
| 90 | "math" |
| 91 | "testing" |
| 92 | ) |
| 93 | |
| 94 | func h64(s string) uint64 { f := fnv.New64a(); f.Write([]byte(s)); return f.Sum64() } |
| 95 | |
| 96 | func 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 | |
| 110 | func 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 | |
| 131 | func 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 | |
| 150 | func itoa(i int) string { return string(rune('0'+i%10)) + string(rune('0'+(i/10)%10)) + string(rune('0'+(i/100)%10)) + string(rune('0'+(i/1000)%10)) + string(rune('0'+(i/10000)%10)) + string(rune('0'+(i/100000)%10)) } |
| 151 | ``` |
| 152 | |
| 153 | `topk_test.go`: |
| 154 | ```go |
| 155 | package analytics |
| 156 | |
| 157 | import "testing" |
| 158 | |
| 159 | func 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 | |
| 189 | func 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. |
| 225 | package analytics |
| 226 | |
| 227 | import ( |
| 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. |
| 236 | const HLLPrecision = 11 |
| 237 | const hllRegisters = 1 << HLLPrecision |
| 238 | |
| 239 | // HLL is a HyperLogLog cardinality sketch over 64-bit hashes. |
| 240 | type HLL struct{ reg [hllRegisters]uint8 } |
| 241 | |
| 242 | // Add records one hashed element. |
| 243 | func (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. |
| 253 | func (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. |
| 262 | func (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). |
| 273 | func (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 | |
| 291 | const hllVersion = 1 |
| 292 | |
| 293 | // Encode renders the sketch as base64 of a version byte plus the registers. |
| 294 | func (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). |
| 303 | func 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 |
| 323 | package 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. |
| 329 | const TopKSize = 500 |
| 330 | |
| 331 | // OtherKey is the fold-over row for keys beyond the cap. |
| 332 | const OtherKey = "_other" |
| 333 | |
| 334 | // MaxKeyLen caps a key's bytes. |
| 335 | const MaxKeyLen = 256 |
| 336 | |
| 337 | // TopK is a Misra–Gries summary with TopKSize counters. |
| 338 | type TopK struct { |
| 339 | counts map[string]int64 |
| 340 | other int64 |
| 341 | } |
| 342 | |
| 343 | // NewTopK returns an empty summary. |
| 344 | func 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. |
| 349 | func (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. |
| 386 | func (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. |
| 403 | func (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). |
| 415 | func 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). |
| 428 | func (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 |
| 462 | package analytics |
| 463 | |
| 464 | import "testing" |
| 465 | |
| 466 | func 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 |
| 493 | package analytics |
| 494 | |
| 495 | import ( |
| 496 | "os" |
| 497 | "path/filepath" |
| 498 | "runtime" |
| 499 | "testing" |
| 500 | ) |
| 501 | |
| 502 | func 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 |
| 530 | package analytics |
| 531 | |
| 532 | import ( |
| 533 | "context" |
| 534 | "encoding/json" |
| 535 | "testing" |
| 536 | |
| 537 | "github.com/carlosframework/platform/internal/release" |
| 538 | ) |
| 539 | |
| 540 | func 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 | |
| 549 | func 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 | |
| 578 | func 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 | |
| 604 | func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0) } |
| 605 | func 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 |
| 620 | package analytics |
| 621 | |
| 622 | import "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. |
| 628 | var 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. |
| 640 | func 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 |
| 657 | package analytics |
| 658 | |
| 659 | import ( |
| 660 | "crypto/rand" |
| 661 | "errors" |
| 662 | "os" |
| 663 | "path/filepath" |
| 664 | "strings" |
| 665 | ) |
| 666 | |
| 667 | const 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. |
| 673 | func 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 |
| 710 | package analytics |
| 711 | |
| 712 | import ( |
| 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. |
| 724 | type 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). |
| 731 | type 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). |
| 739 | type 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. |
| 747 | type 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. |
| 762 | type 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. |
| 776 | func Prefix(sqid, app string) string { return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) } |
| 777 | |
| 778 | // BoxPrefix is one box's day files. |
| 779 | func BoxPrefix(sqid, app, box string) string { return Prefix(sqid, app) + box + "/days/" } |
| 780 | |
| 781 | // DayKey is one day file. |
| 782 | func 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. |
| 786 | func (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 | |
| 826 | func 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 | |
| 834 | func 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. |
| 841 | func 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 | |
| 877 | Ensure `Day` zero-value fields marshal as `{}` not `null` for `hours`, `pages`, `referers`, `events`, `seq`, `countries` (initialise nil maps in `Merge`; `mergeTop` returns a non-nil map). |
| 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 |
| 907 | package analytics |
| 908 | |
| 909 | import ( |
| 910 | "testing" |
| 911 | "time" |
| 912 | ) |
| 913 | |
| 914 | func 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 | |
| 920 | func 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 | |
| 948 | func 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 | |
| 997 | func 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 | |
| 1017 | func 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 |
| 1039 | package analytics |
| 1040 | |
| 1041 | import ( |
| 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. |
| 1057 | type 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. |
| 1073 | type 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. |
| 1078 | func 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. |
| 1100 | func 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 | |
| 1112 | var eventName = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`) |
| 1113 | |
| 1114 | // EventName normalises a named event: lowercased, 64 bytes of [a-z0-9_.-]. |
| 1115 | func 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). |
| 1126 | type 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). |
| 1141 | func 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 | |
| 1146 | func (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 | |
| 1159 | func (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. |
| 1176 | func (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). |
| 1204 | func (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. |
| 1216 | func (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. |
| 1241 | func (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 | |
| 1257 | Note: `Requeue` must not re-stamp `Seq`; `Drain` stamps fresh seqs only on current deltas, and re-queued ones keep the seq they were drained with (they may have landed: the day file's seq rule makes the replay a no-op). Re-check the second test: after the first failed flush, the re-queued delta (seq 1) comes back before the new delta (seq 2); on the second failure both are re-queued, `pending[k]` had 0 entries at that moment (Drain cleared it), so nothing is dropped yet — adjust the test's third expectation: the drop happens when a delta is re-queued while `pending` still holds an earlier window, i.e. `Requeue(first)`, then `Requeue(second)` without a Drain between. Rewrite `TestMeterRequeueSurvivesOnce`'s tail to: `m.Requeue(first)`; `m.Requeue(second_without_drain)` — concretely: after `second := m.Drain()`, call `m.Requeue(second)`, then immediately `m.Requeue(map[AppKey][]Delta{k: {obsDelta}})` with a hand-built delta, then `Drain()` and assert the first window is gone and `Dropped.Flush == 2` on the last delta. Write whichever exact sequence matches the implementation and state it in the report. |
| 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 |
| 1281 | package analytics |
| 1282 | |
| 1283 | import ( |
| 1284 | "context" |
| 1285 | "testing" |
| 1286 | "time" |
| 1287 | |
| 1288 | "github.com/carlosframework/platform/internal/release" |
| 1289 | ) |
| 1290 | |
| 1291 | func 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 |
| 1334 | package analytics |
| 1335 | |
| 1336 | import ( |
| 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. |
| 1352 | type 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. |
| 1365 | type 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. |
| 1382 | func 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. |
| 1394 | func 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 | |
| 1454 | func 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 |
| 1487 | package edge |
| 1488 | |
| 1489 | import ( |
| 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 | |
| 1500 | type memAnalytics struct { |
| 1501 | mu sync.Mutex |
| 1502 | obs []analytics.Obs |
| 1503 | } |
| 1504 | |
| 1505 | func (m *memAnalytics) Observe(o analytics.Obs) { m.mu.Lock(); m.obs = append(m.obs, o); m.mu.Unlock() } |
| 1506 | func (m *memAnalytics) snap() []analytics.Obs { |
| 1507 | m.mu.Lock() |
| 1508 | defer m.mu.Unlock() |
| 1509 | return append([]analytics.Obs(nil), m.obs...) |
| 1510 | } |
| 1511 | |
| 1512 | func 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 | |
| 1526 | func 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 | |
| 1585 | If there is no `withHopVerified` helper, add a tiny unexported one in `analytics.go` (`func withHopVerified(ctx context.Context) context.Context { return context.WithValue(ctx, hopVerifiedKey{}, true) }`) matching how `acceptHop` sets the marker (read homemap.go:767-798 for the exact key and value). |
| 1586 | |
| 1587 | - [ ] **Step 2: Run, expect compile failure** |
| 1588 | |
| 1589 | - [ ] **Step 3: Implement `internal/edge/analytics.go`** |
| 1590 | |
| 1591 | ```go |
| 1592 | package edge |
| 1593 | |
| 1594 | import ( |
| 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. |
| 1610 | type AnalyticsMeter interface { |
| 1611 | Observe(analytics.Obs) |
| 1612 | } |
| 1613 | |
| 1614 | type analyticsMeterRef struct{ AnalyticsMeter } |
| 1615 | |
| 1616 | // EnableAnalytics wires the meter in; nil is a valid no-op. |
| 1617 | func (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"). |
| 1628 | func (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 | |
| 1636 | func (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. |
| 1647 | func 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. |
| 1666 | func (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 | |
| 1687 | Add to `Edge`: |
| 1688 | ```go |
| 1689 | analyticsMeter atomic.Pointer[analyticsMeterRef] |
| 1690 | analyticsOn atomic.Pointer[map[string]bool] |
| 1691 | ``` |
| 1692 | and 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 |
| 1722 | package host |
| 1723 | |
| 1724 | import ( |
| 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 | |
| 1733 | func 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 | |
| 1762 | Find the helper host tests use to open a temporary registry (grep `registry.Open(filepath.Join(t.TempDir()` in `internal/host/*_test.go`); use it or open one inline. The route's account column name: check `registry.Route` for the field `release.RouteAccount` reads (`Account`), and set it so `RouteAccount` returns the sqid. |
| 1763 | |
| 1764 | `usage_loop_test.go` append: |
| 1765 | ```go |
| 1766 | func 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. |
| 1802 | package host |
| 1803 | |
| 1804 | import ( |
| 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 | |
| 1814 | var 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. |
| 1818 | func 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. |
| 1824 | func 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. |
| 1854 | func 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 | |
| 1868 | Confirm `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. |
| 1878 | func 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 | |
| 1900 | Pass wiring: in `d.fullPass` (agent.go ~1505) add `telemetryPass(ctx, store, reg)` after `reconcileSerialized`; in `instancesLoop`'s tick add the same call after its reconcile. Both have `store` and `reg` in scope (verify; `instancesLoop(reg)` may open its own store — read its body and use that variable). |
| 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 |
| 1918 | func 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 | |
| 1951 | func 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 | ``` |
| 1971 | Use the form-post and CSRF helpers the existing settings tests use (grep `csrf` in `appsettings_test.go` for how a test obtains the token and posts a form; adapt the two helper names). Check `release.ReadSerial`'s return shape (`serial.go:32`). |
| 1972 | |
| 1973 | - [ ] **Step 2: Run, expect failure** |
| 1974 | |
| 1975 | - [ ] **Step 3: Implement** |
| 1976 | |
| 1977 | In `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 | ``` |
| 1992 | and in the `Update` closure set `st.Errors` when given and merge `st.Analytics` field-by-field (only the keys present, keeping the other). After a successful write with `body.Analytics != nil`, call `s.bumpSerial(ctx)` (box-consumed; the errors level is console-read and needs none). Audit text names what changed. |
| 1993 | |
| 1994 | New handlers (in `analyticsview.go`, mirroring `handleAppErrorLevel`/`handleAccountErrorLevel` exactly, including the nil-Releases guard): read `count` and `door` form values, validate as above (empty = leave unchanged), `telemetry.Update` on `AppKey`/`AccountKey` merging into `st.Analytics`, `s.audit(...)`, `s.bumpSerial(r.Context())`, flash "Analytics counting is now edge for this app" / "…off…", redirect back to the settings page. Routes beside the errors ones. |
| 1995 | |
| 1996 | Page fields and population: beside `ErrLevelSetting`/`ErrLevelUnread` add `AnalyticsSetting telemetry.Resolved` and `AnalyticsUnread bool`; since `Resolve` already returns both halves, reuse the one call: set `p.AnalyticsSetting = p.ErrLevelSetting` and `p.AnalyticsUnread = p.ErrLevelUnread` in both settings handlers (comment that they are one object). |
| 1997 | |
| 1998 | Templates: after the Errors section in both files: |
| 1999 | ```html |
| 2000 | <h2>Analytics</h2> |
| 2001 | {{if .AnalyticsUnread}} |
| 2002 | <p class="small">This app’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 | ``` |
| 2020 | The account form: same fields, action `{{.Base}}/analytics/setting`, copy "for every app without its own setting". The `door` select is NOT rendered in step 2 (no door exists yet); the handlers accept it for step 3. |
| 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 |
| 2047 | func 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 | |
| 2067 | func 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 | |
| 2089 | func 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 | |
| 2124 | Template `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}}). |
| 2134 | When on, the edge counts HTML page views and daily visitors as it serves them: no JavaScript, no cookie, |
| 2135 | no stored IP. A visitor is a salted hash that changes every day. Turn it on for this app or for the whole |
| 2136 | account 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 | ``` |
| 2171 | Add a template func `sortedKV` (map → slice of `{Key string; Value int64}` sorted by value desc, `_other` last) in `server.go`'s funcMap beside `logLines`; fix the `range` variable usage accordingly (`{{range sortedKV .}}<tr><td>{{.Key}}</td><td>{{.Value}}</td></tr>{{end}}`). |
| 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 | |
| 2193 | Page views, daily visitors, bytes served, top pages, top referers and named |
| 2194 | events, counted at the edge as it serves your app: no JavaScript, no cookie, |
| 2195 | no stored IP. Off until you turn it on, for one app or for the whole account. |
| 2196 | |
| 2197 | ```sh |
| 2198 | carlos analytics --app hello --set-count edge |
| 2199 | carlos analytics --app hello --range 7d |
| 2200 | ``` |
| 2201 | |
| 2202 | `--range` is `today`, `7d` or `30d`; multi-day visitor totals are daily |
| 2203 | visitors summed. The same view is the Analytics tab on the app's dashboard, |
| 2204 | and 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 | |