carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 0fb9157275a59e6eec201e2f6b8aa0a3f4bc6634 parent 4269177
8 files changed, +102 −38
  • internal/edge/analytics.go +23 −15
  • internal/edge/analytics_test.go +20 −4
  • internal/edge/edge.go +9 −7
  • internal/host/agent.go +1 −1
  • internal/host/telemetry_pass.go +9 −10
  • internal/host/telemetry_pass_test.go +19 −1
  • internal/telemetry/telemetry.go +10 −0
  • internal/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)
+ }
+}