Skip to main content

zenkey_fleet/report/
export.rs

1//! The metrics-surface snapshot (#228, RFC 13 §3 *Exporter obligations*,
2//! v1.34): what `zenctl export` serves, as one document.
3//!
4//! The Prometheus exposition is a **pure function of this struct**
5//! ([`crate::model::prom::exposition`]), so `--format json` and the text a
6//! scraper reads can never disagree about a value, a state or a bound. The
7//! three exporter obligations are visible in the shape rather than in
8//! prose: every O6 kind is its own counter under [`ObserverCounters`] and is
9//! never summed; a series carries a [`SeriesState`] and loses its value —
10//! not its labels — when it stopped, so absence and silence are different
11//! bytes; the scopes watched and the planes a wildcard cannot reach ride the
12//! document, and a payload verdict is three counted populations, the third
13//! being *not validated*.
14
15use std::collections::BTreeMap;
16
17use serde::Serialize;
18
19use super::asked::{Asked, u64_is_zero};
20use super::doctor::{CheckId, DoctorSeverity};
21
22/// One snapshot of the exporter's ledger, folded at scrape time.
23#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct ExportSnapshot {
25    /// The exact selectors watched — coverage is this list and no wider
26    /// (RFC 13 §3 O5).
27    pub scopes: Vec<String>,
28    /// The planes a wildcard in `scopes` cannot reach, named verbatim; empty
29    /// when no scope carries a wildcard. Never implied by the selector alone.
30    pub excluded: Vec<String>,
31    /// The registry the contract came from: how many producers' slices were
32    /// loaded. *Not asked* when no registry was loaded at all — then no key
33    /// refines and every key counts under `unregistered_keys`, which is a
34    /// statement about the exporter, not about the fleet (O4).
35    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
36    pub registry: Asked<RegistryInfo>,
37    /// The ledger's own bound on distinct series; overflow is counted under
38    /// `suppressed["max_series"]`, never silent.
39    pub max_series: usize,
40    /// When the observer started watching (unix seconds). A claim about a
41    /// span longer than `taken_at - started_at` is unobservable.
42    pub started_at_unix_s: u64,
43    /// When this fold was taken (unix seconds). Deliberately **not** in the
44    /// exposition: two scrapes with no traffic between them must be
45    /// byte-identical, and the scrape time is the scraper's to record.
46    pub taken_at_unix_s: u64,
47    /// Every series the ledger holds, in a deterministic order.
48    pub series: Vec<SeriesRow>,
49    pub observer: ObserverCounters,
50    pub contract: ContractCounters,
51    /// Samples that produced no series, by reason — `cardinality`
52    /// (over the declared budget), `max_series`, `fields` (past the
53    /// per-subject field cap), `text` (a declared text kind), `non_numeric`,
54    /// `undecodable`, `unparsed` (not a v1 data key). Absent when nothing was
55    /// suppressed.
56    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
57    pub suppressed: BTreeMap<String, u64>,
58    /// Distinct keys the registry does not declare (or, with no registry
59    /// loaded, every key): counted and never exported — the contract is the
60    /// registry, and the count keeps the omission visible (O4).
61    pub unregistered_keys: u64,
62    /// The last doctor run, when one was asked for (`--doctor-every`).
63    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
64    pub doctor: Asked<DoctorSummary>,
65}
66
67/// The loaded registry, in one number.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
69pub struct RegistryInfo {
70    pub producers: usize,
71}
72
73/// One exported series: a contract-derived identity, its last value, and
74/// the state that says whether the value is current.
75#[derive(Debug, Clone, PartialEq, Serialize)]
76pub struct SeriesRow {
77    /// The metric name the exposition uses — derived from the registry's
78    /// producer, pattern, `unit` and `kind`, never from the leaf's spelling.
79    pub name: String,
80    /// The concrete wire key this series was last fed from.
81    pub key: String,
82    pub origin: String,
83    pub producer: String,
84    pub class: String,
85    /// The declared pattern, e.g. `disk/{mount}/used`.
86    pub subject: String,
87    /// The `{var}` bindings by their declared names.
88    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
89    pub labels: BTreeMap<String, String>,
90    /// The top-level field this series reads, when the payload was an object
91    /// carrying several numerics rather than one leaf value.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub field: Option<String>,
94    /// The declared kind token, when the registry declares one.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub kind: Option<String>,
97    /// The declared unit, verbatim, when the registry declares one.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub unit: Option<String>,
100    /// The newest value seen. **Absent** when the state says the series
101    /// stopped — a stopped series exposes its state and its labels, never a
102    /// stale number that reads as current.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub value: Option<f64>,
105    /// When the newest sample arrived, unix seconds on the observer's clock
106    /// — captured at ingest, so it does not move between scrapes.
107    pub last_seen_unix_s: u64,
108    pub state: SeriesState,
109    /// Samples folded into this series since the observer started.
110    pub samples: u64,
111    /// Folds during which this series was fed **and** the observer dropped
112    /// samples — the interval where its value may not be the newest.
113    #[serde(skip_serializing_if = "u64_is_zero", default)]
114    pub drop_exposed: u64,
115}
116
117/// Why a series is, or is not, current (RFC 13 §3 — "a series that stopped
118/// is not a series that went quiet").
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "snake_case")]
121pub enum SeriesState {
122    /// Fed, and nothing has said it stopped. For telemetry, which declares
123    /// no period, this is all an observer can say — `last_seen_unix_s`
124    /// carries the fact.
125    Live,
126    /// A `state` subject silent past its declared `ttl_s`. Judged only where
127    /// the registry declares the period; never for telemetry (O4).
128    Quiet,
129    /// The observer chose to forget the key at its own bound (O6); the
130    /// labels are kept so the disappearance is named.
131    Evicted,
132    /// The producer's `alive` token went away (RFC 04 §5) after this series
133    /// was fed.
134    OriginDown,
135    /// The producer retired the key with a tombstone (RFC 04 §1.2) — its
136    /// own statement, which outranks every inference above.
137    Retired,
138}
139
140impl SeriesState {
141    /// The wire token, exactly as it serializes — the `state` label.
142    pub fn as_str(self) -> &'static str {
143        match self {
144            SeriesState::Live => "live",
145            SeriesState::Quiet => "quiet",
146            SeriesState::Evicted => "evicted",
147            SeriesState::OriginDown => "origin_down",
148            SeriesState::Retired => "retired",
149        }
150    }
151
152    /// Whether a value line is exposed in this state.
153    pub fn exposes_value(self) -> bool {
154        matches!(self, SeriesState::Live | SeriesState::Quiet)
155    }
156}
157
158/// What the bounded observer cost, by kind — never summed (RFC 13 §3 O6).
159/// Every field is always present: a scraper expects a series to exist at
160/// zero, and "absent" is the one thing an exporter must not let a series do.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
162pub struct ObserverCounters {
163    /// Samples missed while the ledger's receiver was behind.
164    pub dropped: u64,
165    /// Keys retired from the statistics table at its bound.
166    pub evicted_keys: u64,
167    /// Retained samples dropped because the byte budget bit.
168    pub evicted_bytes: u64,
169    /// Retained samples that aged past the retention window.
170    pub expired: u64,
171    /// Keys retired because their watch was released.
172    pub unwatched: u64,
173    /// Samples folded into a newer one between two scrapes — the exporter
174    /// keeps the newest value per series and counts the rest here.
175    pub coalesced: u64,
176    /// Samples that carried no HLC — counted, never defaulted to arrival.
177    pub unstamped: u64,
178}
179
180/// Declared-versus-observed, counted (RFC 13 §3 — "a payload verdict is
181/// three counted populations").
182#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
183pub struct ContractCounters {
184    /// Samples whose subject declares a QoS profile this build knows.
185    pub qos_judged: u64,
186    /// Of those, samples that did not ride the declared profile.
187    pub qos_mismatch: u64,
188    /// The mismatches by declared subject, for the labelled series.
189    #[serde(skip_serializing_if = "Vec::is_empty", default)]
190    pub qos_mismatch_by_subject: Vec<QosMismatchRow>,
191    pub payload_valid: u64,
192    pub payload_invalid: u64,
193    /// The third population: without `--validate` every sample lands here,
194    /// and so does every sample past the decode budget.
195    pub payload_not_validated: u64,
196}
197
198/// One declared subject's QoS mismatches.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct QosMismatchRow {
201    pub producer: String,
202    pub subject: String,
203    pub n: u64,
204}
205
206/// The last doctor run, reduced to what a series can carry.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208pub struct DoctorSummary {
209    pub ran_at_unix_s: u64,
210    pub findings: Vec<DoctorFindingRef>,
211}
212
213/// One finding as a series identity: which check, how bad, on what.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
215pub struct DoctorFindingRef {
216    pub check: CheckId,
217    pub severity: DoctorSeverity,
218    pub subject: String,
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use serde_json::json;
225
226    /// The whole document, pinned: a stopped series carries no `value`, an
227    /// unasked doctor and an unloaded registry are absent, a zero
228    /// `drop_exposed` is absent, and every observer counter is present at
229    /// zero.
230    #[test]
231    fn an_export_snapshot_is_pinned() {
232        let snapshot = ExportSnapshot {
233            scopes: vec!["v1/*/**".into()],
234            excluded: vec!["@rpc".into(), "service origins".into()],
235            registry: Asked::Asked(RegistryInfo { producers: 2 }),
236            max_series: 10_000,
237            started_at_unix_s: 1_700_000_000,
238            taken_at_unix_s: 1_700_000_060,
239            series: vec![
240                SeriesRow {
241                    name: "zenkey_subject_sysinfo_cpu_usage_percent".into(),
242                    key: "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage".into(),
243                    origin: "h-3fa9c2d41b7e".into(),
244                    producer: "sysinfo".into(),
245                    class: "telemetry".into(),
246                    subject: "cpu/usage".into(),
247                    labels: BTreeMap::new(),
248                    field: None,
249                    kind: Some("gauge".into()),
250                    unit: Some("percent".into()),
251                    value: Some(12.5),
252                    last_seen_unix_s: 1_700_000_059,
253                    state: SeriesState::Live,
254                    samples: 7,
255                    drop_exposed: 0,
256                },
257                SeriesRow {
258                    name: "zenkey_subject_sysinfo_disk_used_bytes".into(),
259                    key: "v1/h-3fa9c2d41b7e/telemetry/sysinfo/disk/var-log/used".into(),
260                    origin: "h-3fa9c2d41b7e".into(),
261                    producer: "sysinfo".into(),
262                    class: "telemetry".into(),
263                    subject: "disk/{mount}/used".into(),
264                    labels: [("mount".to_string(), "var-log".to_string())]
265                        .into_iter()
266                        .collect(),
267                    field: Some("free".into()),
268                    kind: None,
269                    unit: Some("bytes".into()),
270                    value: None,
271                    last_seen_unix_s: 1_700_000_010,
272                    state: SeriesState::OriginDown,
273                    samples: 3,
274                    drop_exposed: 1,
275                },
276            ],
277            observer: ObserverCounters::default(),
278            contract: ContractCounters {
279                qos_judged: 7,
280                qos_mismatch: 1,
281                qos_mismatch_by_subject: vec![QosMismatchRow {
282                    producer: "sysinfo".into(),
283                    subject: "cpu/usage".into(),
284                    n: 1,
285                }],
286                payload_valid: 0,
287                payload_invalid: 0,
288                payload_not_validated: 10,
289            },
290            suppressed: [("cardinality".to_string(), 4u64)].into_iter().collect(),
291            unregistered_keys: 1,
292            doctor: Asked::NotAsked,
293        };
294        assert_eq!(
295            serde_json::to_value(&snapshot).unwrap(),
296            json!({
297                "scopes": ["v1/*/**"],
298                "excluded": ["@rpc", "service origins"],
299                "registry": {"producers": 2},
300                "max_series": 10000,
301                "started_at_unix_s": 1_700_000_000,
302                "taken_at_unix_s": 1_700_000_060,
303                "series": [
304                    {
305                        "name": "zenkey_subject_sysinfo_cpu_usage_percent",
306                        "key": "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage",
307                        "origin": "h-3fa9c2d41b7e",
308                        "producer": "sysinfo",
309                        "class": "telemetry",
310                        "subject": "cpu/usage",
311                        "kind": "gauge",
312                        "unit": "percent",
313                        "value": 12.5,
314                        "last_seen_unix_s": 1_700_000_059,
315                        "state": "live",
316                        "samples": 7,
317                    },
318                    {
319                        "name": "zenkey_subject_sysinfo_disk_used_bytes",
320                        "key": "v1/h-3fa9c2d41b7e/telemetry/sysinfo/disk/var-log/used",
321                        "origin": "h-3fa9c2d41b7e",
322                        "producer": "sysinfo",
323                        "class": "telemetry",
324                        "subject": "disk/{mount}/used",
325                        "labels": {"mount": "var-log"},
326                        "field": "free",
327                        "unit": "bytes",
328                        "last_seen_unix_s": 1_700_000_010,
329                        "state": "origin_down",
330                        "samples": 3,
331                        "drop_exposed": 1,
332                    },
333                ],
334                "observer": {
335                    "dropped": 0,
336                    "evicted_keys": 0,
337                    "evicted_bytes": 0,
338                    "expired": 0,
339                    "unwatched": 0,
340                    "coalesced": 0,
341                    "unstamped": 0,
342                },
343                "contract": {
344                    "qos_judged": 7,
345                    "qos_mismatch": 1,
346                    "qos_mismatch_by_subject": [
347                        {"producer": "sysinfo", "subject": "cpu/usage", "n": 1}
348                    ],
349                    "payload_valid": 0,
350                    "payload_invalid": 0,
351                    "payload_not_validated": 10,
352                },
353                "suppressed": {"cardinality": 4},
354                "unregistered_keys": 1,
355            })
356        );
357    }
358
359    /// The asked poles: a doctor that ran and a registry that was never
360    /// loaded — one present with its findings, the other absent rather than
361    /// `{"producers": 0}`.
362    #[test]
363    fn a_doctor_run_and_an_unloaded_registry_are_pinned() {
364        let doctor = DoctorSummary {
365            ran_at_unix_s: 1_700_000_030,
366            findings: vec![DoctorFindingRef {
367                check: CheckId::StaleState,
368                severity: DoctorSeverity::Warning,
369                subject: "h-3fa9c2d41b7e/sysinfo".into(),
370            }],
371        };
372        assert_eq!(
373            serde_json::to_value(&doctor).unwrap(),
374            json!({
375                "ran_at_unix_s": 1_700_000_030,
376                "findings": [
377                    {"check": "stale-state", "severity": "warning", "subject": "h-3fa9c2d41b7e/sysinfo"}
378                ],
379            })
380        );
381        let unloaded: Asked<RegistryInfo> = Asked::NotAsked;
382        assert!(unloaded.is_not_asked());
383    }
384
385    #[test]
386    fn a_stopped_state_exposes_no_value() {
387        for s in [
388            SeriesState::Evicted,
389            SeriesState::OriginDown,
390            SeriesState::Retired,
391        ] {
392            assert!(!s.exposes_value(), "{}", s.as_str());
393        }
394        assert!(SeriesState::Live.exposes_value());
395        assert!(SeriesState::Quiet.exposes_value());
396    }
397}