analytics: the meter the edge feeds — pageview rule, visitors, top keys, requeue (spec §3.3)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+381
−0
internal/analytics/meter.go+219 −0internal/analytics/meter_test.go+162 −0
diff --git a/internal/analytics/meter.go b/internal/analytics/meter.go| new file mode 100644 |
| index 000000000..77889c762 |
| --- /dev/null |
| +++ b/internal/analytics/meter.go |
| @@ -0,0 +1,219 @@ |
| +package analytics |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/binary" |
| + "log" |
| + "regexp" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/apperr" |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// Obs is one served response as the edge saw it (spec §3.3). The edge |
| +// fills it from the observer defer; nothing here touches the request. |
| +type Obs struct { |
| + Acct, App, Host string |
| + Method string |
| + Status int |
| + ContentType string |
| + Path string // raw; sanitised here |
| + RefererHost string // host only, "" = direct |
| + UA string |
| + ClientIP string |
| + Bytes int64 |
| + Prefetch bool // Sec-Purpose/Purpose: prefetch |
| + FetchDest string // Sec-Fetch-Dest, "" when absent |
| + When time.Time |
| +} |
| + |
| +// AppKey is the (account, app) the meter buckets by. |
| +type AppKey = applog.AppKey |
| + |
| +// IsPageview applies §3.3's rule: GET, 200, text/html, not prefetch, dest |
| +// document or absent, not under /__carlos/. A bot UA is reported |
| +// separately and never a pageview. |
| +func IsPageview(o Obs) (pageview, bot bool) { |
| + if o.Method != "GET" || o.Status != 200 { |
| + return false, false |
| + } |
| + mt := strings.ToLower(strings.TrimSpace(strings.SplitN(o.ContentType, ";", 2)[0])) |
| + if mt != "text/html" { |
| + return false, false |
| + } |
| + if o.Prefetch || (o.FetchDest != "" && o.FetchDest != "document") { |
| + return false, false |
| + } |
| + if strings.HasPrefix(o.Path, "/__carlos/") { |
| + return false, false |
| + } |
| + if IsBot(o.UA) { |
| + return false, true |
| + } |
| + return true, false |
| +} |
| + |
| +// VisitorHash is sha256(salt ‖ host ‖ ip ‖ ua) folded to 64 bits for the |
| +// sketch; the platform never stores the inputs. |
| +func VisitorHash(salt []byte, host, ip, ua string) uint64 { |
| + h := sha256.New() |
| + h.Write(salt) |
| + h.Write([]byte{0}) |
| + h.Write([]byte(host)) |
| + h.Write([]byte{0}) |
| + h.Write([]byte(ip)) |
| + h.Write([]byte{0}) |
| + h.Write([]byte(ua)) |
| + return binary.BigEndian.Uint64(h.Sum(nil)[:8]) |
| +} |
| + |
| +var eventName = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`) |
| + |
| +// EventName normalises a named event: lowercased, ≤64 bytes of [a-z0-9_.-]. |
| +func EventName(s string) (string, bool) { |
| + s = strings.ToLower(strings.TrimSpace(s)) |
| + if !eventName.MatchString(s) { |
| + return "", false |
| + } |
| + return s, true |
| +} |
| + |
| +// Meter accumulates per-app deltas between flushes. One per edge process; |
| +// Observe sits on the serving path (one mutex, no I/O except the first |
| +// salt read of a day). |
| +type Meter struct { |
| + box string |
| + saltDir string |
| + now func() time.Time |
| + |
| + mu sync.Mutex |
| + cur map[AppKey]map[string]*Delta // app → date → delta being built |
| + pending map[AppKey][]Delta // re-queued after a failed flush (one window) |
| + seq int64 |
| + saltDay string |
| + salt []byte |
| + started string // date the process started: Restarted is set on that day's first delta |
| +} |
| + |
| +// NewMeter returns a meter whose salts live in saltDir (the box data dir). |
| +func NewMeter(saltDir, box string, now func() time.Time) *Meter { |
| + return &Meter{box: box, saltDir: saltDir, now: now, cur: map[AppKey]map[string]*Delta{}, |
| + pending: map[AppKey][]Delta{}, seq: now().UnixMilli(), started: usage.DayOf(now())} |
| +} |
| + |
| +func (m *Meter) saltFor(date string) []byte { |
| + if m.saltDay == date && m.salt != nil { |
| + return m.salt |
| + } |
| + s, err := DaySalt(m.saltDir, date) |
| + if err != nil { |
| + log.Printf("analytics: day salt: %v (visitors not counted until it works)", err) |
| + return nil |
| + } |
| + m.saltDay, m.salt = date, s |
| + return s |
| +} |
| + |
| +func (m *Meter) delta(k AppKey, date string) *Delta { |
| + days := m.cur[k] |
| + if days == nil { |
| + days = map[string]*Delta{} |
| + m.cur[k] = days |
| + } |
| + d := days[date] |
| + if d == nil { |
| + d = &Delta{Date: date, Box: m.box, Hours: map[string]HourRow{}, Visitors: &HLL{}, |
| + Pages: NewTopK(), Referers: NewTopK(), Events: NewTopK(), Restarted: date == m.started} |
| + days[date] = d |
| + } |
| + return d |
| +} |
| + |
| +// Observe counts one response. Bytes count for every response; pageview, |
| +// bot, visitor, page and referer only when the rule says so. |
| +func (m *Meter) Observe(o Obs) { |
| + pv, bot := IsPageview(o) |
| + date, hour := usage.DayOf(o.When), usage.HourLabel(o.When) |
| + path := apperr.SanitizePath(o.Path) |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + d := m.delta(AppKey{Acct: o.Acct, App: o.App}, date) |
| + row := d.Hours[hour] |
| + row.Bytes += o.Bytes |
| + if bot { |
| + row.Bots++ |
| + } |
| + if pv { |
| + row.PV++ |
| + if salt := m.saltFor(date); salt != nil { |
| + d.Visitors.Add(VisitorHash(salt, o.Host, o.ClientIP, o.UA)) |
| + } |
| + d.Pages.Add(path, 1) |
| + ref := o.RefererHost |
| + if ref == "" { |
| + ref = "direct" |
| + } |
| + d.Referers.Add(strings.ToLower(ref), 1) |
| + } |
| + d.Hours[hour] = row |
| +} |
| + |
| +// Track counts one named event (the doors call it; nothing does in step 2). |
| +func (m *Meter) Track(acct, app, name string, when time.Time) { |
| + n, ok := EventName(name) |
| + if !ok { |
| + return |
| + } |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.delta(AppKey{Acct: acct, App: app}, usage.DayOf(when)).Events.Add(n, 1) |
| +} |
| + |
| +// Drain returns everything buffered — re-queued deltas first, then the |
| +// current ones, each stamped with a fresh box-monotonic seq — and resets. |
| +func (m *Meter) Drain() map[AppKey][]Delta { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + out := map[AppKey][]Delta{} |
| + for k, ds := range m.pending { |
| + out[k] = append(out[k], ds...) |
| + } |
| + m.pending = map[AppKey][]Delta{} |
| + for k, days := range m.cur { |
| + for _, d := range days { |
| + m.seq++ |
| + d.Seq = m.seq |
| + out[k] = append(out[k], *d) |
| + } |
| + } |
| + m.cur = map[AppKey]map[string]*Delta{} |
| + if m.started != "" && usage.DayOf(m.now()) != m.started { |
| + m.started = "" // the restart note is only for the day it happened |
| + } |
| + return out |
| +} |
| + |
| +// Requeue holds a failed flush for the next drain. One window survives: a |
| +// window already sitting in pending when Requeue is called again (i.e. no |
| +// successful Drain emptied it in between) is dropped and its delta count |
| +// is added to Dropped.Flush on the newest delta of the incoming batch. |
| +// Seq is left untouched — a re-queued delta keeps the seq it was drained |
| +// with, so a replay that actually landed is a no-op at the day file. |
| +func (m *Meter) Requeue(failed map[AppKey][]Delta) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + for k, ds := range failed { |
| + if n := len(m.pending[k]); n > 0 { |
| + dropped := int64(n) |
| + m.pending[k] = nil |
| + if len(ds) > 0 { |
| + ds[len(ds)-1].Dropped.Flush += dropped |
| + } |
| + } |
| + m.pending[k] = append(m.pending[k], ds...) |
| + } |
| +} |
diff --git a/internal/analytics/meter_test.go b/internal/analytics/meter_test.go| new file mode 100644 |
| index 000000000..a6445bb6d |
| --- /dev/null |
| +++ b/internal/analytics/meter_test.go |
| @@ -0,0 +1,162 @@ |
| +package analytics |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func obs(method string, status int, ctype string) Obs { |
| + return Obs{Acct: "bab", App: "web", Host: "h1", Method: method, Status: status, ContentType: ctype, |
| + Path: "/pricing?x=1", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.5", |
| + Bytes: 1200, When: time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)} |
| +} |
| + |
| +func TestIsPageviewRule(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + o Obs |
| + pv bool |
| + bot bool |
| + }{ |
| + {"html 200", obs("GET", 200, "text/html; charset=utf-8"), true, false}, |
| + {"json", obs("GET", 200, "application/json"), false, false}, |
| + {"404", obs("GET", 404, "text/html"), false, false}, |
| + {"redirect", obs("GET", 302, "text/html"), false, false}, |
| + {"304", obs("GET", 304, ""), false, false}, |
| + {"head", obs("HEAD", 200, "text/html"), false, false}, |
| + {"post", obs("POST", 200, "text/html"), false, false}, |
| + {"prefetch", func() Obs { o := obs("GET", 200, "text/html"); o.Prefetch = true; return o }(), false, false}, |
| + {"fetch dest empty", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "empty"; return o }(), false, false}, |
| + {"fetch dest document", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "document"; return o }(), true, false}, |
| + {"carlos prefix", func() Obs { o := obs("GET", 200, "text/html"); o.Path = "/__carlos/a"; return o }(), false, false}, |
| + {"bot", func() Obs { o := obs("GET", 200, "text/html"); o.UA = "Googlebot/2.1"; return o }(), false, true}, |
| + } |
| + for _, c := range cases { |
| + pv, bot := IsPageview(c.o) |
| + if pv != c.pv || bot != c.bot { |
| + t.Errorf("%s: pv=%v bot=%v, want %v %v", c.name, pv, bot, c.pv, c.bot) |
| + } |
| + } |
| +} |
| + |
| +func TestMeterCountsAndDrains(t *testing.T) { |
| + now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) |
| + m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) |
| + m.Observe(obs("GET", 200, "text/html")) |
| + m.Observe(obs("GET", 200, "text/html")) |
| + o := obs("GET", 200, "text/html") |
| + o.ClientIP = "198.51.100.9" |
| + m.Observe(o) |
| + m.Observe(obs("GET", 200, "application/json")) // bytes only |
| + b := obs("GET", 200, "text/html") |
| + b.UA = "curl/8" |
| + m.Observe(b) |
| + m.Track("bab", "web", "Signup", now) |
| + m.Track("bab", "web", "bad name!", now) // rejected |
| + out := m.Drain() |
| + ds := out[AppKey{Acct: "bab", App: "web"}] |
| + if len(ds) != 1 { |
| + t.Fatalf("deltas = %d", len(ds)) |
| + } |
| + d := ds[0] |
| + if d.Date != "2026-09-07" || d.Box != "box1" || d.Seq == 0 { |
| + t.Fatalf("delta = %+v", d) |
| + } |
| + if h := d.Hours["09"]; h.PV != 3 || h.Bots != 1 || h.Bytes != 6000 { |
| + t.Fatalf("hour = %+v", h) |
| + } |
| + if c := d.Visitors.Count(); c < 1.5 || c > 2.5 { |
| + t.Fatalf("visitors ≈ 2, got %.2f", c) |
| + } |
| + if p := d.Pages.Fold(); p["/pricing"] != 3 { |
| + t.Fatalf("pages = %v", p) |
| + } |
| + if r := d.Referers.Fold(); r["news.ycombinator.com"] != 3 { |
| + t.Fatalf("referers = %v", r) |
| + } |
| + if e := d.Events.Fold(); e["signup"] != 1 || len(e) != 1 { |
| + t.Fatalf("events = %v", e) |
| + } |
| + if len(m.Drain()) != 0 { |
| + t.Fatal("second drain must be empty") |
| + } |
| + // Event-time bucketing: an observation at 09:59 flushed at 10:00 lands in 09. |
| + m.Observe(func() Obs { o := obs("GET", 200, "text/html"); o.When = now.Add(29 * time.Minute); return o }()) |
| + now = now.Add(31 * time.Minute) |
| + if d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0]; d.Hours["09"].PV != 1 || d.Hours["10"].PV != 0 { |
| + t.Fatalf("event-time bucket: %+v", d.Hours) |
| + } |
| +} |
| + |
| +// TestMeterRequeueSurvivesOnce pins down the exact requeue semantics: |
| +// |
| +// 1. Requeue(first) after a failed flush: first's delta is held in |
| +// pending — nothing was pending before, so nothing is dropped. |
| +// 2. A normal Drain (no Requeue in between, so pending got emptied by |
| +// the Drain call itself) returns the held delta ahead of the |
| +// newly-current one, each keeping its own Seq — the re-queued delta |
| +// keeps the seq it was drained with (so a replay that actually landed |
| +// is a no-op at the day file, via Day.Merge's seq gate), while the |
| +// current delta gets a fresh, higher seq. |
| +// 3. Requeue(second) holds both of second's deltas in pending. Because |
| +// Drain had just cleared pending, this call finds nothing already |
| +// there and drops nothing yet. |
| +// 4. A second Requeue call — this time with a freshly built delta, |
| +// fired with NO Drain in between — finds second's two deltas still |
| +// sitting in pending from step 3. That whole older window is dropped; |
| +// its count (2) is added to Dropped.Flush on the newest delta in the |
| +// incoming batch, and only the incoming batch is kept in pending. |
| +// 5. A Drain() with nothing newly observed returns exactly that one |
| +// surviving, Dropped.Flush-stamped delta: the older window is gone. |
| +func TestMeterRequeueSurvivesOnce(t *testing.T) { |
| + now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) |
| + m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) |
| + k := AppKey{Acct: "bab", App: "web"} |
| + |
| + m.Observe(obs("GET", 200, "text/html")) |
| + first := m.Drain() |
| + m.Requeue(first) // flush failed |
| + |
| + m.Observe(obs("GET", 200, "text/html")) |
| + second := m.Drain() |
| + ds := second[k] |
| + if len(ds) != 2 || ds[0].Seq >= ds[1].Seq { |
| + t.Fatalf("requeued delta must come back first with its own seq: %+v", ds) |
| + } |
| + if ds[0].Seq != first[k][0].Seq { |
| + t.Fatalf("requeued delta must keep its original seq: got %d, want %d", ds[0].Seq, first[k][0].Seq) |
| + } |
| + |
| + m.Requeue(second) // failed again: pending was empty, so nothing drops yet |
| + |
| + // A further failure before any successful Drain: pending still holds |
| + // second's two deltas from the call above, so this Requeue drops that |
| + // whole older window onto the newest delta of the incoming batch. |
| + handBuilt := Delta{Date: "2026-09-07", Box: "box1", Seq: 999, |
| + Hours: map[string]HourRow{}, Visitors: &HLL{}, Pages: NewTopK(), Referers: NewTopK(), Events: NewTopK()} |
| + m.Requeue(map[AppKey][]Delta{k: {handBuilt}}) |
| + |
| + third := m.Drain()[k] |
| + if len(third) != 1 || third[0].Dropped.Flush != 2 { |
| + t.Fatalf("requeue-while-pending must drop the older window and count both its deltas: %+v", third) |
| + } |
| + if third[0].Seq != handBuilt.Seq { |
| + t.Fatalf("surviving window must be the most recently re-queued one: %+v", third) |
| + } |
| +} |
| + |
| +func TestVisitorHashAndEventName(t *testing.T) { |
| + s1, s2 := []byte("salt-a"), []byte("salt-b") |
| + if VisitorHash(s1, "h", "ip", "ua") != VisitorHash(s1, "h", "ip", "ua") { |
| + t.Fatal("deterministic") |
| + } |
| + if VisitorHash(s1, "h", "ip", "ua") == VisitorHash(s2, "h", "ip", "ua") { |
| + t.Fatal("salt must change the hash") |
| + } |
| + for in, want := range map[string]string{"Signup": "signup", "checkout.done": "checkout.done", "a b": "", "": "", "x/y": ""} { |
| + got, ok := EventName(in) |
| + if (want == "") == ok || got != want { |
| + t.Errorf("EventName(%q) = %q,%v", in, got, ok) |
| + } |
| + } |
| +} |