// 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" "sync/atomic" "time" "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) } // telemetryReads is what the switch pass remembers between passes while the // change index is armed (S3 change-index design §5): a gate over the app and // account scopes, and the last good settings object per key, so a read the // gate skips, or one that fails, reuses the last good value. A failed read // marks its scope failed, so the next pass reads it again. // // Disarmed, each pass gets a fresh one over a nil watch, and the pass is // main's (telemetrySwitchesResolve): every app resolves both levels on its // own, nothing is remembered within or across passes, and a failed read // leaves only that app off, as before the index. type telemetryReads struct { // mu is held across a whole pass, reads included: the fast converge loop // and the instances loop both run the pass and take turns here. Nothing // else is locked under it. mu sync.Mutex w *release.ChangeWatch gate *release.ReadGate last map[string]telemetry.Settings // telemetry.AccountKey / AppKey -> last good object } func newTelemetryReads(w *release.ChangeWatch) *telemetryReads { return &telemetryReads{w: w, gate: newChangeGateOver(w), last: map[string]telemetry.Settings{}} } // telemetryGate is the process's telemetryReads for the armed watch. It is // rebuilt whenever the armed watch differs from the one it was built over, // so a pass that ran before arming never pins a nil watch. var telemetryGate struct { mu sync.Mutex r *telemetryReads } // currentTelemetryReads is the state a pass uses: telemetryGate's while a // watch is armed, a fresh one otherwise. func currentTelemetryReads() *telemetryReads { w := armedChangeWatch.Load() if w == nil { return newTelemetryReads(nil) } telemetryGate.mu.Lock() defer telemetryGate.mu.Unlock() if telemetryGate.r == nil || telemetryGate.r.w != w { telemetryGate.r = newTelemetryReads(w) } return telemetryGate.r } // telemetrySwitches resolves the count switch for each distinct // (account, app) among the box's routes. Status routes carry no app and // are skipped. func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]bool, error) { return telemetrySwitchesWith(ctx, s, reg, currentTelemetryReads(), time.Now()) } // telemetrySwitchesWith is telemetrySwitches over r at now. Over a watch, // each account's telemetry.json is read at most once per pass, however many // of its apps the box serves, and each app's at most once; a read is skipped // when r's gate says its scope has not moved and r holds a last good value. // A pair with no good value at either level is left OFF and logged: off is // the safe side of this switch. Without one (disarmed), it is main's pass. func telemetrySwitchesWith(ctx context.Context, s release.Store, reg *registry.Registry, r *telemetryReads, now time.Time) (map[string]bool, error) { if r == nil || r.w == nil { return telemetrySwitchesResolve(ctx, s, reg) } routes, err := reg.List() if err != nil { return nil, err } r.mu.Lock() defer r.mu.Unlock() pass := map[string]error{} // keys this pass has settled, and the error when there is no good value 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 acct, err := r.read(ctx, s, telemetry.AccountKey(sqid), now, pass) if err != nil { log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err) continue } app, err := r.read(ctx, s, telemetry.AppKey(sqid, rt.App), now, pass) if err != nil { log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err) continue } if telemetry.Fold(acct, app).Count == telemetry.CountEdge { on[key] = true } } // Objects no route needs any more are forgotten, so a returning app is // read afresh. for k := range r.last { if _, used := pass[k]; !used { delete(r.last, k) } } return on, nil } // telemetrySwitchesResolve is the pass with no change index armed, exactly // as before the index: one Resolve (two Gets) per (account, app); a pair // that fails to resolve is left OFF and logged, and no other pair is. func telemetrySwitchesResolve(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 } // read is key's settings for this pass: settled once per pass in pass. The // last good value stands in when the gate says the key's scope has not moved; // otherwise the object is read. A failed read keeps the last good value, logs // it and marks the scope failed; with no last good value it returns the // error. func (r *telemetryReads) read(ctx context.Context, s release.Store, key string, now time.Time, pass map[string]error) (telemetry.Settings, error) { if err, done := pass[key]; done { return r.last[key], err } scope, gated := release.ChangeScope(key) gen, due := r.gate.Due(scope, now) if prev, ok := r.last[key]; ok && gated && !due { pass[key] = nil return prev, nil } st, _, err := telemetry.Read(ctx, s, key) if gated { r.gate.Mark(scope, gen, now, err == nil) } if err == nil { r.last[key] = st pass[key] = nil return st, nil } if prev, ok := r.last[key]; ok { log.Printf("agent: telemetry read %s: %v (the last good value stands; retried next pass)", key, err) pass[key] = nil return prev, nil } pass[key] = err return telemetry.Settings{}, err } // 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) }