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///
150/// For `makod_dead_letter_recorded_total`, the `reason` label is:
151/// - `unknown_pid:<N>` when `DeadLetterReason::UnknownPid { pid: N, .. }` — one label per
152///   distinct PID, enabling per-PID alerting
153/// - a short category string (`unknown_conversation`, `version_mismatch`, etc.)
154///   for all other reason variants
155///
156/// [`process_initiated`]: EngineMetrics::process_initiated
157/// [`process_completed`]: EngineMetrics::process_completed
158/// [`validation_failed`]: EngineMetrics::validation_failed
159/// [`outbox_delivery_attempted`]: EngineMetrics::outbox_delivery_attempted
160/// [`deadline_fired`]: EngineMetrics::deadline_fired
161/// [`dead_letter_recorded`]: EngineMetrics::dead_letter_recorded
162pub struct EngineMetrics {
163    /// `makod_process_initiated_total{family}` — incremented when a new
164    /// process is spawned via `Process::execute(InitiateXxx)`.
165    process_initiated: MetricVec,
166
167    /// `makod_process_completed_total{family,result}` — incremented when a
168    /// process reaches a terminal state.
169    process_completed: MetricVec,
170
171    /// `makod_validation_failed_total{message_type,release}` — incremented
172    /// when an inbound EDIFACT message fails AHB validation.
173    validation_failed: MetricVec,
174
175    /// `makod_outbox_delivery_attempts_total{result}` — incremented by the
176    /// AS4 sender on every delivery attempt.
177    outbox_delivery_attempts: MetricVec,
178
179    /// `makod_deadline_fired_total{family}` — incremented when a deadline
180    /// scheduler fires a `TimeoutExpired` command.
181    deadline_fired: MetricVec,
182
183    /// `makod_dead_letter_recorded_total{reason}` — incremented when a message
184    /// is sent to the dead-letter sink.
185    dead_letter_recorded: MetricVec,
186
187    /// `makod_inbound_messages_total{pid,result}` — incremented for every
188    /// inbound EDIFACT message that enters the dispatch pipeline.
189    ///
190    /// - `pid`: the 5-digit EDIFACT Prüfidentifikator (e.g. `"55001"`)
191    /// - `result`: `"dispatched"`, `"skipped"`, or `"error"`
192    inbound_received: MetricVec,
193}
194
195impl EngineMetrics {
196    fn new() -> Self {
197        Self {
198            process_initiated: MetricVec::default(),
199            process_completed: MetricVec::default(),
200            validation_failed: MetricVec::default(),
201            outbox_delivery_attempts: MetricVec::default(),
202            deadline_fired: MetricVec::default(),
203            dead_letter_recorded: MetricVec::default(),
204            inbound_received: MetricVec::default(),
205        }
206    }
207
208    /// Return the process-global [`EngineMetrics`] instance.
209    ///
210    /// The instance is initialised lazily on first call. Subsequent calls
211    /// return the same instance with zero allocation.
212    #[must_use]
213    pub fn global() -> &'static Self {
214        static GLOBAL: OnceLock<EngineMetrics> = OnceLock::new();
215        GLOBAL.get_or_init(Self::new)
216    }
217
218    // ── Increment methods ─────────────────────────────────────────────────────
219
220    /// Increment `makod_process_initiated_total{family=<family>}`.
221    ///
222    /// Call once when a domain workflow receives its first initiating command
223    /// (e.g. `LfAnmeldungCommand::InitiateAnmeldung`).
224    ///
225    /// `family` is the [`EngineModule::name`] value (`"gpke"`, `"wim"`, etc.).
226    ///
227    /// [`EngineModule::name`]: crate::builder::EngineModule::name
228    pub fn process_initiated(&self, family: &str) {
229        self.process_initiated.increment(family);
230    }
231
232    /// Increment `makod_process_completed_total{family=<family>,result=<result>}`.
233    ///
234    /// Call once when a workflow transitions to a **terminal state**
235    /// (`Active`, `Rejected`, timeout, or cancellation).
236    pub fn process_completed(&self, family: &str, outcome: ProcessOutcome) {
237        let label = format!("{family},{}", outcome.label());
238        self.process_completed.increment(&label);
239    }
240
241    /// Increment `makod_validation_failed_total{message_type=<type>,release=<rel>}`.
242    ///
243    /// Call when an inbound message fails `validate()` or `validate_against()`.
244    pub fn validation_failed(&self, message_type: &str, release: &str) {
245        let label = format!("{message_type},{release}");
246        self.validation_failed.increment(&label);
247    }
248
249    /// Increment `makod_outbox_delivery_attempts_total{result=<result>}`.
250    ///
251    /// Call in the AS4 sender after every delivery attempt.
252    /// `result` should be one of `"ok"`, `"transport_error"`, `"partner_unknown"`.
253    pub fn outbox_delivery_attempted(&self, result: &str) {
254        self.outbox_delivery_attempts.increment(result);
255    }
256
257    /// Increment `makod_deadline_fired_total{family=<family>}`.
258    ///
259    /// Call in the deadline scheduler when it dispatches a `TimeoutExpired`.
260    pub fn deadline_fired(&self, family: &str) {
261        self.deadline_fired.increment(family);
262    }
263
264    /// Increment `makod_dead_letter_recorded_total{reason=<reason>}`.
265    ///
266    /// Call in the dead-letter sink when `reject()` is invoked.
267    /// `reason` should match [`DeadLetterReason`]'s label string.
268    ///
269    /// [`DeadLetterReason`]: crate::dead_letter::DeadLetterReason
270    pub fn dead_letter_recorded(&self, reason: &str) {
271        self.dead_letter_recorded.increment(reason);
272    }
273
274    /// Increment `makod_inbound_messages_total{pid=<pid>,result=<result>}`.
275    ///
276    /// Call once per inbound EDIFACT message after the dispatch pipeline
277    /// completes (whether it succeeded or failed).
278    ///
279    /// - `pid` — the EDIFACT Prüfidentifikator (e.g. `55001`)
280    /// - `result` — `"dispatched"`, `"skipped"`, or `"error"`
281    pub fn inbound_received(&self, pid: u32, result: &str) {
282        let label = format!("{pid},{result}");
283        self.inbound_received.increment(&label);
284    }
285
286    // ── Snapshot ──────────────────────────────────────────────────────────────
287
288    /// Return a snapshot of all counters as a [`MetricsSnapshot`].
289    ///
290    /// This is a **read-only** operation that does not reset any counters.
291    /// Counters are monotonically increasing; Prometheus's `rate()` handles
292    /// counter resets on process restart automatically.
293    #[must_use]
294    pub fn snapshot(&self) -> MetricsSnapshot {
295        MetricsSnapshot {
296            process_initiated: self.process_initiated.snapshot(),
297            process_completed: self.process_completed.snapshot(),
298            validation_failed: self.validation_failed.snapshot(),
299            outbox_delivery_attempts: self.outbox_delivery_attempts.snapshot(),
300            deadline_fired: self.deadline_fired.snapshot(),
301            dead_letter_recorded: self.dead_letter_recorded.snapshot(),
302            inbound_received: self.inbound_received.snapshot(),
303        }
304    }
305}
306
307// ── MetricsSnapshot ───────────────────────────────────────────────────────────
308
309/// A point-in-time snapshot of all [`EngineMetrics`] counters.
310///
311/// Obtained via [`EngineMetrics::snapshot()`]. All fields are `Vec` of
312/// `(label, count)` pairs sorted by label for deterministic Prometheus output.
313///
314/// The `label` field uses a `","` separator for multi-label metrics
315/// (e.g. `"gpke,accepted"` for `{family="gpke",result="accepted"}`).
316/// The [`render_prometheus`] function splits them appropriately.
317///
318/// [`render_prometheus`]: MetricsSnapshot::render_prometheus
319#[derive(Debug, Clone)]
320pub struct MetricsSnapshot {
321    /// `(family, count)` pairs for `makod_process_initiated_total`.
322    pub process_initiated: Vec<(Box<str>, u64)>,
323    /// `("family,result", count)` pairs for `makod_process_completed_total`.
324    pub process_completed: Vec<(Box<str>, u64)>,
325    /// `("message_type,release", count)` pairs for `makod_validation_failed_total`.
326    pub validation_failed: Vec<(Box<str>, u64)>,
327    /// `(result, count)` pairs for `makod_outbox_delivery_attempts_total`.
328    pub outbox_delivery_attempts: Vec<(Box<str>, u64)>,
329    /// `(family, count)` pairs for `makod_deadline_fired_total`.
330    pub deadline_fired: Vec<(Box<str>, u64)>,
331    /// `(reason, count)` pairs for `makod_dead_letter_recorded_total`.
332    pub dead_letter_recorded: Vec<(Box<str>, u64)>,
333    /// `("pid,result", count)` pairs for `makod_inbound_messages_total`.
334    pub inbound_received: Vec<(Box<str>, u64)>,
335}
336
337impl MetricsSnapshot {
338    /// Render this snapshot to Prometheus text exposition format (v0.0.4).
339    ///
340    /// The output follows the format:
341    /// ```text
342    /// # HELP <metric_name> <description>
343    /// # TYPE <metric_name> counter
344    /// <metric_name>{<labels>} <value>
345    /// ```
346    ///
347    /// Multi-label metrics use a `","` separator in the internal label string,
348    /// which is split into separate `key="value"` pairs in the output.
349    #[must_use]
350    pub fn render_prometheus(&self) -> String {
351        let mut out = String::with_capacity(4096);
352
353        Self::write_counter_vec(
354            &mut out,
355            "makod_process_initiated_total",
356            "Total number of MaKo process instances initiated, by process family.",
357            &["family"],
358            &self.process_initiated,
359        );
360        Self::write_counter_vec(
361            &mut out,
362            "makod_process_completed_total",
363            "Total number of MaKo process instances that reached a terminal state.",
364            &["family", "result"],
365            &self.process_completed,
366        );
367        Self::write_counter_vec(
368            &mut out,
369            "makod_validation_failed_total",
370            "Total number of inbound EDIFACT messages that failed AHB validation.",
371            &["message_type", "release"],
372            &self.validation_failed,
373        );
374        Self::write_counter_vec(
375            &mut out,
376            "makod_outbox_delivery_attempts_total",
377            "Total number of AS4 outbox delivery attempts.",
378            &["result"],
379            &self.outbox_delivery_attempts,
380        );
381        Self::write_counter_vec(
382            &mut out,
383            "makod_deadline_fired_total",
384            "Total number of regulatory deadlines fired (TimeoutExpired dispatched).",
385            &["family"],
386            &self.deadline_fired,
387        );
388        Self::write_counter_vec(
389            &mut out,
390            "makod_dead_letter_recorded_total",
391            "Total number of messages sent to the durable dead-letter sink.",
392            &["reason"],
393            &self.dead_letter_recorded,
394        );
395        Self::write_counter_vec(
396            &mut out,
397            "makod_inbound_messages_total",
398            "Total number of inbound EDIFACT messages that entered the dispatch pipeline, \
399             by PID and outcome.",
400            &["pid", "result"],
401            &self.inbound_received,
402        );
403
404        out
405    }
406
407    /// Write a `counter` metric family to `out`.
408    ///
409    /// `label_names` specifies the label key names in order.  Each entry in
410    /// `pairs` has a label value that is either a bare string (single-label
411    /// metrics) or a `","` separated string (multi-label metrics, split in
412    /// order of `label_names`).
413    fn write_counter_vec(
414        out: &mut String,
415        name: &str,
416        help: &str,
417        label_names: &[&str],
418        pairs: &[(Box<str>, u64)],
419    ) {
420        if pairs.is_empty() {
421            return;
422        }
423        out.push_str("# HELP ");
424        out.push_str(name);
425        out.push(' ');
426        out.push_str(help);
427        out.push('\n');
428        out.push_str("# TYPE ");
429        out.push_str(name);
430        out.push_str(" counter\n");
431
432        for (label_str, count) in pairs {
433            let values: Vec<&str> = label_str.splitn(label_names.len(), ',').collect();
434            out.push_str(name);
435            out.push('{');
436            for (i, (key, val)) in label_names.iter().zip(values.iter()).enumerate() {
437                if i > 0 {
438                    out.push(',');
439                }
440                out.push_str(key);
441                out.push_str("=\"");
442                // Escape backslash, double-quote, and newline per Prometheus spec.
443                for ch in val.chars() {
444                    match ch {
445                        '\\' => out.push_str(r"\\"),
446                        '"' => out.push_str(r#"\""#),
447                        '\n' => out.push_str(r"\n"),
448                        _ => out.push(ch),
449                    }
450                }
451                out.push('"');
452            }
453            out.push_str("} ");
454            let _ = std::fmt::Write::write_fmt(out, format_args!("{count}"));
455            out.push('\n');
456        }
457    }
458}
459
460// ── Tests ─────────────────────────────────────────────────────────────────────
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn fresh_metrics() -> EngineMetrics {
467        EngineMetrics::new()
468    }
469
470    #[test]
471    fn process_initiated_increments_by_family() {
472        let m = fresh_metrics();
473        m.process_initiated("gpke");
474        m.process_initiated("gpke");
475        m.process_initiated("wim");
476
477        let snap = m.snapshot();
478        assert_eq!(snap.process_initiated.len(), 2);
479
480        let gpke = snap
481            .process_initiated
482            .iter()
483            .find(|(k, _)| k.as_ref() == "gpke");
484        assert_eq!(gpke.map(|(_, v)| *v), Some(2));
485
486        let wim = snap
487            .process_initiated
488            .iter()
489            .find(|(k, _)| k.as_ref() == "wim");
490        assert_eq!(wim.map(|(_, v)| *v), Some(1));
491    }
492
493    #[test]
494    fn process_completed_uses_composite_label() {
495        let m = fresh_metrics();
496        m.process_completed("gpke", ProcessOutcome::Accepted);
497        m.process_completed("gpke", ProcessOutcome::Rejected);
498        m.process_completed("gpke", ProcessOutcome::Accepted);
499        m.process_completed("wim", ProcessOutcome::Timeout);
500
501        let snap = m.snapshot();
502        let accepted = snap
503            .process_completed
504            .iter()
505            .find(|(k, _)| k.as_ref() == "gpke,accepted");
506        assert_eq!(accepted.map(|(_, v)| *v), Some(2));
507
508        let timeout = snap
509            .process_completed
510            .iter()
511            .find(|(k, _)| k.as_ref() == "wim,timeout");
512        assert_eq!(timeout.map(|(_, v)| *v), Some(1));
513    }
514
515    #[test]
516    fn snapshot_returns_zero_for_unincremented_metric() {
517        let m = fresh_metrics();
518        // No increments — snapshot should be empty.
519        let snap = m.snapshot();
520        assert!(snap.process_initiated.is_empty());
521        assert!(snap.process_completed.is_empty());
522    }
523
524    #[test]
525    fn render_prometheus_omits_empty_metric_families() {
526        let m = fresh_metrics();
527        m.process_initiated("gpke");
528
529        let output = m.snapshot().render_prometheus();
530
531        // Only the incremented family should appear.
532        assert!(
533            output.contains("makod_process_initiated_total"),
534            "initiated must appear"
535        );
536        assert!(
537            !output.contains("makod_process_completed_total"),
538            "completed must be absent"
539        );
540        assert!(
541            !output.contains("makod_validation_failed_total"),
542            "validation must be absent"
543        );
544    }
545
546    #[test]
547    fn render_prometheus_formats_labels_correctly() {
548        let m = fresh_metrics();
549        m.process_initiated("gpke");
550        m.process_completed("gpke", ProcessOutcome::Accepted);
551        m.validation_failed("utilmd", "S2.1");
552
553        let output = m.snapshot().render_prometheus();
554
555        assert!(
556            output.contains(r#"makod_process_initiated_total{family="gpke"} 1"#),
557            "single-label format must match; output:\n{output}"
558        );
559        assert!(
560            output.contains(r#"makod_process_completed_total{family="gpke",result="accepted"} 1"#),
561            "two-label format must match; output:\n{output}"
562        );
563        assert!(
564            output.contains(
565                r#"makod_validation_failed_total{message_type="utilmd",release="S2.1"} 1"#
566            ),
567            "message_type+release format must match; output:\n{output}"
568        );
569    }
570
571    #[test]
572    fn render_prometheus_escapes_special_chars_in_label_values() {
573        let m = fresh_metrics();
574        // Inject a label value with a backslash and a double-quote.
575        m.outbox_delivery_attempted("ok");
576        m.dead_letter_recorded("unknown_pid:13002");
577
578        let output = m.snapshot().render_prometheus();
579        assert!(
580            output.contains(r#"result="ok""#),
581            "plain label must survive; output:\n{output}"
582        );
583        assert!(
584            output.contains(r#"reason="unknown_pid:13002""#),
585            "reason label must survive; output:\n{output}"
586        );
587    }
588
589    #[test]
590    fn counters_are_monotonically_increasing() {
591        let m = fresh_metrics();
592        for _ in 0..100 {
593            m.deadline_fired("gpke");
594        }
595        let snap = m.snapshot();
596        let gpke = snap
597            .deadline_fired
598            .iter()
599            .find(|(k, _)| k.as_ref() == "gpke");
600        assert_eq!(gpke.map(|(_, v)| *v), Some(100));
601    }
602
603    #[test]
604    fn snapshot_sorted_by_label() {
605        let m = fresh_metrics();
606        // Insert in reverse order to verify sort.
607        m.process_initiated("wim");
608        m.process_initiated("mabis");
609        m.process_initiated("geli-gas");
610        m.process_initiated("gpke");
611
612        let snap = m.snapshot();
613        let labels: Vec<&str> = snap
614            .process_initiated
615            .iter()
616            .map(|(k, _)| k.as_ref())
617            .collect();
618        let mut sorted = labels.clone();
619        sorted.sort_unstable();
620        assert_eq!(labels, sorted, "snapshot must be sorted by label");
621    }
622}