Skip to main content

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 either makes the metric
262    /// meaningless: a message that failed to *parse* — it has neither a message
263    /// type nor a release to label — and a message whose release has no
264    /// registered profile, which is "there was no rule to break" rather than a
265    /// violation, and is the normal shape for an answer PID.
266    pub fn validation_failed(&self, message_type: &str, release: &str) {
267        let label = format!("{message_type},{release}");
268        self.validation_failed.increment(&label);
269    }
270
271    /// Increment `makod_outbox_delivery_attempts_total{result=<result>}`.
272    ///
273    /// Call in the AS4 sender after every delivery attempt.
274    /// `result` should be one of `"ok"`, `"transport_error"`, `"partner_unknown"`.
275    pub fn outbox_delivery_attempted(&self, result: &str) {
276        self.outbox_delivery_attempts.increment(result);
277    }
278
279    /// Increment `makod_deadline_fired_total{family=<family>}`.
280    ///
281    /// Call in the deadline scheduler when it dispatches a `TimeoutExpired`.
282    pub fn deadline_fired(&self, family: &str) {
283        self.deadline_fired.increment(family);
284    }
285
286    /// Increment `makod_dead_letter_recorded_total{reason=<reason>}`.
287    ///
288    /// Call in the dead-letter sink when `reject()` is invoked.
289    /// `reason` should match [`DeadLetterReason`]'s label string.
290    ///
291    /// [`DeadLetterReason`]: crate::dead_letter::DeadLetterReason
292    pub fn dead_letter_recorded(&self, reason: &str) {
293        self.dead_letter_recorded.increment(reason);
294    }
295
296    /// Increment `makod_aperak_missed_total{label=<label>}`.
297    ///
298    /// Call in the deadline scheduler when an APERAK delivery window comes due
299    /// **while still registered** — the outbox worker discharges it on delivery,
300    /// so surviving to the due time is what marks the obligation unmet.
301    /// `label` should be the APERAK deadline label constant from `fristen::`
302    /// (e.g. `APERAK_STROM_WINDOW_LABEL`, `APERAK_GAS_FOLGEPROZESS_LABEL`).
303    pub fn aperak_missed(&self, label: &str) {
304        self.aperak_missed.increment(label);
305    }
306
307    /// Increment `makod_inbound_messages_total{pid=<pid>,result=<result>}`.
308    ///
309    /// Call once per inbound EDIFACT message after the dispatch pipeline
310    /// completes (whether it succeeded or failed).
311    ///
312    /// - `pid` — the EDIFACT Prüfidentifikator (e.g. `55001`)
313    /// - `result` — `"dispatched"`, `"skipped"`, or `"error"`
314    pub fn inbound_received(&self, pid: u32, result: &str) {
315        let label = format!("{pid},{result}");
316        self.inbound_received.increment(&label);
317    }
318
319    // ── Snapshot ──────────────────────────────────────────────────────────────
320
321    /// Return a snapshot of all counters as a [`MetricsSnapshot`].
322    ///
323    /// This is a **read-only** operation that does not reset any counters.
324    /// Counters are monotonically increasing; Prometheus's `rate()` handles
325    /// counter resets on process restart automatically.
326    #[must_use]
327    pub fn snapshot(&self) -> MetricsSnapshot {
328        MetricsSnapshot {
329            process_initiated: self.process_initiated.snapshot(),
330            process_completed: self.process_completed.snapshot(),
331            validation_failed: self.validation_failed.snapshot(),
332            outbox_delivery_attempts: self.outbox_delivery_attempts.snapshot(),
333            deadline_fired: self.deadline_fired.snapshot(),
334            dead_letter_recorded: self.dead_letter_recorded.snapshot(),
335            inbound_received: self.inbound_received.snapshot(),
336            aperak_missed: self.aperak_missed.snapshot(),
337        }
338    }
339}
340
341// ── MetricsSnapshot ───────────────────────────────────────────────────────────
342
343/// A point-in-time snapshot of all [`EngineMetrics`] counters.
344///
345/// Obtained via [`EngineMetrics::snapshot()`]. All fields are `Vec` of
346/// `(label, count)` pairs sorted by label for deterministic Prometheus output.
347///
348/// The `label` field uses a `","` separator for multi-label metrics
349/// (e.g. `"gpke,accepted"` for `{family="gpke",result="accepted"}`).
350/// The [`render_prometheus`] function splits them appropriately.
351///
352/// [`render_prometheus`]: MetricsSnapshot::render_prometheus
353#[derive(Debug, Clone)]
354pub struct MetricsSnapshot {
355    /// `(family, count)` pairs for `makod_process_initiated_total`.
356    pub process_initiated: Vec<(Box<str>, u64)>,
357    /// `("family,result", count)` pairs for `makod_process_completed_total`.
358    pub process_completed: Vec<(Box<str>, u64)>,
359    /// `("message_type,release", count)` pairs for `makod_validation_failed_total`.
360    pub validation_failed: Vec<(Box<str>, u64)>,
361    /// `(result, count)` pairs for `makod_outbox_delivery_attempts_total`.
362    pub outbox_delivery_attempts: Vec<(Box<str>, u64)>,
363    /// `(family, count)` pairs for `makod_deadline_fired_total`.
364    pub deadline_fired: Vec<(Box<str>, u64)>,
365    /// `(reason, count)` pairs for `makod_dead_letter_recorded_total`.
366    pub dead_letter_recorded: Vec<(Box<str>, u64)>,
367    /// `("pid,result", count)` pairs for `makod_inbound_messages_total`.
368    pub inbound_received: Vec<(Box<str>, u64)>,
369    /// `(label, count)` pairs for `makod_aperak_missed_total`.
370    pub aperak_missed: Vec<(Box<str>, u64)>,
371}
372
373impl MetricsSnapshot {
374    /// Render this snapshot to Prometheus text exposition format (v0.0.4).
375    ///
376    /// The output follows the format:
377    /// ```text
378    /// # HELP <metric_name> <description>
379    /// # TYPE <metric_name> counter
380    /// <metric_name>{<labels>} <value>
381    /// ```
382    ///
383    /// Multi-label metrics use a `","` separator in the internal label string,
384    /// which is split into separate `key="value"` pairs in the output.
385    #[must_use]
386    pub fn render_prometheus(&self) -> String {
387        let mut out = String::with_capacity(4096);
388
389        Self::write_counter_vec(
390            &mut out,
391            "makod_process_initiated_total",
392            "Total number of MaKo process instances initiated, by process family.",
393            &["family"],
394            &self.process_initiated,
395        );
396        Self::write_counter_vec(
397            &mut out,
398            "makod_process_completed_total",
399            "Total number of MaKo process instances that reached a terminal state.",
400            &["family", "result"],
401            &self.process_completed,
402        );
403        Self::write_counter_vec(
404            &mut out,
405            "makod_validation_failed_total",
406            "Total number of inbound EDIFACT messages that failed AHB validation.",
407            &["message_type", "release"],
408            &self.validation_failed,
409        );
410        Self::write_counter_vec(
411            &mut out,
412            "makod_outbox_delivery_attempts_total",
413            "Total number of AS4 outbox delivery attempts.",
414            &["result"],
415            &self.outbox_delivery_attempts,
416        );
417        Self::write_counter_vec(
418            &mut out,
419            "makod_deadline_fired_total",
420            "Total number of regulatory deadlines fired (TimeoutExpired dispatched).",
421            &["family"],
422            &self.deadline_fired,
423        );
424        Self::write_counter_vec(
425            &mut out,
426            "makod_dead_letter_recorded_total",
427            "Total number of messages sent to the durable dead-letter sink.",
428            &["reason"],
429            &self.dead_letter_recorded,
430        );
431        Self::write_counter_vec(
432            &mut out,
433            "makod_inbound_messages_total",
434            "Total number of inbound EDIFACT messages that entered the dispatch pipeline, \
435             by PID and outcome.",
436            &["pid", "result"],
437            &self.inbound_received,
438        );
439        Self::write_counter_vec(
440            &mut out,
441            "makod_aperak_missed_total",
442            "Total number of APERAK deadlines fired after their due-at time — a regulatory \
443             violation under APERAK AHB 1.0 §2.4.1 (Strom) / §2.3.1 (Gas). \
444             Alert when this counter is non-zero.",
445            &["label"],
446            &self.aperak_missed,
447        );
448
449        out
450    }
451
452    /// Write a `counter` metric family to `out`.
453    ///
454    /// `label_names` specifies the label key names in order.  Each entry in
455    /// `pairs` has a label value that is either a bare string (single-label
456    /// metrics) or a `","` separated string (multi-label metrics, split in
457    /// order of `label_names`).
458    fn write_counter_vec(
459        out: &mut String,
460        name: &str,
461        help: &str,
462        label_names: &[&str],
463        pairs: &[(Box<str>, u64)],
464    ) {
465        if pairs.is_empty() {
466            return;
467        }
468        out.push_str("# HELP ");
469        out.push_str(name);
470        out.push(' ');
471        out.push_str(help);
472        out.push('\n');
473        out.push_str("# TYPE ");
474        out.push_str(name);
475        out.push_str(" counter\n");
476
477        for (label_str, count) in pairs {
478            let values: Vec<&str> = label_str.splitn(label_names.len(), ',').collect();
479            out.push_str(name);
480            out.push('{');
481            for (i, (key, val)) in label_names.iter().zip(values.iter()).enumerate() {
482                if i > 0 {
483                    out.push(',');
484                }
485                out.push_str(key);
486                out.push_str("=\"");
487                // Escape backslash, double-quote, and newline per Prometheus spec.
488                for ch in val.chars() {
489                    match ch {
490                        '\\' => out.push_str(r"\\"),
491                        '"' => out.push_str(r#"\""#),
492                        '\n' => out.push_str(r"\n"),
493                        _ => out.push(ch),
494                    }
495                }
496                out.push('"');
497            }
498            out.push_str("} ");
499            let _ = std::fmt::Write::write_fmt(out, format_args!("{count}"));
500            out.push('\n');
501        }
502    }
503}
504
505// ── Tests ─────────────────────────────────────────────────────────────────────
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn fresh_metrics() -> EngineMetrics {
512        EngineMetrics::new()
513    }
514
515    #[test]
516    fn process_initiated_increments_by_family() {
517        let m = fresh_metrics();
518        m.process_initiated("gpke");
519        m.process_initiated("gpke");
520        m.process_initiated("wim");
521
522        let snap = m.snapshot();
523        assert_eq!(snap.process_initiated.len(), 2);
524
525        let gpke = snap
526            .process_initiated
527            .iter()
528            .find(|(k, _)| k.as_ref() == "gpke");
529        assert_eq!(gpke.map(|(_, v)| *v), Some(2));
530
531        let wim = snap
532            .process_initiated
533            .iter()
534            .find(|(k, _)| k.as_ref() == "wim");
535        assert_eq!(wim.map(|(_, v)| *v), Some(1));
536    }
537
538    #[test]
539    fn process_completed_uses_composite_label() {
540        let m = fresh_metrics();
541        m.process_completed("gpke", ProcessOutcome::Accepted);
542        m.process_completed("gpke", ProcessOutcome::Rejected);
543        m.process_completed("gpke", ProcessOutcome::Accepted);
544        m.process_completed("wim", ProcessOutcome::Timeout);
545
546        let snap = m.snapshot();
547        let accepted = snap
548            .process_completed
549            .iter()
550            .find(|(k, _)| k.as_ref() == "gpke,accepted");
551        assert_eq!(accepted.map(|(_, v)| *v), Some(2));
552
553        let timeout = snap
554            .process_completed
555            .iter()
556            .find(|(k, _)| k.as_ref() == "wim,timeout");
557        assert_eq!(timeout.map(|(_, v)| *v), Some(1));
558    }
559
560    #[test]
561    fn snapshot_returns_zero_for_unincremented_metric() {
562        let m = fresh_metrics();
563        // No increments — snapshot should be empty.
564        let snap = m.snapshot();
565        assert!(snap.process_initiated.is_empty());
566        assert!(snap.process_completed.is_empty());
567    }
568
569    #[test]
570    fn render_prometheus_omits_empty_metric_families() {
571        let m = fresh_metrics();
572        m.process_initiated("gpke");
573
574        let output = m.snapshot().render_prometheus();
575
576        // Only the incremented family should appear.
577        assert!(
578            output.contains("makod_process_initiated_total"),
579            "initiated must appear"
580        );
581        assert!(
582            !output.contains("makod_process_completed_total"),
583            "completed must be absent"
584        );
585        assert!(
586            !output.contains("makod_validation_failed_total"),
587            "validation must be absent"
588        );
589    }
590
591    #[test]
592    fn render_prometheus_formats_labels_correctly() {
593        let m = fresh_metrics();
594        m.process_initiated("gpke");
595        m.process_completed("gpke", ProcessOutcome::Accepted);
596        m.validation_failed("utilmd", "S2.1");
597
598        let output = m.snapshot().render_prometheus();
599
600        assert!(
601            output.contains(r#"makod_process_initiated_total{family="gpke"} 1"#),
602            "single-label format must match; output:\n{output}"
603        );
604        assert!(
605            output.contains(r#"makod_process_completed_total{family="gpke",result="accepted"} 1"#),
606            "two-label format must match; output:\n{output}"
607        );
608        assert!(
609            output.contains(
610                r#"makod_validation_failed_total{message_type="utilmd",release="S2.1"} 1"#
611            ),
612            "message_type+release format must match; output:\n{output}"
613        );
614    }
615
616    #[test]
617    fn render_prometheus_escapes_special_chars_in_label_values() {
618        let m = fresh_metrics();
619        // Inject a label value with a backslash and a double-quote.
620        m.outbox_delivery_attempted("ok");
621        m.dead_letter_recorded("unknown_pid:13002");
622
623        let output = m.snapshot().render_prometheus();
624        assert!(
625            output.contains(r#"result="ok""#),
626            "plain label must survive; output:\n{output}"
627        );
628        assert!(
629            output.contains(r#"reason="unknown_pid:13002""#),
630            "reason label must survive; output:\n{output}"
631        );
632    }
633
634    #[test]
635    fn counters_are_monotonically_increasing() {
636        let m = fresh_metrics();
637        for _ in 0..100 {
638            m.deadline_fired("gpke");
639        }
640        let snap = m.snapshot();
641        let gpke = snap
642            .deadline_fired
643            .iter()
644            .find(|(k, _)| k.as_ref() == "gpke");
645        assert_eq!(gpke.map(|(_, v)| *v), Some(100));
646    }
647
648    #[test]
649    fn snapshot_sorted_by_label() {
650        let m = fresh_metrics();
651        // Insert in reverse order to verify sort.
652        m.process_initiated("wim");
653        m.process_initiated("mabis");
654        m.process_initiated("geli-gas");
655        m.process_initiated("gpke");
656
657        let snap = m.snapshot();
658        let labels: Vec<&str> = snap
659            .process_initiated
660            .iter()
661            .map(|(k, _)| k.as_ref())
662            .collect();
663        let mut sorted = labels.clone();
664        sorted.sort_unstable();
665        assert_eq!(labels, sorted, "snapshot must be sorted by label");
666    }
667}