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}