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 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}