Skip to main content

zenkey_fleet/model/
export.rs

1//! The exporter's ledger (#228, RFC 13 §3 *Exporter obligations*): samples
2//! in, an [`ExportSnapshot`] out, and every blind spot counted on the way.
3//!
4//! Pure by the layer's rule — nothing here takes a session. The frontend
5//! (`zenctl export`) decodes a sample structurally, describes its key, and
6//! hands both to [`ExportLedger::ingest`]; a scrape is one
7//! [`ExportLedger::fold`] over the monitor's counters, the statistics table,
8//! the roster's departures and the last doctor run. A `.zrec` replayed
9//! through the same two calls would fold to the same series.
10//!
11//! **A series is the contract, not the wire.** Its identity is `(origin,
12//! producer, declared pattern, `{var}` bindings, field)`; its name and unit
13//! are the registry's; a key that does not refine is counted under
14//! `unregistered_keys` and never exported. Everything the ledger refuses —
15//! a population past its declared `cardinality`, a series past
16//! `max_series`, a field past the per-subject cap, a `text` kind, a payload
17//! with no number in it — is counted by reason, because to a scraper a
18//! series that was never made and a series that stopped look the same, and
19//! only a count tells them apart.
20//!
21//! **Coalescing is the third O6 kind.** Between two folds only the newest
22//! value per series survives; the samples folded into it are counted, and
23//! the count rides the exposition beside the drops and the evictions.
24
25use 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
39/// How many top-level numeric fields one subject family may fan out into
40/// when its payload is an object rather than one leaf value. Past it, the
41/// overflow is counted under `suppressed["fields"]`.
42pub const FIELD_CAP: usize = 16;
43
44/// The default `--max-series` bound.
45pub const DEFAULT_MAX_SERIES: usize = 10_000;
46
47/// A payload verdict as the ledger counts it — the three populations, with
48/// every reason a payload was *not* validated folded into the third
49/// (RFC 13 §3: never a ratio that hides it).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PayloadVerdict {
52    Valid,
53    Invalid,
54    NotValidated,
55}
56
57/// One sample, as the frontend hands it over.
58#[derive(Debug, Clone, Copy)]
59pub struct Observed<'a> {
60    /// The full wire key.
61    pub key: &'a str,
62    /// `SampleKind::Delete` — a retirement tombstone (RFC 04 §1.2), never a
63    /// value.
64    pub delete: bool,
65    /// The structural document, when the bytes carried one. `None` is
66    /// *undecodable* and is counted as such.
67    pub doc: Option<&'a Value>,
68    /// Whether the wire's QoS axes matched the declared profile; `None`
69    /// when the subject declares none this build knows (not judged).
70    pub qos_matches: Option<bool>,
71    pub verdict: PayloadVerdict,
72    /// Arrival, unix seconds on the observer's clock.
73    pub wall_unix_s: u64,
74}
75
76/// What one fold reads besides the ledger.
77pub struct FoldInputs<'a> {
78    pub stats: &'a StatsTable,
79    pub retention: RetentionStats,
80    /// The monitor's cumulative broadcast drops.
81    pub dropped: u64,
82    /// `(origin, producer)` pairs whose `alive` token the roster saw leave.
83    pub down: &'a BTreeSet<(String, String)>,
84    /// The last doctor run and when it finished, if one was asked for.
85    pub doctor: Option<DoctorRun<'a>>,
86    pub now: SystemTime,
87}
88
89/// A doctor report with the time it finished.
90#[derive(Debug, Clone, Copy)]
91pub struct DoctorRun<'a> {
92    pub report: &'a DoctorReport,
93    pub ran_at_unix_s: u64,
94}
95
96/// The contract identity of one series. Ordered, so the snapshot is
97/// deterministic.
98#[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/// One `(origin, producer, pattern)` family's populations, for the two caps.
125#[derive(Debug, Default)]
126struct Family {
127    bindings: BTreeSet<Vec<(String, String)>>,
128    fields: BTreeSet<String>,
129}
130
131/// The ledger.
132#[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
149/// The planes a `*`/`**` selector cannot reach, named verbatim (RFC 03 §4
150/// D2/D4; RFC 13 §3 O5).
151pub const WILDCARD_EXCLUDES: [&str; 5] = ["@rpc", "@media", "@blob", "@adv", "service origins"];
152
153/// What a wildcard scope leaves out: the verbatim planes when any scope
154/// carries a wildcard, nothing when every scope is concrete.
155pub 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
169/// The numeric reading of a JSON value: a number, or a bool as `0`/`1`.
170fn 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    /// An empty ledger over `scopes`, started at `started_at`.
180    ///
181    /// `registry_producers` is `None` when no registry was loaded — then
182    /// nothing refines, every key is unregistered, and the snapshot says
183    /// the registry was not asked rather than that it declares nothing.
184    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    /// Distinct series held.
212    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    /// Feed one sample. `facts` is the key's projection under the active
221    /// base and registry ([`crate::describe_key`] or a
222    /// [`crate::FactsCache`] entry).
223    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                // Bounded like the series map: past the bound the key is
234                // counted under `max_series`, which is the bound it hit.
235                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        // A service origin omits the producer chunk (RFC 03 §1.5); the
250        // roster spells its producer as the origin without the `@`
251        // (`token_identity`), and the `down` set is matched against that.
252        let producer = v
253            .producer
254            .clone()
255            .unwrap_or_else(|| v.origin.trim_start_matches('@').to_string());
256
257        // The contract counters count every sample that refined, tombstone
258        // or not: a mismatched QoS on a delete is still a mismatch.
259        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            // The producer's own statement: every series fed from this key
277            // is retired. Labels stay; the value line goes.
278            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        // The values: one leaf, or one per top-level numeric field.
300        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                // The RFC 11 §4 self-describing tag: the leaf is `value`.
306                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            // RFC 04 §1 bounds a `{var}` population per producer; a
330            // literal subject's population is 1 by construction.
331            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        // The field cap, decided while the family is borrowed; the count is
341        // charged once the borrow ends.
342        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    /// One scrape: the snapshot as of `inputs.now`.
412    ///
413    /// Two folds with no ingest between them differ only in
414    /// `taken_at_unix_s` (and in a `quiet` judgement a ttl may have crossed)
415    /// — the exposition carries neither the scrape time nor anything else
416    /// that moves without traffic, which is what makes an idle scrape
417    /// byte-identical.
418    pub fn fold(&mut self, inputs: &FoldInputs<'_>) -> ExportSnapshot {
419        // Drops since the last fold taint every series fed in the interval:
420        // its value may not be the newest, and the row says so.
421        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    /// The acceptance clause: killing a producer makes its series go stale
668    /// **explicitly** — the state names it and the value line is gone, the
669    /// labels are not.
670    #[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    /// The second acceptance clause: forcing observer drops moves the
697    /// counter and taints exactly the series fed in that interval.
698    #[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        // Only cpu is fed while the observer drops.
706        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    /// The third acceptance clause: two scrapes with no traffic are
724    /// byte-identical.
725    #[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    /// Coalescing is counted, and only the newest value is exposed.
741    #[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    /// A population past its declared `cardinality` is suppressed and
756    /// counted — never a silent missing series.
757    #[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    /// The four O6 populations serialize distinctly and are never summed —
780    /// asserted with all of them non-zero, because a renderer that adds
781    /// them up passes any fixture where three are zero.
782    #[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    /// The three payload populations are always three lines, and without
811    /// validation everything is the third.
812    #[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    /// A key the registry does not declare is counted, never exported; a
823    /// declared `text` kind and a non-numeric payload are counted by reason.
824    #[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    /// An object payload fans out into one series per top-level numeric
842    /// field, and a tombstone retires them all.
843    #[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    /// A `state` subject past its declared `ttl_s` is `quiet`; telemetry
881    /// declares no period and is never judged (O4).
882    #[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    /// A key the statistics table forgot is `evicted`, by name.
904    #[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}