1use std::collections::BTreeMap;
21use std::fmt::Write as _;
22
23use crate::report::{ExportSnapshot, SeriesRow, SeriesState};
24
25pub 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
61pub 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
76pub 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
87pub 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
100fn 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
114fn 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
127fn 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
141const 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
177struct 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
226pub fn exposition(s: &ExportSnapshot) -> String {
228 let mut fams: Vec<Family> = Vec::new();
229
230 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 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 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 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 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
490pub const SUPPRESSION_REASONS: [&str; 7] = [
493 "cardinality",
494 "max_series",
495 "fields",
496 "text",
497 "non_numeric",
498 "undecodable",
499 "unparsed",
500];
501
502pub 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}