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}