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}