package analytics import ( "path/filepath" "testing" "time" ) func obs(method string, status int, ctype string) Obs { return Obs{Acct: "bab", App: "web", Host: "h1", Method: method, Status: status, ContentType: ctype, Path: "/pricing?x=1", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.5", Bytes: 1200, When: time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC)} } func TestIsPageviewRule(t *testing.T) { cases := []struct { name string o Obs pv bool bot bool }{ {"html 200", obs("GET", 200, "text/html; charset=utf-8"), true, false}, {"json", obs("GET", 200, "application/json"), false, false}, {"404", obs("GET", 404, "text/html"), false, false}, {"redirect", obs("GET", 302, "text/html"), false, false}, {"304", obs("GET", 304, ""), false, false}, {"head", obs("HEAD", 200, "text/html"), false, false}, {"post", obs("POST", 200, "text/html"), false, false}, {"prefetch", func() Obs { o := obs("GET", 200, "text/html"); o.Prefetch = true; return o }(), false, false}, {"fetch dest empty", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "empty"; return o }(), false, false}, {"fetch dest document", func() Obs { o := obs("GET", 200, "text/html"); o.FetchDest = "document"; return o }(), true, false}, {"carlos prefix", func() Obs { o := obs("GET", 200, "text/html"); o.Path = "/__carlos/a"; return o }(), false, false}, {"bot", func() Obs { o := obs("GET", 200, "text/html"); o.UA = "Googlebot/2.1"; return o }(), false, true}, } for _, c := range cases { pv, bot := IsPageview(c.o) if pv != c.pv || bot != c.bot { t.Errorf("%s: pv=%v bot=%v, want %v %v", c.name, pv, bot, c.pv, c.bot) } } } func TestMeterCountsAndDrains(t *testing.T) { now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) m.Observe(obs("GET", 200, "text/html")) m.Observe(obs("GET", 200, "text/html")) o := obs("GET", 200, "text/html") o.ClientIP = "198.51.100.9" m.Observe(o) m.Observe(obs("GET", 200, "application/json")) // bytes only b := obs("GET", 200, "text/html") b.UA = "curl/8" m.Observe(b) m.Track("bab", "web", "Signup", now) m.Track("bab", "web", "bad name!", now) // rejected out := m.Drain() ds := out[AppKey{Acct: "bab", App: "web"}] if len(ds) != 1 { t.Fatalf("deltas = %d", len(ds)) } d := ds[0] if d.Date != "2026-09-07" || d.Box != "box1" || d.Seq == 0 { t.Fatalf("delta = %+v", d) } if h := d.Hours["09"]; h.PV != 3 || h.Bots != 1 || h.Bytes != 6000 { t.Fatalf("hour = %+v", h) } if c := d.Visitors.Count(); c < 1.5 || c > 2.5 { t.Fatalf("visitors ≈ 2, got %.2f", c) } if p := d.Pages.Fold(); p["/pricing"] != 3 { t.Fatalf("pages = %v", p) } if r := d.Referers.Fold(); r["news.ycombinator.com"] != 3 { t.Fatalf("referers = %v", r) } if e := d.Events.Fold(); e["signup"] != 1 || len(e) != 1 { t.Fatalf("events = %v", e) } if d.Dropped.A.Malformed != 1 { t.Fatalf("bad event name must count Dropped.A.Malformed, not vanish: %+v", d.Dropped) } if len(m.Drain()) != 0 { t.Fatal("second drain must be empty") } // Event-time bucketing: an observation at 09:59 flushed at 10:00 lands in 09. m.Observe(func() Obs { o := obs("GET", 200, "text/html"); o.When = now.Add(29 * time.Minute); return o }()) now = now.Add(31 * time.Minute) if d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0]; d.Hours["09"].PV != 1 || d.Hours["10"].PV != 0 { t.Fatalf("event-time bucket: %+v", d.Hours) } } // TestMeterRequeueSurvivesOnce exercises the production caller's actual // sequence — Drain, flush, Requeue on failure, repeat — where pending is // always empty at the moment Requeue is called (the prior Drain just // cleared it). The "already re-queued once" state has to live on the // delta itself, not on pending's emptiness, or nothing is ever dropped // and pending grows without bound across repeated failures. Each delta // may survive exactly one failed flush: a second failure for the same // delta drops it and counts the drop (once the delta is marked, a second // Requeue drops it) on the next delta that reaches the app's bucket — // bounded forever at two windows per app no matter how many flushes in a // row fail. func TestMeterRequeueSurvivesOnce(t *testing.T) { now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) k := AppKey{Acct: "bab", App: "web"} m.Observe(obs("GET", 200, "text/html")) d1 := m.Drain() m.Requeue(d1) // flush failed: d1's delta is marked and held m.Observe(obs("GET", 200, "text/html")) d2 := m.Drain() // = d1's delta (marked, own seq) + the new one (fresh seq) if ds := d2[k]; len(ds) != 2 || ds[0].Seq >= ds[1].Seq { t.Fatalf("d2 must be the requeued delta then the new one, in seq order: %+v", ds) } m.Requeue(d2) // failed again: d1's delta is already marked, so it drops; d2's new delta is marked and held m.Observe(obs("GET", 200, "text/html")) d3 := m.Drain()[k] if len(d3) != 2 { t.Fatalf("d1's window must be gone, leaving d2's surviving delta plus the new one: %+v", d3) } if d3[0].Dropped.Flush != 1 { t.Fatalf("the drop must be reported on the delta that reaches the bucket: %+v", d3) } if d3[0].Seq != d2[k][1].Seq { t.Fatalf("the surviving delta must keep the seq it had in d2, not be re-stamped: got %d, want %d", d3[0].Seq, d2[k][1].Seq) } if d3[1].Seq <= d3[0].Seq { t.Fatalf("the new delta must get a fresh, higher seq: %+v", d3) } // A third failure in a row: bounded forever at two windows per app. m.Requeue(map[AppKey][]Delta{k: d3}) m.Observe(obs("GET", 200, "text/html")) d4 := m.Drain()[k] if len(d4) != 2 || d4[0].Dropped.Flush != 1 { t.Fatalf("repeated failures must stay bounded at two windows, each drop reported once: %+v", d4) } } // TestMeterTrackMalformedCountsDropped: an invalid event name must not // vanish silently — it counts against the app's current-day // Dropped.A.Malformed. func TestMeterTrackMalformedCountsDropped(t *testing.T) { now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) m.Track("bab", "web", "not a valid name!", now) m.Track("bab", "web", "also/not valid", now) d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0] if d.Dropped.A.Malformed != 2 { t.Fatalf("Dropped.A.Malformed = %d, want 2", d.Dropped.A.Malformed) } } // TestMeterSaltFailureIsThrottled: a persistently failing DaySalt must not // be retried on every request — pageviews still count, but visitors stay // uncounted while it fails, without blocking or panicking on the // serving path. func TestMeterSaltFailureIsThrottled(t *testing.T) { now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) saltDir := filepath.Join(t.TempDir(), "missing", "nested") // DaySalt can't create its file here m := NewMeter(saltDir, "box1", func() time.Time { return now }) m.Observe(obs("GET", 200, "text/html")) // salt lookup fails m.Observe(obs("GET", 200, "text/html")) // within the backoff window: not retried now = now.Add(31 * time.Second) m.Observe(obs("GET", 200, "text/html")) // backoff elapsed: retried (and fails again, saltDir still missing) d := m.Drain()[AppKey{Acct: "bab", App: "web"}][0] if d.Hours["09"].PV != 3 { t.Fatalf("pageviews must still count while the salt fails: %+v", d.Hours) } if c := d.Visitors.Count(); c != 0 { t.Fatalf("visitors must stay uncounted while the salt fails, got %.2f", c) } } // TestMeterRestartedPerAppOnStartDay: Restarted is per (app, day), not per // process. Every app observed on the start day gets its own first-delta // flag (the "counts since the last flush may be missing" note is true for // each app independently); a later drain of the same start day must not // repeat it; and it never fires once the wall clock has moved past the // start day. func TestMeterRestartedPerAppOnStartDay(t *testing.T) { now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) appA := AppKey{Acct: "bab", App: "web"} appB := AppKey{Acct: "other", App: "app2"} m.Observe(obs("GET", 200, "text/html")) // app A's first delta of the start day o := obs("GET", 200, "text/html") o.Acct, o.App = appB.Acct, appB.App m.Observe(o) // app B's first delta of the start day out := m.Drain() if !out[appA][0].Restarted { t.Fatal("app A's first delta of the start day must carry Restarted") } if !out[appB][0].Restarted { t.Fatal("app B's first delta of the start day must also carry Restarted: the flag is per app-day, not per process") } // A second drain of the same start day for app A must not repeat the note. m.Observe(obs("GET", 200, "text/html")) out = m.Drain() if out[appA][0].Restarted { t.Fatal("app A's second delta of the start day must not be flagged again") } // The next day, nothing is flagged: the note belongs to the start day. now = time.Date(2026, 9, 8, 9, 30, 0, 0, time.UTC) next := obs("GET", 200, "text/html") next.When = now m.Observe(next) out = m.Drain() if out[appA][0].Restarted { t.Fatal("a day after the start day must never carry Restarted") } } func TestVisitorHashAndEventName(t *testing.T) { s1, s2 := []byte("salt-a"), []byte("salt-b") if VisitorHash(s1, "h", "ip", "ua") != VisitorHash(s1, "h", "ip", "ua") { t.Fatal("deterministic") } if VisitorHash(s1, "h", "ip", "ua") == VisitorHash(s2, "h", "ip", "ua") { t.Fatal("salt must change the hash") } for in, want := range map[string]string{"Signup": "signup", "checkout.done": "checkout.done", "a b": "", "": "", "x/y": ""} { got, ok := EventName(in) if (want == "") == ok || got != want { t.Errorf("EventName(%q) = %q,%v", in, got, ok) } } }