1use std::collections::BTreeMap;
32
33use serde_json::Value;
34use zenkey::{SliceToken, SubjectKind};
35
36use crate::judge::common::{EXPANSION_CAP, FINDING_CAP};
37use crate::model::examples::Examples;
38use crate::report::{CheckId, DoctorFinding, DoctorSeverity};
39
40pub type ProducerId = (String, String);
46
47#[derive(Debug)]
49pub struct KeyKind {
50 pub producer: ProducerId,
52 pub declared: SubjectKind,
53 pub judged: u64,
55 pub tag_mismatches: u64,
57 pub value_mismatches: u64,
60 pub decreases: u64,
62 pub undecoded: u64,
64 examples: Examples<String>,
66 last: Option<f64>,
68 generation: u64,
70}
71
72#[derive(Debug, Default)]
74pub struct KindObservation {
75 keys: BTreeMap<String, KeyKind>,
76 cycles: BTreeMap<ProducerId, u64>,
82}
83
84fn payload_tag(doc: &Value) -> Option<SubjectKind> {
88 doc.get("type")
89 .and_then(Value::as_str)
90 .and_then(SubjectKind::from_payload_tag)
91}
92
93fn leaf(doc: &Value) -> &Value {
96 match doc.get("value") {
97 Some(v) if doc.is_object() => v,
98 _ => doc,
99 }
100}
101
102fn describe(v: &Value) -> String {
104 match v {
105 Value::Null => "null".into(),
106 Value::Bool(b) => format!("boolean {b}"),
107 Value::Number(n) => format!("number {n}"),
108 Value::String(s) if s.len() > 24 => format!("string {:?}…", &s[..24]),
109 Value::String(s) => format!("string {s:?}"),
110 Value::Array(a) => format!("array of {}", a.len()),
111 Value::Object(o) => format!("object with {} field(s)", o.len()),
112 }
113}
114
115impl KindObservation {
116 pub fn new() -> KindObservation {
117 KindObservation::default()
118 }
119
120 pub fn alive_cycled(&mut self, origin: &str, producer: &str) {
124 *self
125 .cycles
126 .entry((origin.to_string(), producer.to_string()))
127 .or_default() += 1;
128 }
129
130 pub fn observe(
134 &mut self,
135 key: &str,
136 origin: &str,
137 producer: &str,
138 declared: SubjectKind,
139 doc: Option<&Value>,
140 ) {
141 let producer_id = (origin.to_string(), producer.to_string());
142 let generation = self.cycles.get(&producer_id).copied().unwrap_or(0);
143 let entry = self.keys.entry(key.to_string()).or_insert_with(|| KeyKind {
144 producer: producer_id,
145 declared,
146 judged: 0,
147 tag_mismatches: 0,
148 value_mismatches: 0,
149 decreases: 0,
150 undecoded: 0,
151 examples: Examples::new(EXPANSION_CAP),
152 last: None,
153 generation,
154 });
155 let Some(doc) = doc else {
156 entry.undecoded += 1;
157 return;
158 };
159 entry.judged += 1;
160
161 if let Some(tag) = payload_tag(doc)
164 && tag != declared
165 {
166 entry.tag_mismatches += 1;
167 entry.examples.push_with(|| {
168 format!(
169 "payload tags itself `{}`, registry declares `{}`",
170 tag.payload_tag(),
171 declared.token()
172 )
173 });
174 return;
175 }
176
177 let v = leaf(doc);
179 match declared {
180 SubjectKind::Gauge => {
181 if v.as_f64().is_none() {
182 entry.value_mismatches += 1;
183 entry
184 .examples
185 .push_with(|| format!("gauge value is {}", describe(v)));
186 }
187 }
188 SubjectKind::Bool => {
189 if !v.is_boolean() {
190 entry.value_mismatches += 1;
191 entry
192 .examples
193 .push_with(|| format!("bool value is {}", describe(v)));
194 }
195 }
196 SubjectKind::Text => {
197 if !v.is_string() {
198 entry.value_mismatches += 1;
199 entry
200 .examples
201 .push_with(|| format!("text value is {}", describe(v)));
202 }
203 }
204 SubjectKind::Counter => {
205 let Some(n) = v.as_f64() else {
206 entry.value_mismatches += 1;
207 entry
208 .examples
209 .push_with(|| format!("counter value is {}", describe(v)));
210 return;
211 };
212 if n < 0.0 {
213 entry.value_mismatches += 1;
214 entry
215 .examples
216 .push_with(|| format!("counter value is negative ({n})"));
217 }
218 if entry.generation != generation {
219 entry.generation = generation;
223 entry.last = Some(n);
224 return;
225 }
226 if let Some(prev) = entry.last
227 && n < prev
228 {
229 entry.decreases += 1;
230 entry.examples.push_with(|| {
231 format!("counter decreased {prev} → {n} with no `alive` cycle in between")
232 });
233 }
234 entry.last = Some(n);
235 }
236 }
237 }
238}
239
240impl KeyKind {
241 pub fn examples(&self) -> &[String] {
243 self.examples.as_slice()
244 }
245}
246
247impl KindObservation {
248 pub fn iter(&self) -> impl Iterator<Item = (&str, &KeyKind)> {
250 self.keys.iter().map(|(k, v)| (k.as_str(), v))
251 }
252
253 pub fn keys_seen(&self) -> usize {
254 self.keys.len()
255 }
256}
257
258pub fn judge_kind(observation: &KindObservation, window_s: f64) -> Vec<DoctorFinding> {
264 let mut findings = Vec::new();
265 let mut bad: Examples<DoctorFinding> = Examples::new(FINDING_CAP);
266 let mut unjudged: Examples<DoctorFinding> = Examples::new(FINDING_CAP);
267 for (key, k) in observation.iter() {
268 let mismatches = k.tag_mismatches + k.value_mismatches + k.decreases;
269 if mismatches > 0 {
270 bad.push_with(|| {
271 let mut parts = Vec::new();
272 if k.tag_mismatches > 0 {
273 parts.push(format!("{} tag disagreement(s)", k.tag_mismatches));
274 }
275 if k.decreases > 0 {
276 parts.push(format!("{} decrease(s)", k.decreases));
277 }
278 if k.value_mismatches > 0 {
279 parts.push(format!("{} value(s) not of that kind", k.value_mismatches));
280 }
281 let examples = k.examples.as_slice().join("; ");
282 DoctorFinding {
283 severity: DoctorSeverity::Error,
284 check: CheckId::KindMismatch,
285 subject: key.to_string(),
286 evidence: format!(
287 "declared `{}`, and {} of {} sample(s) from origin {} in {window_s:.0}s \
288 disagree: {} — e.g. {examples}",
289 k.declared.token(),
290 mismatches,
291 k.judged,
292 k.producer.0,
293 parts.join(", "),
294 ),
295 citation: Some("RFC 08 §2".into()),
296 }
297 });
298 }
299 if k.undecoded > 0 {
300 unjudged.push_with(|| DoctorFinding {
301 severity: DoctorSeverity::Warning,
302 check: CheckId::KindMismatch,
303 subject: key.to_string(),
304 evidence: format!(
305 "kind not judged: {} payload(s) from origin {} in {window_s:.0}s could \
306 not be decoded, so the declared `{}` is unobservable for them",
307 k.undecoded,
308 k.producer.0,
309 k.declared.token(),
310 ),
311 citation: Some("RFC 13 §3".into()),
312 });
313 }
314 }
315 for (ex, tail) in [
316 (bad, "more key(s) with the same finding"),
317 (unjudged, "more key(s) whose kind was not judged"),
318 ] {
319 let more = ex.more(tail);
320 findings.extend(ex.into_vec());
321 if let Some(evidence) = more {
322 findings.push(DoctorFinding {
323 severity: DoctorSeverity::Info,
324 check: CheckId::KindMismatch,
325 subject: "fleet".into(),
326 evidence,
327 citation: None,
328 });
329 }
330 }
331 findings
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use serde_json::json;
338
339 const KEY: &str = "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/memory/oom_kills_total";
340
341 fn observe(obs: &mut KindObservation, declared: SubjectKind, doc: Value) {
342 obs.observe(KEY, "h-aaaaaaaaaaaa", "sysinfo", declared, Some(&doc));
343 }
344
345 fn mismatches(obs: &KindObservation) -> Vec<DoctorFinding> {
346 judge_kind(obs, 10.0)
347 .into_iter()
348 .filter(|f| f.severity == DoctorSeverity::Error)
349 .collect()
350 }
351
352 #[test]
355 fn a_decreasing_counter_is_a_finding_and_a_rising_one_is_nothing() {
356 let mut obs = KindObservation::new();
357 for n in [10, 20, 30] {
358 observe(&mut obs, SubjectKind::Counter, json!(n));
359 }
360 assert!(
361 judge_kind(&obs, 10.0).is_empty(),
362 "no Established(yes) exists"
363 );
364
365 observe(&mut obs, SubjectKind::Counter, json!(5));
366 let f = mismatches(&obs);
367 assert_eq!(f.len(), 1, "{f:?}");
368 assert_eq!(f[0].check, CheckId::KindMismatch);
369 assert_eq!(f[0].subject, KEY);
370 assert!(
371 f[0].evidence.contains("h-aaaaaaaaaaaa"),
372 "{}",
373 f[0].evidence
374 );
375 assert!(f[0].evidence.contains("10s"), "the window is stated");
376 assert!(f[0].evidence.contains("30 → 5"), "{}", f[0].evidence);
377 assert_eq!(f[0].citation.as_deref(), Some("RFC 08 §2"));
378 }
379
380 #[test]
385 fn a_reset_across_an_alive_cycle_is_not_a_finding() {
386 let mut obs = KindObservation::new();
387 let other = "v1/h-aaaaaaaaaaaa/telemetry/sysinfo/memory/page_faults_total";
388 observe(&mut obs, SubjectKind::Counter, json!(10));
389 obs.observe(
390 other,
391 "h-aaaaaaaaaaaa",
392 "sysinfo",
393 SubjectKind::Counter,
394 Some(&json!(7)),
395 );
396 obs.alive_cycled("h-aaaaaaaaaaaa", "sysinfo");
397 observe(&mut obs, SubjectKind::Counter, json!(0));
398 obs.observe(
399 other,
400 "h-aaaaaaaaaaaa",
401 "sysinfo",
402 SubjectKind::Counter,
403 Some(&json!(0)),
404 );
405 assert!(mismatches(&obs).is_empty(), "{:?}", judge_kind(&obs, 10.0));
406
407 observe(&mut obs, SubjectKind::Counter, json!(3));
409 observe(&mut obs, SubjectKind::Counter, json!(1));
410 assert_eq!(mismatches(&obs).len(), 1);
411
412 let mut obs = KindObservation::new();
414 observe(&mut obs, SubjectKind::Counter, json!(10));
415 obs.alive_cycled("h-aaaaaaaaaaaa", "netring");
416 observe(&mut obs, SubjectKind::Counter, json!(0));
417 assert_eq!(mismatches(&obs).len(), 1);
418 }
419
420 #[test]
424 fn a_disagreeing_tag_is_a_finding_and_an_agreeing_or_foreign_one_is_not() {
425 let mut obs = KindObservation::new();
426 observe(
427 &mut obs,
428 SubjectKind::Counter,
429 json!({"type": "gauge", "value": 1}),
430 );
431 let f = mismatches(&obs);
432 assert_eq!(f.len(), 1, "{f:?}");
433 assert!(
434 f[0].evidence.contains("tags itself `gauge`"),
435 "{}",
436 f[0].evidence
437 );
438
439 let mut obs = KindObservation::new();
440 observe(
441 &mut obs,
442 SubjectKind::Bool,
443 json!({"type": "boolean", "value": true}),
444 );
445 observe(
446 &mut obs,
447 SubjectKind::Bool,
448 json!({"type": "histogram", "value": true}),
449 );
450 assert!(mismatches(&obs).is_empty());
451 }
452
453 #[test]
456 fn a_value_not_of_the_declared_kind_is_a_finding() {
457 let mut obs = KindObservation::new();
458 observe(&mut obs, SubjectKind::Text, json!(3));
459 observe(&mut obs, SubjectKind::Text, json!({"value": "ok"}));
460 assert_eq!(mismatches(&obs).len(), 1);
461 let mut obs = KindObservation::new();
462 observe(&mut obs, SubjectKind::Bool, json!({"value": "true"}));
463 assert_eq!(mismatches(&obs).len(), 1);
464 let mut obs = KindObservation::new();
465 observe(&mut obs, SubjectKind::Counter, json!(-1));
466 assert_eq!(mismatches(&obs).len(), 1);
467 let mut obs = KindObservation::new();
468 observe(&mut obs, SubjectKind::Gauge, json!(-1.5));
469 observe(&mut obs, SubjectKind::Gauge, json!({"value": 2}));
470 assert!(mismatches(&obs).is_empty(), "a gauge is any number");
471 }
472
473 #[test]
476 fn an_undecodable_payload_is_reported_unobservable_not_passed() {
477 let mut obs = KindObservation::new();
478 obs.observe(KEY, "h-aaaaaaaaaaaa", "sysinfo", SubjectKind::Counter, None);
479 obs.observe(KEY, "h-aaaaaaaaaaaa", "sysinfo", SubjectKind::Counter, None);
480 let f = judge_kind(&obs, 10.0);
481 assert_eq!(f.len(), 1, "{f:?}");
482 assert_eq!(f[0].severity, DoctorSeverity::Warning);
483 assert_eq!(f[0].check, CheckId::KindMismatch);
484 assert!(
485 f[0].evidence.contains("kind not judged: 2 payload(s)"),
486 "{}",
487 f[0].evidence
488 );
489 assert!(f[0].evidence.contains("10s"));
490 }
491
492 #[test]
495 fn the_cap_bites_with_a_counted_remainder() {
496 let mut obs = KindObservation::new();
497 for i in 0..(FINDING_CAP + 3) {
498 let key = format!("v1/h-aaaaaaaaaaaa/telemetry/sysinfo/k{i}");
499 obs.observe(
500 &key,
501 "h-aaaaaaaaaaaa",
502 "sysinfo",
503 SubjectKind::Text,
504 Some(&json!(1)),
505 );
506 }
507 let f = judge_kind(&obs, 10.0);
508 assert_eq!(f.len(), FINDING_CAP + 1, "{f:?}");
509 let tail = f.last().unwrap();
510 assert_eq!(tail.severity, DoctorSeverity::Info);
511 assert!(tail.evidence.contains("… and 3 more"), "{}", tail.evidence);
512 }
513}