carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The analytics switch pass (spec 2026-09-07 §3.3): resolve
2// telemetry.json app → account → default for every (account, app) this
3// box serves and hand the edge a fresh switch map. Rides the serial-
4// triggered full pass and the five-minute instances loop, so a console
5// write lands within seconds and the hourly floor stands underneath.
6package host
7
8import (
9 "context"
10 "log"
11 "sync"
12 "sync/atomic"
13 "time"
14
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17 "github.com/carlosframework/platform/internal/telemetry"
18)
19
20var analyticsSink atomic.Pointer[func(map[string]bool)]
21
22// setAnalyticsSink registers where a resolved switch map goes (the edge's
23// SetAnalyticsOn), the way setUpstreamSource publishes the prober table.
24func setAnalyticsSink(fn func(map[string]bool)) { analyticsSink.Store(&fn) }
25
26// telemetryReads is what the switch pass remembers between passes while the
27// change index is armed (S3 change-index design §5): a gate over the app and
28// account scopes, and the last good settings object per key, so a read the
29// gate skips, or one that fails, reuses the last good value. A failed read
30// marks its scope failed, so the next pass reads it again.
31//
32// Disarmed, each pass gets a fresh one over a nil watch, and the pass is
33// main's (telemetrySwitchesResolve): every app resolves both levels on its
34// own, nothing is remembered within or across passes, and a failed read
35// leaves only that app off, as before the index.
36type telemetryReads struct {
37 // mu is held across a whole pass, reads included: the fast converge loop
38 // and the instances loop both run the pass and take turns here. Nothing
39 // else is locked under it.
40 mu sync.Mutex
41 w *release.ChangeWatch
42 gate *release.ReadGate
43 last map[string]telemetry.Settings // telemetry.AccountKey / AppKey -> last good object
44}
45
46func newTelemetryReads(w *release.ChangeWatch) *telemetryReads {
47 return &telemetryReads{w: w, gate: newChangeGateOver(w), last: map[string]telemetry.Settings{}}
48}
49
50// telemetryGate is the process's telemetryReads for the armed watch. It is
51// rebuilt whenever the armed watch differs from the one it was built over,
52// so a pass that ran before arming never pins a nil watch.
53var telemetryGate struct {
54 mu sync.Mutex
55 r *telemetryReads
56}
57
58// currentTelemetryReads is the state a pass uses: telemetryGate's while a
59// watch is armed, a fresh one otherwise.
60func currentTelemetryReads() *telemetryReads {
61 w := armedChangeWatch.Load()
62 if w == nil {
63 return newTelemetryReads(nil)
64 }
65 telemetryGate.mu.Lock()
66 defer telemetryGate.mu.Unlock()
67 if telemetryGate.r == nil || telemetryGate.r.w != w {
68 telemetryGate.r = newTelemetryReads(w)
69 }
70 return telemetryGate.r
71}
72
73// telemetrySwitches resolves the count switch for each distinct
74// (account, app) among the box's routes. Status routes carry no app and
75// are skipped.
76func telemetrySwitches(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]bool, error) {
77 return telemetrySwitchesWith(ctx, s, reg, currentTelemetryReads(), time.Now())
78}
79
80// telemetrySwitchesWith is telemetrySwitches over r at now. Over a watch,
81// each account's telemetry.json is read at most once per pass, however many
82// of its apps the box serves, and each app's at most once; a read is skipped
83// when r's gate says its scope has not moved and r holds a last good value.
84// A pair with no good value at either level is left OFF and logged: off is
85// the safe side of this switch. Without one (disarmed), it is main's pass.
86func telemetrySwitchesWith(ctx context.Context, s release.Store, reg *registry.Registry, r *telemetryReads, now time.Time) (map[string]bool, error) {
87 if r == nil || r.w == nil {
88 return telemetrySwitchesResolve(ctx, s, reg)
89 }
90 routes, err := reg.List()
91 if err != nil {
92 return nil, err
93 }
94 r.mu.Lock()
95 defer r.mu.Unlock()
96 pass := map[string]error{} // keys this pass has settled, and the error when there is no good value
97 on := map[string]bool{}
98 seen := map[string]bool{}
99 for _, rt := range routes {
100 if rt.Kind == "status" || rt.App == "" {
101 continue
102 }
103 sqid := release.RouteAccount(rt)
104 key := sqid + "/" + rt.App
105 if seen[key] {
106 continue
107 }
108 seen[key] = true
109 acct, err := r.read(ctx, s, telemetry.AccountKey(sqid), now, pass)
110 if err != nil {
111 log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err)
112 continue
113 }
114 app, err := r.read(ctx, s, telemetry.AppKey(sqid, rt.App), now, pass)
115 if err != nil {
116 log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err)
117 continue
118 }
119 if telemetry.Fold(acct, app).Count == telemetry.CountEdge {
120 on[key] = true
121 }
122 }
123 // Objects no route needs any more are forgotten, so a returning app is
124 // read afresh.
125 for k := range r.last {
126 if _, used := pass[k]; !used {
127 delete(r.last, k)
128 }
129 }
130 return on, nil
131}
132
133// telemetrySwitchesResolve is the pass with no change index armed, exactly
134// as before the index: one Resolve (two Gets) per (account, app); a pair
135// that fails to resolve is left OFF and logged, and no other pair is.
136func telemetrySwitchesResolve(ctx context.Context, s release.Store, reg *registry.Registry) (map[string]bool, error) {
137 routes, err := reg.List()
138 if err != nil {
139 return nil, err
140 }
141 on := map[string]bool{}
142 seen := map[string]bool{}
143 for _, rt := range routes {
144 if rt.Kind == "status" || rt.App == "" {
145 continue
146 }
147 sqid := release.RouteAccount(rt)
148 key := sqid + "/" + rt.App
149 if seen[key] {
150 continue
151 }
152 seen[key] = true
153 res, err := telemetry.Resolve(ctx, s, sqid, rt.App)
154 if err != nil {
155 log.Printf("agent: telemetry resolve %s: %v (analytics stays off)", key, err)
156 continue
157 }
158 if res.Count == telemetry.CountEdge {
159 on[key] = true
160 }
161 }
162 return on, nil
163}
164
165// read is key's settings for this pass: settled once per pass in pass. The
166// last good value stands in when the gate says the key's scope has not moved;
167// otherwise the object is read. A failed read keeps the last good value, logs
168// it and marks the scope failed; with no last good value it returns the
169// error.
170func (r *telemetryReads) read(ctx context.Context, s release.Store, key string, now time.Time, pass map[string]error) (telemetry.Settings, error) {
171 if err, done := pass[key]; done {
172 return r.last[key], err
173 }
174 scope, gated := release.ChangeScope(key)
175 gen, due := r.gate.Due(scope, now)
176 if prev, ok := r.last[key]; ok && gated && !due {
177 pass[key] = nil
178 return prev, nil
179 }
180 st, _, err := telemetry.Read(ctx, s, key)
181 if gated {
182 r.gate.Mark(scope, gen, now, err == nil)
183 }
184 if err == nil {
185 r.last[key] = st
186 pass[key] = nil
187 return st, nil
188 }
189 if prev, ok := r.last[key]; ok {
190 log.Printf("agent: telemetry read %s: %v (the last good value stands; retried next pass)", key, err)
191 pass[key] = nil
192 return prev, nil
193 }
194 pass[key] = err
195 return telemetry.Settings{}, err
196}
197
198// telemetryPass resolves and pushes; errors are one log line each.
199func telemetryPass(ctx context.Context, s release.Store, reg *registry.Registry) {
200 sink := analyticsSink.Load()
201 if sink == nil {
202 return
203 }
204 on, err := telemetrySwitches(ctx, s, reg)
205 if err != nil {
206 log.Printf("agent: telemetry pass: %v", err)
207 return
208 }
209 (*sink)(on)
210}
211