1use std::collections::{BTreeMap, BTreeSet};
26use std::time::SystemTime;
27
28use serde_json::Value;
29
30use crate::model::facts::{KeyFacts, KeyShape, Registration};
31use crate::model::prom::metric_name;
32use crate::model::retain::RetentionStats;
33use crate::model::stats::StatsTable;
34use crate::report::{
35 Asked, ContractCounters, DoctorFindingRef, DoctorReport, DoctorSummary, ExportSnapshot,
36 ObserverCounters, QosMismatchRow, RegistryInfo, SeriesRow, SeriesState,
37};
38
39pub const FIELD_CAP: usize = 16;
43
44pub const DEFAULT_MAX_SERIES: usize = 10_000;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PayloadVerdict {
52 Valid,
53 Invalid,
54 NotValidated,
55}
56
57#[derive(Debug, Clone, Copy)]
59pub struct Observed<'a> {
60 pub key: &'a str,
62 pub delete: bool,
65 pub doc: Option<&'a Value>,
68 pub qos_matches: Option<bool>,
71 pub verdict: PayloadVerdict,
72 pub wall_unix_s: u64,
74}
75
76pub struct FoldInputs<'a> {
78 pub stats: &'a StatsTable,
79 pub retention: RetentionStats,
80 pub dropped: u64,
82 pub down: &'a BTreeSet<(String, String)>,
84 pub doctor: Option<DoctorRun<'a>>,
86 pub now: SystemTime,
87}
88
89#[derive(Debug, Clone, Copy)]
91pub struct DoctorRun<'a> {
92 pub report: &'a DoctorReport,
93 pub ran_at_unix_s: u64,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
99struct SeriesKey {
100 producer: String,
101 pattern: String,
102 origin: String,
103 bindings: Vec<(String, String)>,
104 field: Option<String>,
105}
106
107#[derive(Debug, Clone)]
108struct Series {
109 name: String,
110 key: String,
111 class: String,
112 kind: Option<String>,
113 unit: Option<String>,
114 ttl_s: Option<i64>,
115 state_class: bool,
116 last_value: f64,
117 last_seen_unix_s: u64,
118 samples: u64,
119 since_fold: u64,
120 drop_exposed: u64,
121 retired: bool,
122}
123
124#[derive(Debug, Default)]
126struct Family {
127 bindings: BTreeSet<Vec<(String, String)>>,
128 fields: BTreeSet<String>,
129}
130
131#[derive(Debug)]
133pub struct ExportLedger {
134 max_series: usize,
135 series: BTreeMap<SeriesKey, Series>,
136 families: BTreeMap<(String, String, String), Family>,
137 unregistered: BTreeSet<String>,
138 suppressed: BTreeMap<&'static str, u64>,
139 contract: ContractCounters,
140 qos_by_subject: BTreeMap<(String, String), u64>,
141 last_dropped: u64,
142 coalesced: u64,
143 scopes: Vec<String>,
144 excluded: Vec<String>,
145 registry_producers: Option<usize>,
146 started_at_unix_s: u64,
147}
148
149pub const WILDCARD_EXCLUDES: [&str; 5] = ["@rpc", "@media", "@blob", "@adv", "service origins"];
152
153pub fn excluded_by(scopes: &[String]) -> Vec<String> {
156 if scopes.iter().any(|s| s.contains('*')) {
157 WILDCARD_EXCLUDES.iter().map(|s| (*s).to_string()).collect()
158 } else {
159 Vec::new()
160 }
161}
162
163fn unix_s(t: SystemTime) -> u64 {
164 t.duration_since(SystemTime::UNIX_EPOCH)
165 .map(|d| d.as_secs())
166 .unwrap_or(0)
167}
168
169fn numeric(v: &Value) -> Option<f64> {
171 match v {
172 Value::Number(n) => n.as_f64(),
173 Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
174 _ => None,
175 }
176}
177
178impl ExportLedger {
179 pub fn new(
185 max_series: usize,
186 scopes: Vec<String>,
187 registry_producers: Option<usize>,
188 started_at: SystemTime,
189 ) -> ExportLedger {
190 ExportLedger {
191 max_series,
192 series: BTreeMap::new(),
193 families: BTreeMap::new(),
194 unregistered: BTreeSet::new(),
195 suppressed: BTreeMap::new(),
196 contract: ContractCounters::default(),
197 qos_by_subject: BTreeMap::new(),
198 last_dropped: 0,
199 coalesced: 0,
200 excluded: excluded_by(&scopes),
201 scopes,
202 registry_producers,
203 started_at_unix_s: unix_s(started_at),
204 }
205 }
206
207 fn suppress(&mut self, reason: &'static str) {
208 *self.suppressed.entry(reason).or_default() += 1;
209 }
210
211 pub fn len(&self) -> usize {
213 self.series.len()
214 }
215
216 pub fn is_empty(&self) -> bool {
217 self.series.is_empty()
218 }
219
220 pub fn ingest(&mut self, obs: &Observed<'_>, facts: &KeyFacts) {
224 let KeyShape::V1(v) = &facts.shape else {
225 self.suppress("unparsed");
226 return;
227 };
228 let sf = match &facts.registration {
229 Registration::Registered(sf) => sf,
230 Registration::Unknown
231 | Registration::NoSliceForProducer
232 | Registration::Unregistered => {
233 if !self.unregistered.contains(obs.key) {
236 if self.unregistered.len() >= self.max_series {
237 self.suppress("max_series");
238 } else {
239 self.unregistered.insert(obs.key.to_string());
240 }
241 }
242 return;
243 }
244 Registration::NotApplicable => {
245 self.suppress("unparsed");
246 return;
247 }
248 };
249 let producer = v
253 .producer
254 .clone()
255 .unwrap_or_else(|| v.origin.trim_start_matches('@').to_string());
256
257 if let Some(matched) = obs.qos_matches {
260 self.contract.qos_judged += 1;
261 if !matched {
262 self.contract.qos_mismatch += 1;
263 *self
264 .qos_by_subject
265 .entry((producer.clone(), sf.path.clone()))
266 .or_default() += 1;
267 }
268 }
269 match obs.verdict {
270 PayloadVerdict::Valid => self.contract.payload_valid += 1,
271 PayloadVerdict::Invalid => self.contract.payload_invalid += 1,
272 PayloadVerdict::NotValidated => self.contract.payload_not_validated += 1,
273 }
274
275 if obs.delete {
276 for s in self.series.values_mut() {
279 if s.key == obs.key {
280 s.retired = true;
281 s.last_seen_unix_s = obs.wall_unix_s;
282 s.samples += 1;
283 s.since_fold += 1;
284 }
285 }
286 return;
287 }
288
289 let kind = sf.kind.as_ref().map(|k| k.token().to_string());
290 if kind.as_deref() == Some("text") {
291 self.suppress("text");
292 return;
293 }
294 let Some(doc) = obs.doc else {
295 self.suppress("undecodable");
296 return;
297 };
298
299 let mut values: Vec<(Option<String>, f64)> = Vec::new();
301 if let Some(n) = numeric(doc) {
302 values.push((None, n));
303 } else if let Some(map) = doc.as_object() {
304 match map.get("value") {
305 Some(leaf) => {
307 if let Some(n) = numeric(leaf) {
308 values.push((None, n));
309 }
310 }
311 None => {
312 for (k, val) in map {
313 if let Some(n) = numeric(val) {
314 values.push((Some(k.clone()), n));
315 }
316 }
317 }
318 }
319 }
320 if values.is_empty() {
321 self.suppress("non_numeric");
322 return;
323 }
324
325 let family_key = (v.origin.clone(), producer.clone(), sf.path.clone());
326 let bindings: Vec<(String, String)> = sf.vars.clone();
327 let family = self.families.entry(family_key).or_default();
328 if !family.bindings.contains(&bindings) {
329 if let Some(card) = sf.cardinality
332 && !bindings.is_empty()
333 && family.bindings.len() as i64 >= card
334 {
335 self.suppress("cardinality");
336 return;
337 }
338 family.bindings.insert(bindings.clone());
339 }
340 let mut fields_over = 0u64;
343 let values: Vec<(Option<String>, f64)> = values
344 .into_iter()
345 .filter(|(field, _)| match field {
346 None => true,
347 Some(f) if family.fields.contains(f) => true,
348 Some(f) if family.fields.len() < FIELD_CAP => {
349 family.fields.insert(f.clone());
350 true
351 }
352 Some(_) => {
353 fields_over += 1;
354 false
355 }
356 })
357 .collect();
358 for _ in 0..fields_over {
359 self.suppress("fields");
360 }
361
362 let name = metric_name(&producer, &sf.path, sf.unit.as_deref(), kind.as_deref());
363 let ttl_s = sf.ttl_s;
364 let unit = sf.unit.clone();
365 let state_class = v.class == "state";
366 for (field, value) in values {
367 let key = SeriesKey {
368 producer: producer.clone(),
369 pattern: sf.path.clone(),
370 origin: v.origin.clone(),
371 bindings: bindings.clone(),
372 field,
373 };
374 match self.series.get_mut(&key) {
375 Some(s) => {
376 s.last_value = value;
377 s.last_seen_unix_s = obs.wall_unix_s;
378 s.samples += 1;
379 s.since_fold += 1;
380 s.retired = false;
381 s.key = obs.key.to_string();
382 }
383 None => {
384 if self.series.len() >= self.max_series {
385 self.suppress("max_series");
386 continue;
387 }
388 self.series.insert(
389 key,
390 Series {
391 name: name.clone(),
392 key: obs.key.to_string(),
393 class: v.class.clone(),
394 kind: kind.clone(),
395 unit: unit.clone(),
396 ttl_s,
397 state_class,
398 last_value: value,
399 last_seen_unix_s: obs.wall_unix_s,
400 samples: 1,
401 since_fold: 1,
402 drop_exposed: 0,
403 retired: false,
404 },
405 );
406 }
407 }
408 }
409 }
410
411 pub fn fold(&mut self, inputs: &FoldInputs<'_>) -> ExportSnapshot {
419 let drops_moved = inputs.dropped > self.last_dropped;
422 self.last_dropped = inputs.dropped;
423 let now = unix_s(inputs.now);
424
425 let mut rows = Vec::with_capacity(self.series.len());
426 for (key, s) in self.series.iter_mut() {
427 if s.since_fold > 0 {
428 if drops_moved {
429 s.drop_exposed += 1;
430 }
431 self.coalesced += s.since_fold - 1;
432 s.since_fold = 0;
433 }
434 let state = if s.retired {
435 SeriesState::Retired
436 } else if inputs
437 .down
438 .contains(&(key.origin.clone(), key.producer.clone()))
439 {
440 SeriesState::OriginDown
441 } else if inputs.stats.get(&s.key).is_none() {
442 SeriesState::Evicted
443 } else if s.state_class
444 && let Some(ttl) = s.ttl_s
445 && ttl > 0
446 && now.saturating_sub(s.last_seen_unix_s) > ttl as u64
447 {
448 SeriesState::Quiet
449 } else {
450 SeriesState::Live
451 };
452 rows.push(SeriesRow {
453 name: s.name.clone(),
454 key: s.key.clone(),
455 origin: key.origin.clone(),
456 producer: key.producer.clone(),
457 class: s.class.clone(),
458 subject: key.pattern.clone(),
459 labels: key.bindings.iter().cloned().collect(),
460 field: key.field.clone(),
461 kind: s.kind.clone(),
462 unit: s.unit.clone(),
463 value: state.exposes_value().then_some(s.last_value),
464 last_seen_unix_s: s.last_seen_unix_s,
465 state,
466 samples: s.samples,
467 drop_exposed: s.drop_exposed,
468 });
469 }
470
471 let unstamped = inputs.stats.iter().map(|(_, k)| k.unstamped).sum();
472 let mut contract = self.contract.clone();
473 contract.qos_mismatch_by_subject = self
474 .qos_by_subject
475 .iter()
476 .map(|((producer, subject), n)| QosMismatchRow {
477 producer: producer.clone(),
478 subject: subject.clone(),
479 n: *n,
480 })
481 .collect();
482
483 ExportSnapshot {
484 scopes: self.scopes.clone(),
485 excluded: self.excluded.clone(),
486 registry: self
487 .registry_producers
488 .map(|producers| RegistryInfo { producers })
489 .into(),
490 max_series: self.max_series,
491 started_at_unix_s: self.started_at_unix_s,
492 taken_at_unix_s: now,
493 series: rows,
494 observer: ObserverCounters {
495 dropped: inputs.dropped,
496 evicted_keys: inputs.stats.evicted(),
497 evicted_bytes: inputs.retention.evicted,
498 expired: inputs.retention.expired,
499 unwatched: inputs.stats.unwatched(),
500 coalesced: self.coalesced,
501 unstamped,
502 },
503 contract,
504 suppressed: self
505 .suppressed
506 .iter()
507 .map(|(k, v)| ((*k).to_string(), *v))
508 .collect(),
509 unregistered_keys: self.unregistered.len() as u64,
510 doctor: match inputs.doctor {
511 None => Asked::NotAsked,
512 Some(run) => Asked::Asked(DoctorSummary {
513 ran_at_unix_s: run.ran_at_unix_s,
514 findings: run
515 .report
516 .findings
517 .iter()
518 .map(|f| DoctorFindingRef {
519 check: f.check,
520 severity: f.severity,
521 subject: f.subject.clone(),
522 })
523 .collect(),
524 }),
525 },
526 }
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::model::facts::describe_key;
534 use crate::model::prom::exposition;
535 use crate::model::registry::SliceSet;
536 use serde_json::json;
537 use std::time::{Duration, Instant};
538
539 const ORIGIN: &str = "h-3fa9c2d41b7e";
540
541 fn slices() -> SliceSet {
542 SliceSet::from_slices(vec![
543 zenkey::parse_slice(
544 r#"
545[registry]
546version = "1.0"
547app = "t"
548convention = 1
549[producer]
550name = "sysinfo"
551[[subject]]
552path = "cpu/usage"
553class = "telemetry"
554type = "TelemetryPoint"
555unit = "percent"
556kind = "gauge"
557[[subject]]
558path = "disk/{mount}/used"
559class = "telemetry"
560type = "TelemetryPoint"
561unit = "bytes"
562cardinality = 2
563[[subject]]
564path = "health"
565class = "state"
566type = "HealthSnapshot"
567ttl_s = 30
568[[subject]]
569path = "hostname"
570class = "state"
571type = "Text"
572kind = "text"
573"#,
574 )
575 .expect("fixture parses"),
576 ])
577 }
578
579 fn at(secs: u64) -> SystemTime {
580 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
581 }
582
583 fn key(rest: &str) -> String {
584 format!("v1/{ORIGIN}/{rest}")
585 }
586
587 struct Rig {
588 ledger: ExportLedger,
589 slices: SliceSet,
590 stats: StatsTable,
591 down: BTreeSet<(String, String)>,
592 dropped: u64,
593 clock: Instant,
594 }
595
596 impl Rig {
597 fn new() -> Rig {
598 Rig {
599 ledger: ExportLedger::new(16, vec!["v1/*/**".into()], Some(1), at(1_000)),
600 slices: slices(),
601 stats: StatsTable::new(),
602 down: BTreeSet::new(),
603 dropped: 0,
604 clock: Instant::now(),
605 }
606 }
607
608 fn put(&mut self, rest: &str, doc: Value, wall: u64) {
609 let key = key(rest);
610 self.stats.record(&key, 8, None, self.clock, None, None);
611 let facts = describe_key("", &key, Some(&self.slices)).facts;
612 self.ledger.ingest(
613 &Observed {
614 key: &key,
615 delete: false,
616 doc: Some(&doc),
617 qos_matches: None,
618 verdict: PayloadVerdict::NotValidated,
619 wall_unix_s: wall,
620 },
621 &facts,
622 );
623 }
624
625 fn delete(&mut self, rest: &str, wall: u64) {
626 let key = key(rest);
627 let facts = describe_key("", &key, Some(&self.slices)).facts;
628 self.ledger.ingest(
629 &Observed {
630 key: &key,
631 delete: true,
632 doc: None,
633 qos_matches: None,
634 verdict: PayloadVerdict::NotValidated,
635 wall_unix_s: wall,
636 },
637 &facts,
638 );
639 }
640
641 fn fold(&mut self, now: u64) -> ExportSnapshot {
642 self.ledger.fold(&FoldInputs {
643 stats: &self.stats,
644 retention: RetentionStats {
645 budget: Default::default(),
646 retained: 0,
647 retained_bytes: 0,
648 span: Duration::ZERO,
649 evicted: 0,
650 expired: 0,
651 },
652 dropped: self.dropped,
653 down: &self.down,
654 doctor: None,
655 now: at(now),
656 })
657 }
658 }
659
660 fn row<'a>(s: &'a ExportSnapshot, name: &str) -> &'a SeriesRow {
661 s.series
662 .iter()
663 .find(|r| r.name == name)
664 .unwrap_or_else(|| panic!("no series {name} in {:?}", s.series))
665 }
666
667 #[test]
671 fn a_producer_that_went_away_flips_its_series_to_origin_down_and_drops_the_value() {
672 let mut rig = Rig::new();
673 rig.put("telemetry/sysinfo/cpu/usage", json!(12.5), 1_010);
674 let s = rig.fold(1_011);
675 let r = row(&s, "zenkey_subject_sysinfo_cpu_usage_percent");
676 assert_eq!(r.state, SeriesState::Live);
677 assert_eq!(r.value, Some(12.5));
678
679 rig.down.insert((ORIGIN.to_string(), "sysinfo".to_string()));
680 let s = rig.fold(1_012);
681 let r = row(&s, "zenkey_subject_sysinfo_cpu_usage_percent");
682 assert_eq!(r.state, SeriesState::OriginDown);
683 assert_eq!(r.value, None, "a stopped series exposes no value");
684 assert_eq!(r.origin, ORIGIN, "and keeps its labels");
685 let text = exposition(&s);
686 assert!(
687 text.contains("zenkey_series_state{origin=\"h-3fa9c2d41b7e\",producer=\"sysinfo\",class=\"telemetry\",subject=\"cpu/usage\",state=\"origin_down\"} 1"),
688 "{text}"
689 );
690 assert!(
691 !text.contains("zenkey_subject_sysinfo_cpu_usage_percent{"),
692 "no value line for a series whose origin is down:\n{text}"
693 );
694 }
695
696 #[test]
699 fn drops_move_the_counter_and_taint_only_the_series_fed_in_the_interval() {
700 let mut rig = Rig::new();
701 rig.put("telemetry/sysinfo/cpu/usage", json!(1), 1_010);
702 rig.put("telemetry/sysinfo/disk/root/used", json!(100), 1_010);
703 rig.fold(1_011);
704
705 rig.put("telemetry/sysinfo/cpu/usage", json!(2), 1_012);
707 rig.dropped = 7;
708 let s = rig.fold(1_013);
709 assert_eq!(s.observer.dropped, 7);
710 assert_eq!(
711 row(&s, "zenkey_subject_sysinfo_cpu_usage_percent").drop_exposed,
712 1
713 );
714 assert_eq!(
715 row(&s, "zenkey_subject_sysinfo_disk_used_bytes").drop_exposed,
716 0,
717 "a series not fed in the interval is not tainted by it"
718 );
719 let text = exposition(&s);
720 assert!(text.contains("zenkey_observer_dropped_total 7\n"), "{text}");
721 }
722
723 #[test]
726 fn two_folds_with_no_ingest_between_them_expose_identical_bytes() {
727 let mut rig = Rig::new();
728 rig.put("telemetry/sysinfo/cpu/usage", json!(1), 1_010);
729 rig.put(
730 "state/sysinfo/health",
731 json!({"type": "gauge", "value": 1}),
732 1_010,
733 );
734 let a = exposition(&rig.fold(1_011));
735 let b = exposition(&rig.fold(1_020));
736 assert_eq!(a, b);
737 assert!(a.contains("zenkey_subject_sysinfo_health{"), "{a}");
738 }
739
740 #[test]
742 fn samples_between_two_folds_coalesce_into_the_newest_and_are_counted() {
743 let mut rig = Rig::new();
744 rig.put("telemetry/sysinfo/cpu/usage", json!(1), 1_010);
745 rig.put("telemetry/sysinfo/cpu/usage", json!(2), 1_011);
746 rig.put("telemetry/sysinfo/cpu/usage", json!(3), 1_012);
747 let s = rig.fold(1_013);
748 assert_eq!(
749 row(&s, "zenkey_subject_sysinfo_cpu_usage_percent").value,
750 Some(3.0)
751 );
752 assert_eq!(s.observer.coalesced, 2);
753 }
754
755 #[test]
758 fn a_cardinality_overflow_is_suppressed_and_counted() {
759 let mut rig = Rig::new();
760 rig.put("telemetry/sysinfo/disk/root/used", json!(1), 1_010);
761 rig.put("telemetry/sysinfo/disk/var/used", json!(2), 1_010);
762 rig.put("telemetry/sysinfo/disk/tmp/used", json!(3), 1_010);
763 rig.put("telemetry/sysinfo/disk/tmp/used", json!(4), 1_011);
764 let s = rig.fold(1_012);
765 assert_eq!(
766 s.series
767 .iter()
768 .filter(|r| r.subject == "disk/{mount}/used")
769 .count(),
770 2
771 );
772 assert_eq!(s.suppressed.get("cardinality"), Some(&2));
773 assert_eq!(
774 row(&s, "zenkey_subject_sysinfo_disk_used_bytes").labels["mount"],
775 "root"
776 );
777 }
778
779 #[test]
783 fn the_evicted_populations_are_four_lines_and_never_one() {
784 let mut rig = Rig::new();
785 rig.put("telemetry/sysinfo/cpu/usage", json!(1), 1_010);
786 let mut s = rig.fold(1_011);
787 s.observer.evicted_keys = 3;
788 s.observer.evicted_bytes = 5;
789 s.observer.expired = 7;
790 s.observer.unwatched = 11;
791 s.observer.dropped = 13;
792 s.observer.coalesced = 17;
793 let text = exposition(&s);
794 for line in [
795 "zenkey_observer_evicted_total{population=\"keys\"} 3",
796 "zenkey_observer_evicted_total{population=\"retained_bytes\"} 5",
797 "zenkey_observer_evicted_total{population=\"retained_age\"} 7",
798 "zenkey_observer_evicted_total{population=\"unwatched\"} 11",
799 "zenkey_observer_dropped_total 13",
800 "zenkey_observer_coalesced_total 17",
801 ] {
802 assert!(text.contains(line), "missing `{line}` in:\n{text}");
803 }
804 assert!(
805 !text.contains(" 26") && !text.contains(" 56"),
806 "no sum of the kinds:\n{text}"
807 );
808 }
809
810 #[test]
813 fn payload_verdicts_are_three_populations_never_a_ratio() {
814 let mut rig = Rig::new();
815 rig.put("telemetry/sysinfo/cpu/usage", json!(1), 1_010);
816 let text = exposition(&rig.fold(1_011));
817 assert!(text.contains("zenkey_payload_verdict_total{verdict=\"valid\"} 0"));
818 assert!(text.contains("zenkey_payload_verdict_total{verdict=\"invalid\"} 0"));
819 assert!(text.contains("zenkey_payload_verdict_total{verdict=\"not_validated\"} 1"));
820 }
821
822 #[test]
825 fn what_is_not_exported_is_counted_by_reason() {
826 let mut rig = Rig::new();
827 rig.put("telemetry/sysinfo/nope", json!(1), 1_010);
828 rig.put("telemetry/other/cpu/usage", json!(1), 1_010);
829 rig.put("state/sysinfo/hostname", json!("box"), 1_010);
830 rig.put("telemetry/sysinfo/cpu/usage", json!("high"), 1_010);
831 let s = rig.fold(1_011);
832 assert_eq!(s.unregistered_keys, 2);
833 assert_eq!(s.suppressed.get("text"), Some(&1));
834 assert_eq!(s.suppressed.get("non_numeric"), Some(&1));
835 assert!(s.series.is_empty());
836 let text = exposition(&s);
837 assert!(text.contains("zenkey_unregistered_keys 2"));
838 assert!(text.contains("zenkey_series_suppressed_total{reason=\"text\"} 1"));
839 }
840
841 #[test]
844 fn an_object_fans_out_by_field_and_a_tombstone_retires_the_key() {
845 let mut rig = Rig::new();
846 rig.put(
847 "state/sysinfo/health",
848 json!({"uptime_s": 42, "ok": true, "note": "fine"}),
849 1_010,
850 );
851 let s = rig.fold(1_011);
852 let fields: Vec<_> = s.series.iter().filter_map(|r| r.field.clone()).collect();
853 assert_eq!(fields, ["ok", "uptime_s"]);
854 let by_field = |f: &str| {
855 s.series
856 .iter()
857 .find(|r| r.field.as_deref() == Some(f))
858 .and_then(|r| r.value)
859 };
860 assert_eq!(by_field("ok"), Some(1.0), "a bool reads as 0/1");
861 assert_eq!(by_field("uptime_s"), Some(42.0));
862 assert!(
863 s.series
864 .iter()
865 .all(|r| r.name == "zenkey_subject_sysinfo_health"),
866 "fields share the subject's name and differ by label"
867 );
868
869 rig.delete("state/sysinfo/health", 1_012);
870 let s = rig.fold(1_013);
871 assert!(
872 s.series
873 .iter()
874 .all(|r| r.state == SeriesState::Retired && r.value.is_none()),
875 "{:?}",
876 s.series
877 );
878 }
879
880 #[test]
883 fn quiet_is_judged_only_against_a_declared_ttl() {
884 let mut rig = Rig::new();
885 rig.put("state/sysinfo/health", json!({"value": 1}), 1_010);
886 rig.put("telemetry/sysinfo/cpu/usage", json!(1), 1_010);
887 let s = rig.fold(1_100);
888 assert_eq!(
889 row(&s, "zenkey_subject_sysinfo_health").state,
890 SeriesState::Quiet
891 );
892 assert_eq!(
893 row(&s, "zenkey_subject_sysinfo_health").value,
894 Some(1.0),
895 "quiet still exposes its last value — it is silence, not a stop"
896 );
897 assert_eq!(
898 row(&s, "zenkey_subject_sysinfo_cpu_usage_percent").state,
899 SeriesState::Live
900 );
901 }
902
903 #[test]
905 fn a_key_the_observer_forgot_is_named_evicted() {
906 let mut rig = Rig::new();
907 rig.stats = StatsTable::with_capacity(1);
908 rig.put("telemetry/sysinfo/cpu/usage", json!(1), 1_010);
909 rig.put("telemetry/sysinfo/disk/root/used", json!(1), 1_010);
910 let s = rig.fold(1_011);
911 assert_eq!(
912 row(&s, "zenkey_subject_sysinfo_cpu_usage_percent").state,
913 SeriesState::Evicted
914 );
915 assert_eq!(s.observer.evicted_keys, 1);
916 }
917
918 #[test]
919 fn a_wildcard_scope_names_what_it_cannot_reach() {
920 assert_eq!(
921 excluded_by(&["v1/*/**".to_string()]),
922 WILDCARD_EXCLUDES.map(String::from)
923 );
924 assert!(
925 excluded_by(&["v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage".to_string()]).is_empty()
926 );
927 }
928}