mako_engine/metrics.rs
1//! [`EngineMetrics`] — process-level event counters for Prometheus export.
2//!
3//! Provides a **process-global** set of [`std::sync::atomic::AtomicU64`]
4//! counters that the engine and domain handlers increment at runtime. The
5//! [`metrics_api`] handler reads them via [`EngineMetrics::global()`] without
6//! any I/O and renders them in Prometheus text format.
7//!
8//! ## Design rationale
9//!
10//! The mako-engine is a single-process daemon (`makod`). A process-global
11//! static is the simplest, lowest-overhead counter mechanism that:
12//!
13//! - requires **zero allocations** on the hot path (every command dispatch),
14//! - is **async-safe** (atomics need no async context),
15//! - imposes **no external dependency** (no `prometheus` crate in the engine),
16//! - is **observable** from `metrics_api` via a simple method call.
17//!
18//! The trade-off: counters reset on process restart (they are not persisted).
19//! For a single-process daemon this is acceptable — Prometheus's `rate()`
20//! function handles counter resets automatically.
21//!
22//! ## Usage
23//!
24//! ### Incrementing a counter
25//!
26//! ```rust
27//! use mako_engine::metrics::{EngineMetrics, ProcessOutcome};
28//!
29//! // In a workflow handle() or apply() implementation:
30//! EngineMetrics::global().process_initiated("gpke");
31//! EngineMetrics::global().process_completed("gpke", ProcessOutcome::Accepted);
32//! EngineMetrics::global().validation_failed("utilmd", "S2.1");
33//! ```
34//!
35//! ### Reading counters (metrics endpoint)
36//!
37//! ```rust,ignore
38//! let metrics = mako_engine::metrics::EngineMetrics::global();
39//! let snapshot = metrics.snapshot();
40//! // Render snapshot to Prometheus text format.
41//! ```
42//!
43//! [`metrics_api`]: https://docs.rs/makod
44
45use std::{
46 collections::HashMap,
47 sync::{
48 Arc, OnceLock,
49 atomic::{AtomicU64, Ordering},
50 },
51};
52
53// ── ProcessOutcome ────────────────────────────────────────────────────────────
54
55/// Terminal outcome of a MaKo process instance.
56///
57/// Used as the `result` label on [`EngineMetrics::process_completed`].
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum ProcessOutcome {
60 /// The counterparty accepted the request (Bestätigung / positive APERAK).
61 Accepted,
62 /// The counterparty rejected the request (Ablehnung / negative APERAK).
63 Rejected,
64 /// The process timed out before a response arrived (24h / 5 WD / 10 WD).
65 Timeout,
66 /// The process was cancelled by the originating ERP before completion.
67 Cancelled,
68}
69
70impl ProcessOutcome {
71 /// Prometheus label value for this outcome.
72 #[must_use]
73 pub fn label(self) -> &'static str {
74 match self {
75 Self::Accepted => "accepted",
76 Self::Rejected => "rejected",
77 Self::Timeout => "timeout",
78 Self::Cancelled => "cancelled",
79 }
80 }
81
82 /// All variants in a fixed order, for metric exposition.
83 pub const ALL: &'static [Self] = &[
84 Self::Accepted,
85 Self::Rejected,
86 Self::Timeout,
87 Self::Cancelled,
88 ];
89}
90
91// ── MetricVec ─────────────────────────────────────────────────────────────────
92
93/// A map of label strings → `AtomicU64` counters.
94///
95/// `MetricVec` is append-only: new label combinations are registered on first
96/// increment and are never removed (counters remain at 0 once created).
97#[derive(Default)]
98struct MetricVec {
99 inner: std::sync::RwLock<HashMap<Box<str>, Arc<AtomicU64>>>,
100}
101
102impl MetricVec {
103 fn increment(&self, label: &str) {
104 // Fast path: label already registered — just increment.
105 {
106 let guard = self.inner.read().expect("MetricVec RwLock poisoned");
107 if let Some(counter) = guard.get(label) {
108 counter.fetch_add(1, Ordering::Relaxed);
109 return;
110 }
111 }
112 // Slow path: first increment for this label — register + increment.
113 let mut guard = self.inner.write().expect("MetricVec RwLock poisoned");
114 let counter = guard
115 .entry(label.into())
116 .or_insert_with(|| Arc::new(AtomicU64::new(0)));
117 counter.fetch_add(1, Ordering::Relaxed);
118 }
119
120 /// Snapshot all label → value pairs, sorted by label for deterministic output.
121 fn snapshot(&self) -> Vec<(Box<str>, u64)> {
122 let guard = self.inner.read().expect("MetricVec RwLock poisoned");
123 let mut pairs: Vec<(Box<str>, u64)> = guard
124 .iter()
125 .map(|(k, v)| (k.clone(), v.load(Ordering::Relaxed)))
126 .collect();
127 pairs.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
128 pairs
129 }
130}
131
132// ── EngineMetrics ─────────────────────────────────────────────────────────────
133
134/// Process-global engine metrics counters.
135///
136/// Access via [`EngineMetrics::global()`]. The global instance is initialised
137/// once on first access using [`OnceLock`] and lives for the process lifetime.
138///
139/// ## Counter naming (maps 1:1 to Prometheus metric names)
140///
141/// | Method | Prometheus metric | Labels |
142/// |---|---|---|
143/// | [`process_initiated`] | `makod_process_initiated_total` | `family` |
144/// | [`process_completed`] | `makod_process_completed_total` | `family`, `result` |
145/// | [`validation_failed`] | `makod_validation_failed_total` | `message_type`, `release` |
146/// | [`outbox_delivery_attempted`] | `makod_outbox_delivery_attempts_total` | `result` |
147/// | [`deadline_fired`] | `makod_deadline_fired_total` | `family` |
148/// | [`dead_letter_recorded`] | `makod_dead_letter_recorded_total` | `reason` |
149/// | [`aperak_missed`] | `makod_aperak_missed_total` | `label` |
150///
151/// For `makod_dead_letter_recorded_total`, the `reason` label is:
152/// - `unknown_pid:<N>` when `DeadLetterReason::UnknownPid { pid: N, .. }` — one label per
153/// distinct PID, enabling per-PID alerting
154/// - a short category string (`unknown_conversation`, `version_mismatch`, etc.)
155/// for all other reason variants
156///
157/// [`process_initiated`]: EngineMetrics::process_initiated
158/// [`process_completed`]: EngineMetrics::process_completed
159/// [`validation_failed`]: EngineMetrics::validation_failed
160/// [`outbox_delivery_attempted`]: EngineMetrics::outbox_delivery_attempted
161/// [`deadline_fired`]: EngineMetrics::deadline_fired
162/// [`dead_letter_recorded`]: EngineMetrics::dead_letter_recorded
163/// [`aperak_missed`]: EngineMetrics::aperak_missed
164pub struct EngineMetrics {
165 /// `makod_process_initiated_total{family}` — incremented when a new
166 /// process is spawned via `Process::execute(InitiateXxx)`.
167 process_initiated: MetricVec,
168
169 /// `makod_process_completed_total{family,result}` — incremented when a
170 /// process reaches a terminal state.
171 process_completed: MetricVec,
172
173 /// `makod_validation_failed_total{message_type,release}` — incremented
174 /// when an inbound EDIFACT message fails AHB validation.
175 validation_failed: MetricVec,
176
177 /// `makod_outbox_delivery_attempts_total{result}` — incremented by the
178 /// AS4 sender on every delivery attempt.
179 outbox_delivery_attempts: MetricVec,
180
181 /// `makod_deadline_fired_total{family}` — incremented when a deadline
182 /// scheduler fires a `TimeoutExpired` command.
183 deadline_fired: MetricVec,
184
185 /// `makod_dead_letter_recorded_total{reason}` — incremented when a message
186 /// is sent to the dead-letter sink.
187 dead_letter_recorded: MetricVec,
188
189 /// `makod_inbound_messages_total{pid,result}` — incremented for every
190 /// inbound EDIFACT message that enters the dispatch pipeline.
191 ///
192 /// - `pid`: the 5-digit EDIFACT Prüfidentifikator (e.g. `"55001"`)
193 /// - `result`: `"dispatched"`, `"skipped"`, or `"error"`
194 inbound_received: MetricVec,
195
196 /// `makod_aperak_missed_total{label}` — incremented when an APERAK delivery
197 /// window comes due while still registered.
198 ///
199 /// The outbox worker discharges the window as soon as the APERAK is
200 /// delivered, so a window that survives to its due time was never answered.
201 /// A non-zero value is a regulatory violation under APERAK AHB 1.0 §2.4.1
202 /// (Strom) / §2.3.1 (Gas). Alert on `makod_aperak_missed_total > 0`.
203 ///
204 /// The discharge is what gives this counter meaning. Firing *after* the due
205 /// time is not evidence of anything on its own — the scheduler selects
206 /// deadlines on `due_at <= now`, so every deadline it hands out is late by
207 /// construction.
208 aperak_missed: MetricVec,
209}
210
211impl EngineMetrics {
212 fn new() -> Self {
213 Self {
214 process_initiated: MetricVec::default(),
215 process_completed: MetricVec::default(),
216 validation_failed: MetricVec::default(),
217 outbox_delivery_attempts: MetricVec::default(),
218 deadline_fired: MetricVec::default(),
219 dead_letter_recorded: MetricVec::default(),
220 inbound_received: MetricVec::default(),
221 aperak_missed: MetricVec::default(),
222 }
223 }
224
225 /// Return the process-global [`EngineMetrics`] instance.
226 ///
227 /// The instance is initialised lazily on first call. Subsequent calls
228 /// return the same instance with zero allocation.
229 #[must_use]
230 pub fn global() -> &'static Self {
231 static GLOBAL: OnceLock<EngineMetrics> = OnceLock::new();
232 GLOBAL.get_or_init(Self::new)
233 }
234
235 // ── Increment methods ─────────────────────────────────────────────────────
236
237 /// Increment `makod_process_initiated_total{family=<family>}`.
238 ///
239 /// Call once when a domain workflow receives its first initiating command
240 /// (e.g. `LfAnmeldungCommand::InitiateAnmeldung`).
241 ///
242 /// `family` is the [`EngineModule::name`] value (`"gpke"`, `"wim"`, etc.).
243 ///
244 /// [`EngineModule::name`]: crate::builder::EngineModule::name
245 pub fn process_initiated(&self, family: &str) {
246 self.process_initiated.increment(family);
247 }
248
249 /// Increment `makod_process_completed_total{family=<family>,result=<result>}`.
250 ///
251 /// Call once when a workflow transitions to a **terminal state**
252 /// (`Active`, `Rejected`, timeout, or cancellation).
253 pub fn process_completed(&self, family: &str, outcome: ProcessOutcome) {
254 let label = format!("{family},{}", outcome.label());
255 self.process_completed.increment(&label);
256 }
257
258 /// Increment `makod_validation_failed_total{message_type=<type>,release=<rel>}`.
259 ///
260 /// Call once per inbound message that has a registered profile and violates
261 /// it. Two things this must **not** count, because both once did and both
262 /// made the metric meaningless: a message that failed to *parse* — it has
263 /// neither a message type nor a release, so it was counted under the fixed
264 /// labels `("edifact", "parse_error")` — and a message whose release has no
265 /// registered profile, which is "there was no rule to break" rather than a
266 /// violation, and is the normal shape for an answer PID.
267 pub fn validation_failed(&self, message_type: &str, release: &str) {
268 let label = format!("{message_type},{release}");
269 self.validation_failed.increment(&label);
270 }
271
272 /// Increment `makod_outbox_delivery_attempts_total{result=<result>}`.
273 ///
274 /// Call in the AS4 sender after every delivery attempt.
275 /// `result` should be one of `"ok"`, `"transport_error"`, `"partner_unknown"`.
276 pub fn outbox_delivery_attempted(&self, result: &str) {
277 self.outbox_delivery_attempts.increment(result);
278 }
279
280 /// Increment `makod_deadline_fired_total{family=<family>}`.
281 ///
282 /// Call in the deadline scheduler when it dispatches a `TimeoutExpired`.
283 pub fn deadline_fired(&self, family: &str) {
284 self.deadline_fired.increment(family);
285 }
286
287 /// Increment `makod_dead_letter_recorded_total{reason=<reason>}`.
288 ///
289 /// Call in the dead-letter sink when `reject()` is invoked.
290 /// `reason` should match [`DeadLetterReason`]'s label string.
291 ///
292 /// [`DeadLetterReason`]: crate::dead_letter::DeadLetterReason
293 pub fn dead_letter_recorded(&self, reason: &str) {
294 self.dead_letter_recorded.increment(reason);
295 }
296
297 /// Increment `makod_aperak_missed_total{label=<label>}`.
298 ///
299 /// Call in the deadline scheduler when an APERAK delivery window comes due
300 /// **while still registered** — the outbox worker discharges it on delivery,
301 /// so surviving to the due time is what marks the obligation unmet.
302 /// `label` should be the APERAK deadline label constant from `fristen::`
303 /// (e.g. `APERAK_STROM_WINDOW_LABEL`, `APERAK_GAS_FOLGEPROZESS_LABEL`).
304 pub fn aperak_missed(&self, label: &str) {
305 self.aperak_missed.increment(label);
306 }
307
308 /// Increment `makod_inbound_messages_total{pid=<pid>,result=<result>}`.
309 ///
310 /// Call once per inbound EDIFACT message after the dispatch pipeline
311 /// completes (whether it succeeded or failed).
312 ///
313 /// - `pid` — the EDIFACT Prüfidentifikator (e.g. `55001`)
314 /// - `result` — `"dispatched"`, `"skipped"`, or `"error"`
315 pub fn inbound_received(&self, pid: u32, result: &str) {
316 let label = format!("{pid},{result}");
317 self.inbound_received.increment(&label);
318 }
319
320 // ── Snapshot ──────────────────────────────────────────────────────────────
321
322 /// Return a snapshot of all counters as a [`MetricsSnapshot`].
323 ///
324 /// This is a **read-only** operation that does not reset any counters.
325 /// Counters are monotonically increasing; Prometheus's `rate()` handles
326 /// counter resets on process restart automatically.
327 #[must_use]
328 pub fn snapshot(&self) -> MetricsSnapshot {
329 MetricsSnapshot {
330 process_initiated: self.process_initiated.snapshot(),
331 process_completed: self.process_completed.snapshot(),
332 validation_failed: self.validation_failed.snapshot(),
333 outbox_delivery_attempts: self.outbox_delivery_attempts.snapshot(),
334 deadline_fired: self.deadline_fired.snapshot(),
335 dead_letter_recorded: self.dead_letter_recorded.snapshot(),
336 inbound_received: self.inbound_received.snapshot(),
337 aperak_missed: self.aperak_missed.snapshot(),
338 }
339 }
340}
341
342// ── MetricsSnapshot ───────────────────────────────────────────────────────────
343
344/// A point-in-time snapshot of all [`EngineMetrics`] counters.
345///
346/// Obtained via [`EngineMetrics::snapshot()`]. All fields are `Vec` of
347/// `(label, count)` pairs sorted by label for deterministic Prometheus output.
348///
349/// The `label` field uses a `","` separator for multi-label metrics
350/// (e.g. `"gpke,accepted"` for `{family="gpke",result="accepted"}`).
351/// The [`render_prometheus`] function splits them appropriately.
352///
353/// [`render_prometheus`]: MetricsSnapshot::render_prometheus
354#[derive(Debug, Clone)]
355pub struct MetricsSnapshot {
356 /// `(family, count)` pairs for `makod_process_initiated_total`.
357 pub process_initiated: Vec<(Box<str>, u64)>,
358 /// `("family,result", count)` pairs for `makod_process_completed_total`.
359 pub process_completed: Vec<(Box<str>, u64)>,
360 /// `("message_type,release", count)` pairs for `makod_validation_failed_total`.
361 pub validation_failed: Vec<(Box<str>, u64)>,
362 /// `(result, count)` pairs for `makod_outbox_delivery_attempts_total`.
363 pub outbox_delivery_attempts: Vec<(Box<str>, u64)>,
364 /// `(family, count)` pairs for `makod_deadline_fired_total`.
365 pub deadline_fired: Vec<(Box<str>, u64)>,
366 /// `(reason, count)` pairs for `makod_dead_letter_recorded_total`.
367 pub dead_letter_recorded: Vec<(Box<str>, u64)>,
368 /// `("pid,result", count)` pairs for `makod_inbound_messages_total`.
369 pub inbound_received: Vec<(Box<str>, u64)>,
370 /// `(label, count)` pairs for `makod_aperak_missed_total`.
371 pub aperak_missed: Vec<(Box<str>, u64)>,
372}
373
374impl MetricsSnapshot {
375 /// Render this snapshot to Prometheus text exposition format (v0.0.4).
376 ///
377 /// The output follows the format:
378 /// ```text
379 /// # HELP <metric_name> <description>
380 /// # TYPE <metric_name> counter
381 /// <metric_name>{<labels>} <value>
382 /// ```
383 ///
384 /// Multi-label metrics use a `","` separator in the internal label string,
385 /// which is split into separate `key="value"` pairs in the output.
386 #[must_use]
387 pub fn render_prometheus(&self) -> String {
388 let mut out = String::with_capacity(4096);
389
390 Self::write_counter_vec(
391 &mut out,
392 "makod_process_initiated_total",
393 "Total number of MaKo process instances initiated, by process family.",
394 &["family"],
395 &self.process_initiated,
396 );
397 Self::write_counter_vec(
398 &mut out,
399 "makod_process_completed_total",
400 "Total number of MaKo process instances that reached a terminal state.",
401 &["family", "result"],
402 &self.process_completed,
403 );
404 Self::write_counter_vec(
405 &mut out,
406 "makod_validation_failed_total",
407 "Total number of inbound EDIFACT messages that failed AHB validation.",
408 &["message_type", "release"],
409 &self.validation_failed,
410 );
411 Self::write_counter_vec(
412 &mut out,
413 "makod_outbox_delivery_attempts_total",
414 "Total number of AS4 outbox delivery attempts.",
415 &["result"],
416 &self.outbox_delivery_attempts,
417 );
418 Self::write_counter_vec(
419 &mut out,
420 "makod_deadline_fired_total",
421 "Total number of regulatory deadlines fired (TimeoutExpired dispatched).",
422 &["family"],
423 &self.deadline_fired,
424 );
425 Self::write_counter_vec(
426 &mut out,
427 "makod_dead_letter_recorded_total",
428 "Total number of messages sent to the durable dead-letter sink.",
429 &["reason"],
430 &self.dead_letter_recorded,
431 );
432 Self::write_counter_vec(
433 &mut out,
434 "makod_inbound_messages_total",
435 "Total number of inbound EDIFACT messages that entered the dispatch pipeline, \
436 by PID and outcome.",
437 &["pid", "result"],
438 &self.inbound_received,
439 );
440 Self::write_counter_vec(
441 &mut out,
442 "makod_aperak_missed_total",
443 "Total number of APERAK deadlines fired after their due-at time — a regulatory \
444 violation under APERAK AHB 1.0 §2.4.1 (Strom) / §2.3.1 (Gas). \
445 Alert when this counter is non-zero.",
446 &["label"],
447 &self.aperak_missed,
448 );
449
450 out
451 }
452
453 /// Write a `counter` metric family to `out`.
454 ///
455 /// `label_names` specifies the label key names in order. Each entry in
456 /// `pairs` has a label value that is either a bare string (single-label
457 /// metrics) or a `","` separated string (multi-label metrics, split in
458 /// order of `label_names`).
459 fn write_counter_vec(
460 out: &mut String,
461 name: &str,
462 help: &str,
463 label_names: &[&str],
464 pairs: &[(Box<str>, u64)],
465 ) {
466 if pairs.is_empty() {
467 return;
468 }
469 out.push_str("# HELP ");
470 out.push_str(name);
471 out.push(' ');
472 out.push_str(help);
473 out.push('\n');
474 out.push_str("# TYPE ");
475 out.push_str(name);
476 out.push_str(" counter\n");
477
478 for (label_str, count) in pairs {
479 let values: Vec<&str> = label_str.splitn(label_names.len(), ',').collect();
480 out.push_str(name);
481 out.push('{');
482 for (i, (key, val)) in label_names.iter().zip(values.iter()).enumerate() {
483 if i > 0 {
484 out.push(',');
485 }
486 out.push_str(key);
487 out.push_str("=\"");
488 // Escape backslash, double-quote, and newline per Prometheus spec.
489 for ch in val.chars() {
490 match ch {
491 '\\' => out.push_str(r"\\"),
492 '"' => out.push_str(r#"\""#),
493 '\n' => out.push_str(r"\n"),
494 _ => out.push(ch),
495 }
496 }
497 out.push('"');
498 }
499 out.push_str("} ");
500 let _ = std::fmt::Write::write_fmt(out, format_args!("{count}"));
501 out.push('\n');
502 }
503 }
504}
505
506// ── Tests ─────────────────────────────────────────────────────────────────────
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 fn fresh_metrics() -> EngineMetrics {
513 EngineMetrics::new()
514 }
515
516 #[test]
517 fn process_initiated_increments_by_family() {
518 let m = fresh_metrics();
519 m.process_initiated("gpke");
520 m.process_initiated("gpke");
521 m.process_initiated("wim");
522
523 let snap = m.snapshot();
524 assert_eq!(snap.process_initiated.len(), 2);
525
526 let gpke = snap
527 .process_initiated
528 .iter()
529 .find(|(k, _)| k.as_ref() == "gpke");
530 assert_eq!(gpke.map(|(_, v)| *v), Some(2));
531
532 let wim = snap
533 .process_initiated
534 .iter()
535 .find(|(k, _)| k.as_ref() == "wim");
536 assert_eq!(wim.map(|(_, v)| *v), Some(1));
537 }
538
539 #[test]
540 fn process_completed_uses_composite_label() {
541 let m = fresh_metrics();
542 m.process_completed("gpke", ProcessOutcome::Accepted);
543 m.process_completed("gpke", ProcessOutcome::Rejected);
544 m.process_completed("gpke", ProcessOutcome::Accepted);
545 m.process_completed("wim", ProcessOutcome::Timeout);
546
547 let snap = m.snapshot();
548 let accepted = snap
549 .process_completed
550 .iter()
551 .find(|(k, _)| k.as_ref() == "gpke,accepted");
552 assert_eq!(accepted.map(|(_, v)| *v), Some(2));
553
554 let timeout = snap
555 .process_completed
556 .iter()
557 .find(|(k, _)| k.as_ref() == "wim,timeout");
558 assert_eq!(timeout.map(|(_, v)| *v), Some(1));
559 }
560
561 #[test]
562 fn snapshot_returns_zero_for_unincremented_metric() {
563 let m = fresh_metrics();
564 // No increments — snapshot should be empty.
565 let snap = m.snapshot();
566 assert!(snap.process_initiated.is_empty());
567 assert!(snap.process_completed.is_empty());
568 }
569
570 #[test]
571 fn render_prometheus_omits_empty_metric_families() {
572 let m = fresh_metrics();
573 m.process_initiated("gpke");
574
575 let output = m.snapshot().render_prometheus();
576
577 // Only the incremented family should appear.
578 assert!(
579 output.contains("makod_process_initiated_total"),
580 "initiated must appear"
581 );
582 assert!(
583 !output.contains("makod_process_completed_total"),
584 "completed must be absent"
585 );
586 assert!(
587 !output.contains("makod_validation_failed_total"),
588 "validation must be absent"
589 );
590 }
591
592 #[test]
593 fn render_prometheus_formats_labels_correctly() {
594 let m = fresh_metrics();
595 m.process_initiated("gpke");
596 m.process_completed("gpke", ProcessOutcome::Accepted);
597 m.validation_failed("utilmd", "S2.1");
598
599 let output = m.snapshot().render_prometheus();
600
601 assert!(
602 output.contains(r#"makod_process_initiated_total{family="gpke"} 1"#),
603 "single-label format must match; output:\n{output}"
604 );
605 assert!(
606 output.contains(r#"makod_process_completed_total{family="gpke",result="accepted"} 1"#),
607 "two-label format must match; output:\n{output}"
608 );
609 assert!(
610 output.contains(
611 r#"makod_validation_failed_total{message_type="utilmd",release="S2.1"} 1"#
612 ),
613 "message_type+release format must match; output:\n{output}"
614 );
615 }
616
617 #[test]
618 fn render_prometheus_escapes_special_chars_in_label_values() {
619 let m = fresh_metrics();
620 // Inject a label value with a backslash and a double-quote.
621 m.outbox_delivery_attempted("ok");
622 m.dead_letter_recorded("unknown_pid:13002");
623
624 let output = m.snapshot().render_prometheus();
625 assert!(
626 output.contains(r#"result="ok""#),
627 "plain label must survive; output:\n{output}"
628 );
629 assert!(
630 output.contains(r#"reason="unknown_pid:13002""#),
631 "reason label must survive; output:\n{output}"
632 );
633 }
634
635 #[test]
636 fn counters_are_monotonically_increasing() {
637 let m = fresh_metrics();
638 for _ in 0..100 {
639 m.deadline_fired("gpke");
640 }
641 let snap = m.snapshot();
642 let gpke = snap
643 .deadline_fired
644 .iter()
645 .find(|(k, _)| k.as_ref() == "gpke");
646 assert_eq!(gpke.map(|(_, v)| *v), Some(100));
647 }
648
649 #[test]
650 fn snapshot_sorted_by_label() {
651 let m = fresh_metrics();
652 // Insert in reverse order to verify sort.
653 m.process_initiated("wim");
654 m.process_initiated("mabis");
655 m.process_initiated("geli-gas");
656 m.process_initiated("gpke");
657
658 let snap = m.snapshot();
659 let labels: Vec<&str> = snap
660 .process_initiated
661 .iter()
662 .map(|(k, _)| k.as_ref())
663 .collect();
664 let mut sorted = labels.clone();
665 sorted.sort_unstable();
666 assert_eq!(labels, sorted, "snapshot must be sorted by label");
667 }
668}