carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package metrics
2
3import (
4 "strconv"
5 "time"
6)
7
8// Rules is the thresholds and sustain windows Evaluate checks each tick
9// against. Frac fields are fractions (0..1), matching HostSample; LagSecs is
10// the one exception — keymail relay lag arrives in seconds, not a fraction.
11type Rules struct {
12 DiskFrac float64 // fire >, default 0.90, immediate
13 MemFrac float64 // default 0.90
14 MemSustain time.Duration // default 10 * time.Minute
15 CPUFrac float64 // default 0.95
16 CPUSustain time.Duration // default 15 * time.Minute
17 LagSecs int64 // default 900, immediate; inert while readings are nil
18 ClearBy float64 // hysteresis, default 0.05 (fractions); lag clears at LagSecs-300
19 // GeoIPSustain is how long a geoip reason must hold before the rule
20 // fires (geoip spec §10.4); it clears on the first reading of 0.
21 GeoIPSustain time.Duration // default 30 * time.Minute
22}
23
24// DefaultRules are the thresholds shipped with the platform.
25func DefaultRules() Rules {
26 return Rules{
27 DiskFrac: 0.90,
28 MemFrac: 0.90,
29 MemSustain: 10 * time.Minute,
30 CPUFrac: 0.95,
31 CPUSustain: 15 * time.Minute,
32 LagSecs: 900,
33 ClearBy: 0.05,
34 // GeoIPSustain: geoip spec §10.4.
35 GeoIPSustain: 30 * time.Minute,
36 }
37}
38
39// RulesFromEnv applies percent-integer overrides on top of DefaultRules:
40// CARLOS_ALERT_DISK_PCT, CARLOS_ALERT_MEM_PCT, CARLOS_ALERT_CPU_PCT. An
41// unset, unparsable, or out-of-range (0..100) value keeps the default —
42// deliberately pure (no logging); a caller that wants to log a rejected
43// override asks PctFrac the same question this does, since this package
44// touches neither stderr nor the OS.
45func RulesFromEnv(getenv func(string) string) Rules {
46 r := DefaultRules()
47 if v, ok := PctFrac(getenv("CARLOS_ALERT_DISK_PCT")); ok {
48 r.DiskFrac = v
49 }
50 if v, ok := PctFrac(getenv("CARLOS_ALERT_MEM_PCT")); ok {
51 r.MemFrac = v
52 }
53 if v, ok := PctFrac(getenv("CARLOS_ALERT_CPU_PCT")); ok {
54 r.CPUFrac = v
55 }
56 return r
57}
58
59// PctFrac converts one percent-integer override string ("95") to the
60// fraction Rules holds (0.95), reporting ok=false for empty, unparsable, or
61// out-of-range (0..100) input. Exported so the agent can LOG an override it
62// set but this rejected without either duplicating the rule or dragging
63// stderr into this package.
64func PctFrac(s string) (float64, bool) {
65 if s == "" {
66 return 0, false
67 }
68 n, err := strconv.Atoi(s)
69 if err != nil || n < 0 || n > 100 {
70 return 0, false
71 }
72 return float64(n) / 100.0, true
73}
74
75// Observation is one tick's readings for a box. A nil field means "no
76// data this tick" and is handled identically everywhere in Evaluate: the
77// corresponding rule's state is left untouched (a firing alert stays
78// firing, a quiet rule stays quiet, nothing is ever emitted for it).
79type Observation struct {
80 At time.Time
81 CPUFrac *float64
82 MemFrac *float64
83 DiskFrac *float64
84 LagSecs *int64
85 // GeoIP is the box's geoip reason code (geoip spec §10.4): 0 none,
86 // 1 stale, 2 expiring, 3 expired, 4 not loaded, 5 feed failing. nil on
87 // a box whose edge has not published one.
88 GeoIP *float64
89}
90
91// RuleState is one rule's sustain/hysteresis bookkeeping. Since and FiredAt
92// carry `omitempty`, but encoding/json's omitempty never treats a struct
93// (time.Time is a struct) as empty regardless of its zero value — so a
94// never-triggered RuleState does NOT drop these keys. It marshals as
95// {"firing":false,"since":"0001-01-01T00:00:00Z","fired_at":"0001-01-01T00:00:00Z"},
96// both fields always present. The round-trip is still lossless (the zero
97// time reproduces exactly on Unmarshal), so this is a documentation note,
98// not a bug: any consumer of this wire shape (the agent loop's persistence,
99// the console watcher) must treat the zero time value itself — not key
100// absence — as "never set". See TestRuleStateJSONRoundTrip.
101type RuleState struct {
102 Firing bool `json:"firing"`
103 Since time.Time `json:"since,omitempty"` // continuously-above-threshold since (sustain tracking)
104 FiredAt time.Time `json:"fired_at,omitempty"` // set on fire, cleared on clear
105 Value float64 `json:"value,omitempty"` // reading at last transition
106}
107
108// AlertState is the JSON persisted to disk by the agent loop and published
109// to alerts/boxes/<box>.json (see AlertKey), and read back by the console
110// watcher that diffs Rules[...].Firing across polls.
111type AlertState struct {
112 V int `json:"v"` // 1
113 Box string `json:"box"`
114 UpdatedAt time.Time `json:"updated_at"`
115 Rules map[string]RuleState `json:"rules"` // "disk","mem","cpu","lag"
116}
117
118// Event is emitted on a firing or clearing transition — never on every
119// tick, and never for a nil reading.
120type Event struct {
121 Rule string `json:"rule"`
122 Firing bool `json:"firing"` // true=fired, false=cleared
123 Value float64 `json:"value"`
124 Box string `json:"box"`
125}
126
127// AlertKey is the bucket key an AlertState for box is stored/published
128// under.
129func AlertKey(box string) string {
130 return "alerts/boxes/" + box + ".json"
131}
132
133// Evaluate advances AlertState by one Observation and returns the new
134// state plus any fire/clear transitions as Events.
135//
136// Evaluate never mutates st: it builds a fresh Rules map (copying each
137// RuleState by value — RuleState holds no pointers or slices, so the copy
138// is a true deep copy) and returns that in the new AlertState. Callers
139// comparing old vs new state across the call — the console watcher this
140// feeds diffs Rules[...].Firing on exactly that assumption — see two
141// independent maps, never the same one mutated in place.
142//
143// Semantics, per rule ("disk", "mem", "cpu", "lag"):
144// - nil reading: that rule's state is left exactly as-is. No data never
145// fires and never clears — clearing needs evidence.
146// - Not firing, reading > threshold: Since is set if it was zero; the
147// rule fires (emitting an Event) once obs.At.Sub(Since) >= sustain
148// (immediate rules use sustain 0, so they fire the same tick Since is
149// set). A reading back at-or-below threshold before sustain elapses
150// resets Since to zero — the sustain window must be continuous.
151// - Firing, reading < threshold-ClearBy: the rule clears (emitting an
152// Event) and Since/FiredAt are zeroed.
153// - Firing, reading in [threshold-ClearBy, threshold] (or still above
154// threshold): stays firing — this is the hysteresis band, and it is
155// deliberately wider than just "below threshold" so a reading
156// oscillating right at the line doesn't flap.
157func Evaluate(r Rules, st AlertState, obs Observation) (AlertState, []Event) {
158 out := AlertState{
159 V: st.V,
160 Box: st.Box,
161 UpdatedAt: obs.At,
162 Rules: make(map[string]RuleState, len(st.Rules)),
163 }
164 for name, rs := range st.Rules {
165 out.Rules[name] = rs
166 }
167
168 var events []Event
169 emit := func(name string, firing bool, value float64) {
170 events = append(events, Event{Rule: name, Firing: firing, Value: value, Box: st.Box})
171 }
172
173 evalFrac := func(name string, reading *float64, threshold float64, sustain time.Duration) {
174 if reading == nil {
175 return
176 }
177 val := *reading
178 cur := out.Rules[name]
179 clearLine := threshold - r.ClearBy
180
181 if cur.Firing {
182 if val < clearLine {
183 cur.Firing = false
184 cur.Since = time.Time{}
185 cur.FiredAt = time.Time{}
186 cur.Value = val
187 out.Rules[name] = cur
188 emit(name, false, val)
189 }
190 return
191 }
192
193 if val > threshold {
194 if cur.Since.IsZero() {
195 cur.Since = obs.At
196 }
197 if obs.At.Sub(cur.Since) >= sustain {
198 cur.Firing = true
199 cur.FiredAt = obs.At
200 cur.Value = val
201 out.Rules[name] = cur
202 emit(name, true, val)
203 return
204 }
205 out.Rules[name] = cur
206 return
207 }
208
209 if !cur.Since.IsZero() {
210 cur.Since = time.Time{}
211 out.Rules[name] = cur
212 }
213 }
214
215 evalLagSecs := func(name string, reading *int64, threshold int64, clearBy int64, sustain time.Duration) {
216 if reading == nil {
217 return
218 }
219 val := float64(*reading)
220 cur := out.Rules[name]
221 clearLine := float64(threshold - clearBy)
222 thresholdF := float64(threshold)
223
224 if cur.Firing {
225 if val < clearLine {
226 cur.Firing = false
227 cur.Since = time.Time{}
228 cur.FiredAt = time.Time{}
229 cur.Value = val
230 out.Rules[name] = cur
231 emit(name, false, val)
232 }
233 return
234 }
235
236 if val > thresholdF {
237 if cur.Since.IsZero() {
238 cur.Since = obs.At
239 }
240 if obs.At.Sub(cur.Since) >= sustain {
241 cur.Firing = true
242 cur.FiredAt = obs.At
243 cur.Value = val
244 out.Rules[name] = cur
245 emit(name, true, val)
246 return
247 }
248 out.Rules[name] = cur
249 return
250 }
251
252 if !cur.Since.IsZero() {
253 cur.Since = time.Time{}
254 out.Rules[name] = cur
255 }
256 }
257
258 evalFrac("disk", obs.DiskFrac, r.DiskFrac, 0)
259 evalFrac("mem", obs.MemFrac, r.MemFrac, r.MemSustain)
260 evalFrac("cpu", obs.CPUFrac, r.CPUFrac, r.CPUSustain)
261 evalLagSecs("lag", obs.LagSecs, r.LagSecs, 300, 0)
262 // geoip: a reason code against 0.5, so any reason fires after the
263 // sustain and a reading of 0 clears (geoip spec §10.4).
264 evalFrac("geoip", obs.GeoIP, 0.5, r.GeoIPSustain)
265 // A reason that changes WHILE firing (expiring becomes expired) is a
266 // new firing event with the new code: the alert stays up, its sustain
267 // is not restarted, and the operator is told what it now means (code
268 // review R2-5).
269 if obs.GeoIP != nil {
270 if cur := out.Rules["geoip"]; cur.Firing && *obs.GeoIP > 0.5 && cur.Value != *obs.GeoIP {
271 cur.Value = *obs.GeoIP
272 out.Rules["geoip"] = cur
273 emit("geoip", true, *obs.GeoIP)
274 }
275 }
276
277 return out, events
278}
279