host: resolve analytics switches per pass; flush the analytics meter on the usage tick
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+180
−10
internal/host/agent.go+11 −1internal/host/telemetry_pass.go+69 −0internal/host/telemetry_pass_test.go+38 −0internal/host/usage_loop.go+31 −6internal/host/usage_loop_test.go+31 −3
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 14d1385a2..79b9b90a4 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -165,6 +165,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/apperr" |
| "github.com/carlosframework/platform/internal/applog" |
| @@ -446,7 +447,14 @@ func RunAgent(args []string) { |
| if errSink != nil { |
| e.EnableErrors(errSink) |
| } |
| - go usageLoop(m.Drain) |
| + // The analytics meter (spec 2026-09-07 §3.3): the edge counts |
| + // pageviews per (account, app) while telemetryPass (fullPass and |
| + // instancesLoop) keeps its switch map current, and usageLoop |
| + // 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) |
| + go usageLoop(m.Drain, am) |
| }, |
| // Certificate issuance is a deployment-global duty, so it runs where |
| // the housekeeper runs (housekeeper.go). The gate goes DOWN into the |
| @@ -1506,6 +1514,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| if err := reconcileSerialized(ctx, store, reg, provisionTickConfig(os.Getenv)); err != nil { |
| log.Printf("agent: fast converge reconcile: %v", err) |
| } |
| + telemetryPass(ctx, store, reg) |
| runMovesPass(ctx, store) |
| // Folded once per pass and handed in, never re-read inside the |
| // loop: this leg runs on every serial bump (a seconds cadence), |
| @@ -2005,6 +2014,7 @@ func instancesLoop(reg *registry.Registry) { |
| if err := reconcileSerialized(ctx, store, reg, cfg); err != nil { |
| log.Printf("agent: instances pass: %v", err) |
| } |
| + telemetryPass(ctx, store, reg) |
| runMovesPass(ctx, store) |
| deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now()) |
| if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil { |
diff --git a/internal/host/telemetry_pass.go b/internal/host/telemetry_pass.go| new file mode 100644 |
| index 000000000..1891c8d19 |
| --- /dev/null |
| +++ b/internal/host/telemetry_pass.go |
| @@ -0,0 +1,69 @@ |
| +// The analytics switch pass (spec 2026-09-07 §3.3): resolve |
| +// telemetry.json app → account → default for every (account, app) this |
| +// box serves and hand the edge a fresh switch map. Rides the serial- |
| +// triggered full pass and the five-minute instances loop, so a console |
| +// write lands within seconds and the hourly floor stands underneath. |
| +package host |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "sync/atomic" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/telemetry" |
| +) |
| + |
| +var analyticsSink atomic.Pointer[func(map[string]bool)] |
| + |
| +// 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) } |
| + |
| +// telemetrySwitches resolves the count switch 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) { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return nil, err |
| + } |
| + on := map[string]bool{} |
| + seen := map[string]bool{} |
| + for _, rt := range routes { |
| + if rt.Kind == "status" || rt.App == "" { |
| + continue |
| + } |
| + sqid := release.RouteAccount(rt) |
| + key := sqid + "/" + rt.App |
| + if seen[key] { |
| + continue |
| + } |
| + seen[key] = true |
| + res, err := telemetry.Resolve(ctx, s, sqid, rt.App) |
| + if err != nil { |
| + log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err) |
| + continue |
| + } |
| + if res.Count == telemetry.CountEdge { |
| + on[key] = true |
| + } |
| + } |
| + return on, nil |
| +} |
| + |
| +// telemetryPass resolves and pushes; errors are one log line each. |
| +func telemetryPass(ctx context.Context, s release.Store, reg *registry.Registry) { |
| + sink := analyticsSink.Load() |
| + if sink == nil { |
| + return |
| + } |
| + on, err := telemetrySwitches(ctx, s, reg) |
| + if err != nil { |
| + log.Printf("agent: telemetry pass: %v", err) |
| + return |
| + } |
| + (*sink)(on) |
| +} |
diff --git a/internal/host/telemetry_pass_test.go b/internal/host/telemetry_pass_test.go| new file mode 100644 |
| index 000000000..04b43afda |
| --- /dev/null |
| +++ b/internal/host/telemetry_pass_test.go |
| @@ -0,0 +1,38 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/telemetry" |
| +) |
| + |
| +func TestTelemetrySwitchesResolvePerRoute(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := edgesTestRegistry(t) |
| + for _, rt := range []registry.Route{ |
| + {Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"}, |
| + {Host: "b.example", Kind: "service", Account: "bab", App: "api", Status: "ready"}, |
| + {Host: "c.example", Kind: "static", Account: "zzz", App: "site", Status: "ready"}, |
| + {Host: "status.example", Kind: "status", Status: "ready"}, |
| + } { |
| + if err := reg.Upsert(rt); 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", "api"), func(st *telemetry.Settings) { st.Analytics = &telemetry.Analytics{Count: telemetry.CountOff} }) |
| + on, err := telemetrySwitches(ctx, s, reg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !on["bab/web"] || on["bab/api"] || on["zzz/site"] { |
| + t.Fatalf("switches = %v", on) |
| + } |
| + if _, ok := on["/"]; ok { |
| + t.Fatal("status routes have no app and must not appear") |
| + } |
| +} |
diff --git a/internal/host/usage_loop.go b/internal/host/usage_loop.go| index b3af91669..9ec1d81d8 100644 |
| --- a/internal/host/usage_loop.go |
| +++ b/internal/host/usage_loop.go |
| @@ -7,14 +7,38 @@ import ( |
| "os" |
| "time" |
| + "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/usage" |
| ) |
| // usageFlush drains the edge meter and CAS-merges the deltas into the box's |
| -// usage/edge day object. One flush; the loop below owns cadence. |
| -func usageFlush(ctx context.Context, s release.Store, box string, drain func() map[string]usage.EdgeRow, now time.Time) error { |
| - return usage.UpsertEdgeDay(ctx, s, box, now, drain()) |
| +// usage/edge day object, then (when am is non-nil) drains the analytics |
| +// meter's deltas into their app-day objects (spec §3.3: the same tick, a |
| +// second CAS write per app). Usage writes go to s (rebased under |
| +// CARLOS_STATUS_PREFIX on a fleet box); analytics writes go to raw — a day |
| +// file lives under apps/<sqid>/<app>/analytics/, the app's own data, and |
| +// must NOT be rebased (same reasoning as log shipping — logship.go:54-61). |
| +// A failed analytics batch is re-queued for the next tick; a failed usage |
| +// write is returned but does not block the analytics flush. |
| +func usageFlush(ctx context.Context, s, raw release.Store, box string, drain func() map[string]usage.EdgeRow, am *analytics.Meter, now time.Time) error { |
| + err := usage.UpsertEdgeDay(ctx, s, box, now, drain()) |
| + if am == nil { |
| + return err |
| + } |
| + failed := map[analytics.AppKey][]analytics.Delta{} |
| + for k, ds := range am.Drain() { |
| + for _, d := range ds { |
| + if uerr := analytics.UpsertDay(ctx, raw, k.Acct, k.App, d); uerr != nil { |
| + log.Printf("agent: analytics flush %s/%s %s: %v (re-queued)", k.Acct, k.App, d.Date, uerr) |
| + failed[k] = append(failed[k], d) |
| + } |
| + } |
| + } |
| + if len(failed) > 0 { |
| + am.Requeue(failed) |
| + } |
| + return err |
| } |
| // usageLoop flushes edge usage every 5 minutes, forever — the metricsLoop |
| @@ -23,8 +47,9 @@ func usageFlush(ctx context.Context, s release.Store, box string, drain func() m |
| // distinction at metrics_loop.go:773-779, rather than the brief's single log |
| // line, so the two loops read the same way in the logs), errors logged and |
| // never fatal (the drained window is lost, the next one is not; modelling |
| -// grade tolerates the gap). |
| -func usageLoop(drain func() map[string]usage.EdgeRow) { |
| +// grade tolerates the gap). am is the analytics meter (nil disables the |
| +// analytics half of the flush); its deltas are folded in on the same tick. |
| +func usageLoop(drain func() map[string]usage.EdgeRow, am *analytics.Meter) { |
| defer func() { |
| if r := recover(); r != nil { |
| log.Printf("agent: usage loop panic, edge metering stopped for this process: %v", r) |
| @@ -48,7 +73,7 @@ func usageLoop(drain func() map[string]usage.EdgeRow) { |
| log.Printf("agent: usage loop armed (5min flush), publishing to %s", target) |
| for { |
| time.Sleep(5 * time.Minute) |
| - if err := usageFlush(ctx, s, box, drain, time.Now()); err != nil { |
| + if err := usageFlush(ctx, s, raw, box, drain, am, time.Now()); err != nil { |
| log.Printf("agent: usage flush: %v", err) |
| } |
| } |
diff --git a/internal/host/usage_loop_test.go b/internal/host/usage_loop_test.go| index 9208f6f64..d8b5fabb3 100644 |
| --- a/internal/host/usage_loop_test.go |
| +++ b/internal/host/usage_loop_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/usage" |
| ) |
| @@ -17,10 +18,10 @@ func TestUsageFlushWritesAndReflushesMerge(t *testing.T) { |
| rows := map[string]usage.EdgeRow{ |
| "a.example.com": {Account: "bab", App: "hello", Requests: 3, BytesOut: 300}, |
| } |
| - if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at); err != nil { |
| + if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return rows }, nil, at); err != nil { |
| t.Fatal(err) |
| } |
| - if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at.Add(5*time.Minute)); err != nil { |
| + if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return rows }, nil, at.Add(5*time.Minute)); err != nil { |
| t.Fatal(err) |
| } |
| raw, _, err := s.Get(ctx, usage.EdgeDayKey("box1", "2026-08-11")) |
| @@ -38,7 +39,7 @@ func TestUsageFlushWritesAndReflushesMerge(t *testing.T) { |
| func TestUsageFlushEmptyDrainWritesNothing(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| - err := usageFlush(context.Background(), s, "box1", func() map[string]usage.EdgeRow { return nil }, time.Now().UTC()) |
| + err := usageFlush(context.Background(), s, s, "box1", func() map[string]usage.EdgeRow { return nil }, nil, time.Now().UTC()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -46,3 +47,30 @@ func TestUsageFlushEmptyDrainWritesNothing(t *testing.T) { |
| t.Fatalf("wrote %v", keys) |
| } |
| } |
| + |
| +// TestUsageFlushAlsoFlushesAnalytics: the same tick that flushes edge usage |
| +// also drains the analytics meter into its app-day objects (spec §3.3). A |
| +// batch that fails to write is re-queued and lands on the next flush. |
| +func TestUsageFlushAlsoFlushesAnalytics(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 9, 7, 9, 30, 0, 0, time.UTC) |
| + am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now }) |
| + am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "h", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now}) |
| + if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + sum, err := analytics.ReadRange(ctx, s, "bab", "web", now, now) |
| + if err != nil || sum.PV != 1 { |
| + t.Fatalf("flushed day: %+v %v", sum, err) |
| + } |
| + // A failed analytics flush re-queues; the next flush lands it. |
| + am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "h", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now}) |
| + _ = usageFlush(ctx, s, failingStore{Store: s}, "box1", func() map[string]usage.EdgeRow { return nil }, am, now) |
| + if err := usageFlush(ctx, s, s, "box1", func() map[string]usage.EdgeRow { return nil }, am, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if sum, _ := analytics.ReadRange(ctx, s, "bab", "web", now, now); sum.PV != 2 { |
| + t.Fatalf("after requeue: %d", sum.PV) |
| + } |
| +} |