Skip to main content

zenkey_fleet/model/
prom.rs

1//! The Prometheus text exposition of an [`ExportSnapshot`] (#228, RFC 13
2//! §3 *Exporter obligations*).
3//!
4//! A pure function of the snapshot, so the text a scraper reads and the
5//! `--format json` document can never disagree — and deterministic in every
6//! byte, so two scrapes with no traffic between them are identical (the
7//! snapshot's `taken_at` is deliberately not here).
8//!
9//! **Names and units come from the registry, never from the leaf.** A
10//! series is `zenkey_subject_<producer>_<literal chunks>` with the declared
11//! `unit` normalised into the conventional suffix and `_total` appended for a
12//! declared `counter`; a `{var}` chunk becomes a label named by its declared
13//! name. The one thing this module does *not* do is guess: a subject with no
14//! `unit` gets no suffix and a subject with no `kind` is `untyped`.
15//!
16//! What the exposition refuses is recorded in `zenctl export --help`, not
17//! here — histograms, summaries, remote write and push are tool decisions
18//! this chapter records rather than makes.
19
20use std::collections::BTreeMap;
21use std::fmt::Write as _;
22
23use crate::report::{ExportSnapshot, SeriesRow, SeriesState};
24
25/// The metric name for a registry subject: `zenkey_subject_<producer>_<literal
26/// chunks>[_<unit>][_total]`.
27///
28/// `unit` and `kind` are the registry's tokens (`kind` is `counter`,
29/// `gauge`, `bool`, `text` or a token this build does not know). A suffix
30/// already spelled by the leaf's last chunk (`rx_bytes` with `unit =
31/// "bytes"`, `messages_total` with `kind = "counter"`) is not doubled — that
32/// is a comparison against the declaration, not a sniff of the leaf.
33pub fn metric_name(
34    producer: &str,
35    pattern: &str,
36    unit: Option<&str>,
37    kind: Option<&str>,
38) -> String {
39    let mut name = String::from("zenkey_subject_");
40    name.push_str(&sanitize(producer));
41    for chunk in pattern.split('/') {
42        if chunk.starts_with('{') {
43            continue;
44        }
45        name.push('_');
46        name.push_str(&sanitize(chunk));
47    }
48    if let Some(unit) = unit {
49        let suffix = unit_suffix(unit);
50        if !suffix.is_empty() && !name.ends_with(&format!("_{suffix}")) {
51            name.push('_');
52            name.push_str(&suffix);
53        }
54    }
55    if kind == Some("counter") && !name.ends_with("_total") {
56        name.push_str("_total");
57    }
58    name
59}
60
61/// The registry `unit` as a Prometheus suffix: the base-unit spellings the
62/// convention uses, anything else verbatim (sanitised).
63pub fn unit_suffix(unit: &str) -> String {
64    match unit {
65        "ms" => "milliseconds".into(),
66        "us" => "microseconds".into(),
67        "ns" => "nanoseconds".into(),
68        "s" => "seconds".into(),
69        "bytes" | "B" => "bytes".into(),
70        "percent" | "%" => "percent".into(),
71        "ratio" => "ratio".into(),
72        other => sanitize(other),
73    }
74}
75
76/// The `# TYPE` a declared kind earns: a counter is a counter, a gauge or
77/// a bool is a gauge, anything else — including no declaration — is
78/// `untyped`, which is Prometheus's spelling of *not asked*.
79pub fn prom_type(kind: Option<&str>) -> &'static str {
80    match kind {
81        Some("counter") => "counter",
82        Some("gauge" | "bool") => "gauge",
83        _ => "untyped",
84    }
85}
86
87/// A metric or label name: `[a-zA-Z0-9_]`, everything else `_`.
88pub fn sanitize(s: &str) -> String {
89    s.chars()
90        .map(|c| {
91            if c.is_ascii_alphanumeric() || c == '_' {
92                c
93            } else {
94                '_'
95            }
96        })
97        .collect()
98}
99
100/// A label value, escaped as the text format requires.
101fn escape_label(v: &str) -> String {
102    let mut out = String::with_capacity(v.len());
103    for c in v.chars() {
104        match c {
105            '\\' => out.push_str("\\\\"),
106            '"' => out.push_str("\\\""),
107            '\n' => out.push_str("\\n"),
108            c => out.push(c),
109        }
110    }
111    out
112}
113
114/// A `# HELP` line's text, escaped.
115fn escape_help(v: &str) -> String {
116    let mut out = String::with_capacity(v.len());
117    for c in v.chars() {
118        match c {
119            '\\' => out.push_str("\\\\"),
120            '\n' => out.push_str("\\n"),
121            c => out.push(c),
122        }
123    }
124    out
125}
126
127/// A sample value in the text format's spelling: `+Inf`, `-Inf`, `NaN`,
128/// and no exponent for anything finite.
129fn number(v: f64) -> String {
130    if v.is_nan() {
131        "NaN".into()
132    } else if v == f64::INFINITY {
133        "+Inf".into()
134    } else if v == f64::NEG_INFINITY {
135        "-Inf".into()
136    } else {
137        format!("{v}")
138    }
139}
140
141/// The fixed label names every subject series carries; a `{var}` whose
142/// declared name collides with one is exposed as `var_<name>`.
143const FIXED_LABELS: [&str; 6] = ["origin", "producer", "class", "subject", "field", "state"];
144
145fn labels_of(row: &SeriesRow) -> Vec<(String, String)> {
146    let mut labels = vec![
147        ("origin".to_string(), row.origin.clone()),
148        ("producer".to_string(), row.producer.clone()),
149        ("class".to_string(), row.class.clone()),
150        ("subject".to_string(), row.subject.clone()),
151    ];
152    for (name, value) in &row.labels {
153        let mut name = sanitize(name);
154        if FIXED_LABELS.contains(&name.as_str()) {
155            name = format!("var_{name}");
156        }
157        labels.push((name, value.clone()));
158    }
159    if let Some(field) = &row.field {
160        labels.push(("field".to_string(), field.clone()));
161    }
162    labels
163}
164
165fn label_set(labels: &[(String, String)]) -> String {
166    let mut out = String::from("{");
167    for (i, (k, v)) in labels.iter().enumerate() {
168        if i > 0 {
169            out.push(',');
170        }
171        let _ = write!(out, "{k}=\"{}\"", escape_label(v));
172    }
173    out.push('}');
174    out
175}
176
177/// One family: `# HELP`, `# TYPE`, then its samples, in the order given.
178struct Family {
179    name: String,
180    help: String,
181    kind: &'static str,
182    lines: Vec<String>,
183}
184
185impl Family {
186    fn new(name: impl Into<String>, help: impl Into<String>, kind: &'static str) -> Family {
187        Family {
188            name: name.into(),
189            help: help.into(),
190            kind,
191            lines: Vec::new(),
192        }
193    }
194
195    fn sample(&mut self, labels: &[(String, String)], value: impl Into<f64>) {
196        let value: f64 = value.into();
197        if labels.is_empty() {
198            self.lines.push(format!("{} {}", self.name, number(value)));
199        } else {
200            self.lines.push(format!(
201                "{}{} {}",
202                self.name,
203                label_set(labels),
204                number(value)
205            ));
206        }
207    }
208
209    fn write(&self, out: &mut String) {
210        let _ = writeln!(out, "# HELP {} {}", self.name, escape_help(&self.help));
211        let _ = writeln!(out, "# TYPE {} {}", self.name, self.kind);
212        for line in &self.lines {
213            out.push_str(line);
214            out.push('\n');
215        }
216    }
217}
218
219fn l(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
220    pairs
221        .iter()
222        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
223        .collect()
224}
225
226/// The whole exposition, `text/plain; version=0.0.4`.
227pub fn exposition(s: &ExportSnapshot) -> String {
228    let mut fams: Vec<Family> = Vec::new();
229
230    // ── the observer's own bounds (O6) — four populations, never summed ──
231    let mut f = Family::new(
232        "zenkey_observer_dropped_total",
233        "samples this observer missed while behind; while this moves every value below is a lower bound (RFC 13 §3 O6)",
234        "counter",
235    );
236    f.sample(&[], s.observer.dropped as f64);
237    fams.push(f);
238
239    let mut f = Family::new(
240        "zenkey_observer_evicted_total",
241        "what the observer chose to forget at its bounds, by population — keys at the stats-table bound, retained samples at the byte budget, retained samples aged out, keys unwatched (never sum these; RFC 13 §3 O6)",
242        "counter",
243    );
244    f.sample(
245        &l(&[("population", "keys")]),
246        s.observer.evicted_keys as f64,
247    );
248    f.sample(
249        &l(&[("population", "retained_bytes")]),
250        s.observer.evicted_bytes as f64,
251    );
252    f.sample(
253        &l(&[("population", "retained_age")]),
254        s.observer.expired as f64,
255    );
256    f.sample(
257        &l(&[("population", "unwatched")]),
258        s.observer.unwatched as f64,
259    );
260    fams.push(f);
261
262    let mut f = Family::new(
263        "zenkey_observer_coalesced_total",
264        "samples folded into a newer one between two scrapes — only the newest value per series is exposed (RFC 13 §3 O6)",
265        "counter",
266    );
267    f.sample(&[], s.observer.coalesced as f64);
268    fams.push(f);
269
270    let mut f = Family::new(
271        "zenkey_observer_unstamped_total",
272        "samples that carried no HLC timestamp — counted, never defaulted to arrival",
273        "counter",
274    );
275    f.sample(&[], s.observer.unstamped as f64);
276    fams.push(f);
277
278    // ── the contract: declared versus observed ──
279    let mut f = Family::new(
280        "zenkey_qos_judged_total",
281        "samples whose subject declares a QoS profile this build knows",
282        "counter",
283    );
284    f.sample(&[], s.contract.qos_judged as f64);
285    fams.push(f);
286
287    let mut f = Family::new(
288        "zenkey_qos_mismatch_total",
289        "samples that did not ride their declared QoS profile, by declared subject (RFC 04 §3)",
290        "counter",
291    );
292    for row in &s.contract.qos_mismatch_by_subject {
293        f.sample(
294            &l(&[("producer", &row.producer), ("subject", &row.subject)]),
295            row.n as f64,
296        );
297    }
298    fams.push(f);
299
300    let mut f = Family::new(
301        "zenkey_payload_verdict_total",
302        "payload verdicts as three counted populations — valid, invalid, not_validated — never a ratio (RFC 13 §3); without --validate everything is not_validated",
303        "counter",
304    );
305    f.sample(&l(&[("verdict", "valid")]), s.contract.payload_valid as f64);
306    f.sample(
307        &l(&[("verdict", "invalid")]),
308        s.contract.payload_invalid as f64,
309    );
310    f.sample(
311        &l(&[("verdict", "not_validated")]),
312        s.contract.payload_not_validated as f64,
313    );
314    fams.push(f);
315
316    // ── the doctor: not asked is a state of its own ──
317    let mut info = Family::new(
318        "zenkey_doctor_info",
319        "whether the doctor was asked to run (--doctor-every): not_asked or ran",
320        "gauge",
321    );
322    let mut findings = Family::new(
323        "zenkey_doctor_finding",
324        "one series per finding of the last doctor run, by check id and severity",
325        "gauge",
326    );
327    let mut last_run = None;
328    match s.doctor.as_option() {
329        None => {
330            info.sample(&l(&[("state", "not_asked")]), 1.0);
331        }
332        Some(d) => {
333            info.sample(&l(&[("state", "ran")]), 1.0);
334            let mut last = Family::new(
335                "zenkey_doctor_last_run_timestamp_seconds",
336                "when the last doctor run finished, unix seconds",
337                "gauge",
338            );
339            last.sample(&[], d.ran_at_unix_s as f64);
340            last_run = Some(last);
341            for f in &d.findings {
342                let sev = serde_json::to_value(f.severity)
343                    .ok()
344                    .and_then(|v| v.as_str().map(str::to_string))
345                    .unwrap_or_default();
346                findings.sample(
347                    &l(&[
348                        ("check_id", f.check.as_str()),
349                        ("severity", &sev),
350                        ("subject", &f.subject),
351                    ]),
352                    1.0,
353                );
354            }
355        }
356    }
357    fams.push(info);
358    fams.extend(last_run);
359    fams.push(findings);
360
361    // ── scope and provenance (O5) ──
362    let mut f = Family::new(
363        "zenkey_scope_info",
364        "one series per selector watched; `excluded` names the planes a wildcard cannot reach (RFC 13 §3 O5, RFC 03 §4 D2)",
365        "gauge",
366    );
367    let excluded = s.excluded.join(",");
368    for scope in &s.scopes {
369        f.sample(&l(&[("selector", scope), ("excluded", &excluded)]), 1.0);
370    }
371    fams.push(f);
372
373    let mut f = Family::new(
374        "zenkey_observer_started_timestamp_seconds",
375        "when this observer started watching, unix seconds — a claim about anything earlier is unobservable",
376        "gauge",
377    );
378    f.sample(&[], s.started_at_unix_s as f64);
379    fams.push(f);
380
381    let mut f = Family::new(
382        "zenkey_registry_info",
383        "the contract every subject series derives from: loaded (with the producer count) or not_loaded, in which case no key refines and every key is unregistered",
384        "gauge",
385    );
386    match s.registry.as_option() {
387        Some(r) => f.sample(
388            &l(&[("state", "loaded"), ("producers", &r.producers.to_string())]),
389            1.0,
390        ),
391        None => f.sample(&l(&[("state", "not_loaded"), ("producers", "")]), 1.0),
392    }
393    fams.push(f);
394
395    let mut f = Family::new(
396        "zenkey_series_suppressed_total",
397        "samples that produced no series, by reason: cardinality (over the declared budget), max_series, fields (per-subject field cap), text, non_numeric, undecodable, unparsed",
398        "counter",
399    );
400    for reason in SUPPRESSION_REASONS {
401        let n = s.suppressed.get(reason).copied().unwrap_or(0);
402        f.sample(&l(&[("reason", reason)]), n as f64);
403    }
404    for (reason, n) in &s.suppressed {
405        if !SUPPRESSION_REASONS.contains(&reason.as_str()) {
406            f.sample(&l(&[("reason", reason)]), *n as f64);
407        }
408    }
409    fams.push(f);
410
411    let mut f = Family::new(
412        "zenkey_unregistered_keys",
413        "distinct keys the registry does not declare — counted, never exported (RFC 13 §3 O4)",
414        "gauge",
415    );
416    f.sample(&[], s.unregistered_keys as f64);
417    fams.push(f);
418
419    let mut f = Family::new(
420        "zenkey_series_count",
421        "series the ledger holds, against its --max-series bound",
422        "gauge",
423    );
424    f.sample(&[], s.series.len() as f64);
425    fams.push(f);
426
427    // ── the subject series, grouped by metric name ──
428    let mut subjects: BTreeMap<&str, Family> = BTreeMap::new();
429    let mut last_seen = Family::new(
430        "zenkey_key_last_seen_timestamp_seconds",
431        "when the newest sample of a series arrived, unix seconds on the observer's clock — constant between scrapes",
432        "gauge",
433    );
434    let mut state = Family::new(
435        "zenkey_series_state",
436        "why a series is or is not current: live, quiet (a state subject past its declared ttl_s), evicted (forgotten at the observer's bound), origin_down (the producer's alive token went), retired (a tombstone) — a stopped series keeps this line and loses its value (RFC 13 §3)",
437        "gauge",
438    );
439    let mut exposed = Family::new(
440        "zenkey_series_drop_exposed_total",
441        "scrape intervals in which this series was fed while zenkey_observer_dropped_total moved — its value may not have been the newest",
442        "counter",
443    );
444    for row in &s.series {
445        let labels = labels_of(row);
446        if row.state.exposes_value()
447            && let Some(v) = row.value
448        {
449            let fam = subjects.entry(row.name.as_str()).or_insert_with(|| {
450                Family::new(
451                    row.name.clone(),
452                    format!(
453                        "registry subject `{}` `{}`{}{} — name and unit from the declaration, not the leaf; may lag while zenkey_observer_dropped_total moves",
454                        row.producer,
455                        row.subject,
456                        row.unit
457                            .as_deref()
458                            .map(|u| format!(", unit {u}"))
459                            .unwrap_or_default(),
460                        row.kind
461                            .as_deref()
462                            .map(|k| format!(", kind {k}"))
463                            .unwrap_or_else(|| ", kind undeclared".into()),
464                    ),
465                    prom_type(row.kind.as_deref()),
466                )
467            });
468            fam.sample(&labels, v);
469        }
470        last_seen.sample(&labels, row.last_seen_unix_s as f64);
471        let mut with_state = labels.clone();
472        with_state.push(("state".to_string(), row.state.as_str().to_string()));
473        state.sample(&with_state, 1.0);
474        exposed.sample(&labels, row.drop_exposed as f64);
475    }
476    for fam in subjects.into_values() {
477        fams.push(fam);
478    }
479    fams.push(last_seen);
480    fams.push(state);
481    fams.push(exposed);
482
483    let mut out = String::new();
484    for fam in &fams {
485        fam.write(&mut out);
486    }
487    out
488}
489
490/// The suppression reasons the ledger spells, so every one is exposed at
491/// zero and a reason a scraper alerts on cannot be absent.
492pub const SUPPRESSION_REASONS: [&str; 7] = [
493    "cardinality",
494    "max_series",
495    "fields",
496    "text",
497    "non_numeric",
498    "undecodable",
499    "unparsed",
500];
501
502/// The states, for a consumer that wants the vocabulary without a snapshot.
503pub const SERIES_STATES: [SeriesState; 5] = [
504    SeriesState::Live,
505    SeriesState::Quiet,
506    SeriesState::Evicted,
507    SeriesState::OriginDown,
508    SeriesState::Retired,
509];
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn names_come_from_the_declaration_and_are_never_doubled() {
517        assert_eq!(
518            metric_name("sysinfo", "cpu/usage", Some("percent"), Some("gauge")),
519            "zenkey_subject_sysinfo_cpu_usage_percent"
520        );
521        assert_eq!(
522            metric_name(
523                "netlink",
524                "iface/{iface}/rx_bytes",
525                Some("bytes"),
526                Some("counter")
527            ),
528            "zenkey_subject_netlink_iface_rx_bytes_total"
529        );
530        assert_eq!(
531            metric_name(
532                "logs",
533                "by_unit/{unit}/messages_total",
534                None,
535                Some("counter")
536            ),
537            "zenkey_subject_logs_by_unit_messages_total"
538        );
539        assert_eq!(
540            metric_name("tc-gui", "latency/p95", Some("ms"), None),
541            "zenkey_subject_tc_gui_latency_p95_milliseconds"
542        );
543        assert_eq!(prom_type(None), "untyped");
544        assert_eq!(prom_type(Some("bool")), "gauge");
545        assert_eq!(prom_type(Some("histogram")), "untyped");
546    }
547
548    #[test]
549    fn label_values_are_escaped_and_numbers_spell_the_specials() {
550        assert_eq!(escape_label("a\"b\\c\nd"), "a\\\"b\\\\c\\nd");
551        assert_eq!(number(f64::INFINITY), "+Inf");
552        assert_eq!(number(f64::NEG_INFINITY), "-Inf");
553        assert_eq!(number(f64::NAN), "NaN");
554        assert_eq!(number(12.5), "12.5");
555        assert_eq!(number(3.0), "3");
556    }
557}