carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev fd9a1211c3cb25e841e1fef3137d3b05dd275662 parent 2978aab
4 files changed, +98 −4
  • internal/analytics/meter.go +40 −0
  • internal/analytics/meter_test.go +19 −0
  • internal/edge/analytics.go +6 −2
  • internal/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"