analytics: the switch map carries count and door together
telemetry.Resolved gains Switches(), collapsing Count/Door into the
two booleans a box actually acts on: Switches{Count, Door bool}.
The edge's per-app switch map moves from map[string]bool (count only)
to map[string]telemetry.Switches. Edge.SetAnalyticsOn is replaced by
SetSwitches (same copy semantics: the stored map is read by every
serving goroutine without a lock, so a caller must not go on writing
to a map it handed in — that would be a fatal concurrent map read and
write). Edge.switchesFor(rt) returns the zero Switches when a pair is
unset; analyticsEnabled(rt) is now e.switchesFor(rt).Count.
host/telemetry_pass.go's telemetrySwitches resolves both switches per
(account, app) and leaves a pair that fails to resolve absent from the
map, which reads back as the zero value — count off, door closed, the
safe side of both. agent.go wires setAnalyticsSink(e.SetSwitches).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+102
−38
internal/edge/analytics.go+23 −15internal/edge/analytics_test.go+20 −4internal/edge/edge.go+9 −7internal/host/agent.go+1 −1internal/host/telemetry_pass.go+9 −10internal/host/telemetry_pass_test.go+19 −1internal/telemetry/telemetry.go+10 −0internal/telemetry/telemetry_test.go+11 −0
diff --git a/internal/edge/analytics.go b/internal/edge/analytics.go| index 16c1f1df9..29ae7853f 100644 |
| --- a/internal/edge/analytics.go |
| +++ b/internal/edge/analytics.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| // AnalyticsMeter is where the edge files one observation per response |
| @@ -31,34 +32,41 @@ func (e *Edge) EnableAnalytics(m AnalyticsMeter) { |
| e.analyticsMeter.Store(&analyticsMeterRef{m}) |
| } |
| -// SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The |
| -// host's telemetry pass pushes it on every serial-triggered full pass and |
| -// every five minutes (spec §3.3, "converged by boxes on the next pass"). |
| -// The map is COPIED before it is stored, so a caller may keep and reuse |
| -// the map it passed: the stored copy is read by every serving goroutine |
| -// without a lock, and a caller that went on writing to a shared map would |
| -// turn that into Go's fatal concurrent map read-and-write — an edge-wide |
| -// crash, not a race one recovers from. |
| -func (e *Edge) SetAnalyticsOn(on map[string]bool) { |
| +// SetSwitches replaces the switch map: sqid+"/"+app → the app's count and |
| +// door switches together. The host's telemetry pass pushes it on every |
| +// serial-triggered full pass and every five minutes (spec §3.3, |
| +// "converged by boxes on the next pass"). The map is COPIED before it is |
| +// stored, so a caller may keep and reuse the map it passed: the stored |
| +// copy is read by every serving goroutine without a lock, and a caller |
| +// that went on writing to a shared map would turn that into Go's fatal |
| +// concurrent map read-and-write — an edge-wide crash, not a race one |
| +// recovers from. |
| +func (e *Edge) SetSwitches(on map[string]telemetry.Switches) { |
| if on == nil { |
| - e.analyticsOn.Store(nil) |
| + e.switches.Store(nil) |
| return |
| } |
| - cp := make(map[string]bool, len(on)) |
| + cp := make(map[string]telemetry.Switches, len(on)) |
| for k, v := range on { |
| cp[k] = v |
| } |
| - e.analyticsOn.Store(&cp) |
| + e.switches.Store(&cp) |
| } |
| -func (e *Edge) analyticsEnabled(rt registry.Route) bool { |
| - m := e.analyticsOn.Load() |
| +// switchesFor returns the stored switches for rt's (account, app), or the |
| +// zero Switches (count off, door closed) when unset. |
| +func (e *Edge) switchesFor(rt registry.Route) telemetry.Switches { |
| + m := e.switches.Load() |
| if m == nil { |
| - return false |
| + return telemetry.Switches{} |
| } |
| return (*m)[release.RouteAccount(rt)+"/"+rt.App] |
| } |
| +func (e *Edge) analyticsEnabled(rt registry.Route) bool { |
| + return e.switchesFor(rt).Count |
| +} |
| + |
| // withHopVerified marks ctx as a request that arrived over a proven mesh |
| // hop, exactly as acceptHop does (homemap.go). Test-only in practice — |
| // nothing on the serving path may put this mark on a request, which is the |
diff --git a/internal/edge/analytics_test.go b/internal/edge/analytics_test.go| index 33e46c3be..4aeade5b1 100644 |
| --- a/internal/edge/analytics_test.go |
| +++ b/internal/edge/analytics_test.go |
| @@ -9,6 +9,7 @@ import ( |
| "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| type memAnalytics struct { |
| @@ -74,13 +75,13 @@ func TestEdgeObservesOnlyWhenSwitchedOn(t *testing.T) { |
| if len(m.snap()) != 0 { |
| t.Fatal("nothing is observed while the app is off") |
| } |
| - on := map[string]bool{release.OpsAccount + "/web": true} |
| - e.SetAnalyticsOn(on) |
| + on := map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true, Door: true}} |
| + e.SetSwitches(on) |
| // The switch map is copied, so a caller may keep writing to its own: |
| // the edge reads its copy from every serving goroutine without a lock, |
| // and a shared map would be a fatal concurrent map read-and-write. |
| delete(on, release.OpsAccount+"/web") |
| - on["scribble"] = true |
| + on["scribble"] = telemetry.Switches{Count: true} |
| 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"}) |
| obs := m.snap() |
| if len(obs) != 1 { |
| @@ -103,9 +104,24 @@ func TestEdgeObservesOnlyWhenSwitchedOn(t *testing.T) { |
| if !m.snap()[2].Prefetch { |
| t.Fatal("prefetch;prerender is a prefetch") |
| } |
| - e.SetAnalyticsOn(nil) |
| + e.SetSwitches(nil) |
| get("/", nil) |
| if len(m.snap()) != 3 { |
| t.Fatal("switching off stops observation") |
| } |
| } |
| + |
| +func TestSetSwitchesCopiesAndClears(t *testing.T) { |
| + e := &Edge{} |
| + rt := registry.Route{App: "web"} |
| + m := map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true}} |
| + e.SetSwitches(m) |
| + delete(m, release.OpsAccount+"/web") |
| + if !e.switchesFor(rt).Count { |
| + t.Fatal("stored map must be a copy") |
| + } |
| + e.SetSwitches(nil) |
| + if sw := e.switchesFor(rt); sw.Count || sw.Door { |
| + t.Fatal("nil clears to the zero switches") |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index e11c2a27e..aa7bad5f6 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -55,6 +55,7 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| // Default cache sizing for EnableCache: 4096 entries is comfortably above a |
| @@ -134,14 +135,15 @@ type Edge struct { |
| errSink atomic.Pointer[errorSinkRef] |
| // analyticsMeter, when set, receives one observation per completed |
| // response for an app whose analytics are switched on (analytics.go, |
| - // spec 2026-09-07 §3.3), and analyticsOn is that switch map keyed |
| - // sqid+"/"+app. Both are atomic for the same reason usageMeter is: the |
| - // agent role arms the meter after construction, and the telemetry pass |
| - // replaces the switch map while serving is underway. Nil meter or nil |
| - // map = the edge observes nothing, which is every `carlos edge` box and |
| - // every app nobody has switched on. |
| + // spec 2026-09-07 §3.3), and switches is that switch map keyed |
| + // sqid+"/"+app, carrying both the count and door booleans together. |
| + // Both are atomic for the same reason usageMeter is: the agent role |
| + // arms the meter after construction, and the telemetry pass replaces |
| + // the switch map while serving is underway. Nil meter or nil map = the |
| + // edge observes nothing, which is every `carlos edge` box and every |
| + // app nobody has switched on. |
| analyticsMeter atomic.Pointer[analyticsMeterRef] |
| - analyticsOn atomic.Pointer[map[string]bool] |
| + switches atomic.Pointer[map[string]telemetry.Switches] |
| // tunnels, when non-nil, is the reverse-channel hub (tunnel.go): remote |
| // hosts register on its reserved hostname and routes with Tunnel set are |
| // proxied over the channel they name. nil = tunnel serving not configured |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 79b9b90a4..7ea4fc2c1 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -453,7 +453,7 @@ func RunAgent(args []string) { |
| // drains it into day objects on the same 5-minute tick as usage. |
| am := analytics.NewMeter(dataDir(), status.Label(readMachineID()), time.Now) |
| e.EnableAnalytics(am) |
| - setAnalyticsSink(e.SetAnalyticsOn) |
| + setAnalyticsSink(e.SetSwitches) |
| go usageLoop(m.Drain, am) |
| }, |
| // Certificate issuance is a deployment-global duty, so it runs where |
diff --git a/internal/host/telemetry_pass.go b/internal/host/telemetry_pass.go| index 1891c8d19..97e1c2b1b 100644 |
| --- a/internal/host/telemetry_pass.go |
| +++ b/internal/host/telemetry_pass.go |
| @@ -15,22 +15,23 @@ import ( |
| "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| -var analyticsSink atomic.Pointer[func(map[string]bool)] |
| +var analyticsSink atomic.Pointer[func(map[string]telemetry.Switches)] |
| // setAnalyticsSink registers where a resolved switch map goes (the edge's |
| -// SetAnalyticsOn), the way setUpstreamSource publishes the prober table. |
| -func setAnalyticsSink(fn func(map[string]bool)) { analyticsSink.Store(&fn) } |
| +// SetSwitches), the way setUpstreamSource publishes the prober table. |
| +func setAnalyticsSink(fn func(map[string]telemetry.Switches)) { analyticsSink.Store(&fn) } |
| -// telemetrySwitches resolves the count switch for each distinct |
| +// telemetrySwitches resolves the count and door switches for each distinct |
| // (account, app) among the box's routes. Status routes carry no app and |
| // are skipped. One Resolve (two Gets) per pair; a pair that fails to |
| -// resolve is left OFF and logged — off is the safe side of this switch. |
| -func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]bool, error) { |
| +// resolve is left ABSENT and logged, which is the zero value: off and |
| +// door closed — the safe side of both switches. |
| +func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]telemetry.Switches, error) { |
| routes, err := reg.List() |
| if err != nil { |
| return nil, err |
| } |
| - on := map[string]bool{} |
| + on := map[string]telemetry.Switches{} |
| seen := map[string]bool{} |
| for _, rt := range routes { |
| if rt.Kind == "status" || rt.App == "" { |
| @@ -47,9 +48,7 @@ func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Regis |
| log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err) |
| continue |
| } |
| - if res.Count == telemetry.CountEdge { |
| - on[key] = true |
| - } |
| + on[key] = res.Switches() |
| } |
| return on, nil |
| } |
diff --git a/internal/host/telemetry_pass_test.go b/internal/host/telemetry_pass_test.go| index 04b43afda..56498a829 100644 |
| --- a/internal/host/telemetry_pass_test.go |
| +++ b/internal/host/telemetry_pass_test.go |
| @@ -29,10 +29,28 @@ func TestTelemetrySwitchesResolvePerRoute(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if !on["bab/web"] || on["bab/api"] || on["zzz/site"] { |
| + if on["bab/web"] != (telemetry.Switches{Count: true, Door: true}) || on["bab/api"] != (telemetry.Switches{Count: false, Door: true}) || on["zzz/site"] != (telemetry.Switches{Count: false, Door: true}) { |
| t.Fatalf("switches = %v", on) |
| } |
| if _, ok := on["/"]; ok { |
| t.Fatal("status routes have no app and must not appear") |
| } |
| } |
| + |
| +func TestTelemetrySwitchesDoorOffUnderAccountAnalyticsOn(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := edgesTestRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + telemetry.Update(ctx, s, telemetry.AccountKey("bab"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge} }) |
| + telemetry.Update(ctx, s, telemetry.AppKey("bab", "web"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Door: telemetry.DoorOff} }) |
| + on, err := telemetrySwitches(ctx, s, reg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if on["bab/web"] != (telemetry.Switches{Count: true, Door: false}) { |
| + t.Fatalf("switches = %v", on) |
| + } |
| +} |
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go| index c723e06f5..6618c52ab 100644 |
| --- a/internal/telemetry/telemetry.go |
| +++ b/internal/telemetry/telemetry.go |
| @@ -137,3 +137,13 @@ func Resolve(ctx context.Context, s release.Store, sqid, app string) (Resolved, |
| } |
| return r, nil |
| } |
| + |
| +// Switches is what a box acts on for one app: the two booleans the edge |
| +// consults per request. Resolved carries provenance for the page; this |
| +// carries none, so it is what crosses into the edge. |
| +type Switches struct{ Count, Door bool } |
| + |
| +// Switches derives the two booleans from a Resolved value. |
| +func (r Resolved) Switches() Switches { |
| + return Switches{Count: r.Count == CountEdge, Door: r.Door == DoorOn} |
| +} |
diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go| index 3e2dc5557..a0eed2508 100644 |
| --- a/internal/telemetry/telemetry_test.go |
| +++ b/internal/telemetry/telemetry_test.go |
| @@ -45,3 +45,14 @@ func TestResolveAppThenAccountThenDefault(t *testing.T) { |
| t.Fatalf("read back = %+v", got) |
| } |
| } |
| + |
| +func TestResolvedSwitches(t *testing.T) { |
| + r := Resolved{Count: CountEdge, Door: DoorOff} |
| + if sw := r.Switches(); !sw.Count || sw.Door { |
| + t.Fatalf("got %+v", sw) |
| + } |
| + r = Resolved{Count: CountOff, Door: DoorOn} |
| + if sw := r.Switches(); sw.Count || !sw.Door { |
| + t.Fatalf("got %+v", sw) |
| + } |
| +} |