analytics: the meter records door drops by door and reason
Meter.Drop(acct, app, door, reason, when) counts one refused browser beacon into the app's current-day Dropped block (spec §3.4): DoorErrors increments Dropped.E, DoorEvents increments Dropped.A, and the reason (DropRate/DropSize/DropOrigin/DropMalformed) picks the field. An unknown door or reason counts nothing rather than panicking, since a typo must never take down the serving path. edge.AnalyticsMeter grows Track and Drop alongside Observe so the browser door (task 7) can call them through the same interface; analytics.Meter already satisfies it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+98
−4
internal/analytics/meter.go+40 −0internal/analytics/meter_test.go+19 −0internal/edge/analytics.go+6 −2internal/edge/analytics_test.go+33 −2
diff --git a/internal/analytics/meter.go b/internal/analytics/meter.go| index 50d1d5baa..7bc1a4b8b 100644 |
| --- a/internal/analytics/meter.go |
| +++ b/internal/analytics/meter.go |
| @@ -87,6 +87,18 @@ func EventName(s string) (string, bool) { |
| // on every request — one attempt, and one log line, per window. |
| const saltRetryDelay = 30 * time.Second |
| +// Doors and drop reasons (spec §3.4): the browser door names them when it |
| +// refuses a beacon; the day file's Dropped block carries the counts. |
| +const ( |
| + DoorErrors = 'e' |
| + DoorEvents = 'a' |
| + |
| + DropRate = "rate" |
| + DropSize = "size" |
| + DropOrigin = "origin" |
| + DropMalformed = "malformed" |
| +) |
| + |
| // 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). |
| @@ -202,6 +214,34 @@ func (m *Meter) Track(acct, app, name string, when time.Time) { |
| d.Events.Add(n, 1) |
| } |
| +// Drop counts one refused beacon for the app's current day. An unknown |
| +// door or reason counts nothing: the caller is this package's own door |
| +// constants, and a typo must not panic the serving path. |
| +func (m *Meter) Drop(acct, app string, door byte, reason string, when time.Time) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + d := m.delta(AppKey{Acct: acct, App: app}, usage.DayOf(when)) |
| + var c *DropCounts |
| + switch door { |
| + case DoorErrors: |
| + c = &d.Dropped.E |
| + case DoorEvents: |
| + c = &d.Dropped.A |
| + default: |
| + return |
| + } |
| + switch reason { |
| + case DropRate: |
| + c.Rate++ |
| + case DropSize: |
| + c.Size++ |
| + case DropOrigin: |
| + c.Origin++ |
| + case DropMalformed: |
| + c.Malformed++ |
| + } |
| +} |
| + |
| // Drain returns everything buffered — re-queued deltas first, then the |
| // current ones, each stamped with a fresh box-monotonic seq — and resets. |
| // A window an earlier Requeue had to drop (because it was already marked |
diff --git a/internal/analytics/meter_test.go b/internal/analytics/meter_test.go| index a485da65e..545e63e7d 100644 |
| --- a/internal/analytics/meter_test.go |
| +++ b/internal/analytics/meter_test.go |
| @@ -157,6 +157,25 @@ func TestMeterTrackMalformedCountsDropped(t *testing.T) { |
| } |
| } |
| +func TestMeterDropCountsByDoorAndReason(t *testing.T) { |
| + now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| + m := NewMeter(t.TempDir(), "box1", func() time.Time { return now }) |
| + m.Drop("acct", "web", DoorErrors, DropRate, now) |
| + m.Drop("acct", "web", DoorErrors, DropSize, now) |
| + m.Drop("acct", "web", DoorEvents, DropOrigin, now) |
| + m.Drop("acct", "web", DoorEvents, DropMalformed, now) |
| + m.Drop("acct", "web", DoorEvents, DropMalformed, now) |
| + m.Drop("acct", "web", 'x', DropRate, now) // unknown door: ignored, never a panic |
| + ds := m.Drain()[AppKey{Acct: "acct", App: "web"}] |
| + if len(ds) != 1 { |
| + t.Fatalf("deltas %d", len(ds)) |
| + } |
| + d := ds[0].Dropped |
| + if d.E.Rate != 1 || d.E.Size != 1 || d.A.Origin != 1 || d.A.Malformed != 2 || d.E.Origin != 0 { |
| + t.Fatalf("dropped %+v", d) |
| + } |
| +} |
| + |
| // 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 |
diff --git a/internal/edge/analytics.go b/internal/edge/analytics.go| index 29ae7853f..809d5b55c 100644 |
| --- a/internal/edge/analytics.go |
| +++ b/internal/edge/analytics.go |
| @@ -15,10 +15,14 @@ import ( |
| ) |
| // AnalyticsMeter is where the edge files one observation per response |
| -// for an app whose analytics are switched on (spec §3.3). analytics.Meter |
| -// satisfies it; the interface keeps the dependency on the contract. |
| +// for an app whose analytics are switched on (spec §3.3), plus the |
| +// browser door's named events and refusals (spec §3.4). analytics.Meter |
| +// satisfies it; the door calls Track and Drop, the observer calls |
| +// Observe. |
| type AnalyticsMeter interface { |
| Observe(analytics.Obs) |
| + Track(acct, app, name string, when time.Time) |
| + Drop(acct, app string, door byte, reason string, when time.Time) |
| } |
| type analyticsMeterRef struct{ AnalyticsMeter } |
diff --git a/internal/edge/analytics_test.go b/internal/edge/analytics_test.go| index 4aeade5b1..bd403772f 100644 |
| --- a/internal/edge/analytics_test.go |
| +++ b/internal/edge/analytics_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "net/http/httptest" |
| "sync" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -12,9 +13,18 @@ import ( |
| "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| +type trackCall struct{ Acct, App, Name string } |
| +type dropCall struct { |
| + Acct, App string |
| + Door byte |
| + Reason string |
| +} |
| + |
| type memAnalytics struct { |
| - mu sync.Mutex |
| - obs []analytics.Obs |
| + mu sync.Mutex |
| + obs []analytics.Obs |
| + tracks []trackCall |
| + drops []dropCall |
| } |
| func (m *memAnalytics) Observe(o analytics.Obs) { m.mu.Lock(); m.obs = append(m.obs, o); m.mu.Unlock() } |
| @@ -24,6 +34,27 @@ func (m *memAnalytics) snap() []analytics.Obs { |
| return append([]analytics.Obs(nil), m.obs...) |
| } |
| +func (m *memAnalytics) Track(acct, app, name string, _ time.Time) { |
| + m.mu.Lock() |
| + m.tracks = append(m.tracks, trackCall{acct, app, name}) |
| + m.mu.Unlock() |
| +} |
| +func (m *memAnalytics) Drop(acct, app string, door byte, reason string, _ time.Time) { |
| + m.mu.Lock() |
| + m.drops = append(m.drops, dropCall{acct, app, door, reason}) |
| + m.mu.Unlock() |
| +} |
| +func (m *memAnalytics) snapTracks() []trackCall { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return append([]trackCall(nil), m.tracks...) |
| +} |
| +func (m *memAnalytics) snapDrops() []dropCall { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return append([]dropCall(nil), m.drops...) |
| +} |
| + |
| func TestClientIP(t *testing.T) { |
| r := httptest.NewRequest("GET", "http://h/", nil) |
| r.RemoteAddr = "203.0.113.5:4444" |