Skip to main content

camel_prometheus/
metrics.rs

1use std::time::Duration;
2
3use camel_api::metrics::MetricsCollector;
4use prometheus::{CounterVec, GaugeVec, HistogramVec, Opts, Registry, TextEncoder};
5
6/// Normalize a dynamic metric name for Prometheus: prepend `camel_` if missing,
7/// replace invalid chars with `_`, prefix `_` if it starts with a digit.
8/// Prometheus names must match `^[a-zA-Z_:][a-zA-Z0-9_:]*$`.
9fn normalize_prom_name(name: &str) -> String {
10    let prefixed = if name.starts_with("camel_") {
11        name.to_string()
12    } else {
13        format!("camel_{name}")
14    };
15    let sanitized: String = prefixed
16        .chars()
17        .map(|c| {
18            if c.is_ascii_alphanumeric() || c == '_' || c == ':' {
19                c
20            } else {
21                '_'
22            }
23        })
24        .collect();
25    sanitized
26}
27
28/// Sort label pairs by key so `with_label_values` (positional) binds correctly
29/// regardless of caller order.
30fn sort_label_pairs<'a>(labels: &'a [(&'a str, &'a str)]) -> Vec<(&'a str, &'a str)> {
31    let mut pairs = labels.to_vec();
32    pairs.sort_by(|a, b| a.0.cmp(b.0));
33    pairs
34}
35
36/// Validate a counter value: must be finite, non-negative, and integer-valued.
37/// NaN corrupts Prometheus counters in release; negative silently corrupts;
38/// fractional values diverge across backends (Prometheus keeps f64, OTel truncates).
39fn counter_value_ok(v: f64) -> bool {
40    !v.is_nan() && v >= 0.0 && v.fract() == 0.0
41}
42
43/// A dynamically-created counter plus the label-keys frozen at first observation.
44struct DynCounter {
45    cv: CounterVec,
46    keys: Vec<String>,
47}
48
49/// A dynamically-created histogram plus the label-keys frozen at first observation.
50struct DynHistogram {
51    hv: HistogramVec,
52    keys: Vec<String>,
53}
54
55/// Check whether the incoming label keys match the frozen key-set.
56fn keys_match(frozen: &[String], incoming: &[(&str, &str)]) -> bool {
57    if frozen.len() != incoming.len() {
58        return false;
59    }
60    frozen.iter().zip(incoming.iter()).all(|(f, (k, _))| f == k)
61}
62
63/// Prometheus metrics collector for rust-camel
64///
65/// This struct implements the `MetricsCollector` trait and exposes metrics
66/// in Prometheus format via the `/metrics` endpoint.
67pub struct PrometheusMetrics {
68    registry: Registry,
69    exchanges_total: CounterVec,
70    errors_total: CounterVec,
71    exchange_duration_seconds: HistogramVec,
72    queue_depth: GaugeVec,
73    circuit_breaker_state: GaugeVec,
74    /// Lazy cache for dynamic counters keyed by normalized name.
75    /// `None` = tombstone (registration failed; skip silently on subsequent calls).
76    dyn_counters: dashmap::DashMap<String, Option<DynCounter>>,
77    /// Lazy cache for dynamic histograms keyed by normalized name.
78    /// `None` = tombstone (registration failed; skip silently on subsequent calls).
79    dyn_histograms: dashmap::DashMap<String, Option<DynHistogram>>,
80    /// Names that have already emitted a `warn!` — dedup so a bad metric
81    /// logs once, not per-call (log-flood prevention).
82    warned: dashmap::DashSet<String>,
83}
84
85impl PrometheusMetrics {
86    /// Creates a new PrometheusMetrics instance with all metrics registered.
87    ///
88    /// # Panics
89    ///
90    /// Panics if metric creation or registration fails. This can only happen if:
91    /// - A metric name is invalid (must match `^[a-zA-Z_:][a-zA-Z0-9_:]*$`). All names are
92    ///   hardcoded below and comply with this requirement by convention.
93    /// - A metric is registered twice. This is impossible here because each call creates a
94    ///   fresh [`Registry`].
95    ///
96    /// Since both conditions are static invariants enforced by code review, these `expect()`
97    /// calls are intentional and will never fail in practice.
98    pub fn new() -> Self {
99        let registry = Registry::new();
100
101        // Create and register exchanges_total counter
102        let exchanges_total = CounterVec::new(
103            Opts::new("exchanges_total", "Total number of exchanges processed").namespace("camel"),
104            &["route"],
105        )
106        .expect("Failed to create exchanges_total counter"); // allow-unwrap
107        registry
108            .register(Box::new(exchanges_total.clone()))
109            .expect("Failed to register exchanges_total counter"); // allow-unwrap
110
111        // Create and register errors_total counter
112        let errors_total = CounterVec::new(
113            Opts::new("errors_total", "Total number of errors").namespace("camel"),
114            &["route", "error_type"],
115        )
116        .expect("Failed to create errors_total counter"); // allow-unwrap
117        registry
118            .register(Box::new(errors_total.clone()))
119            .expect("Failed to register errors_total counter"); // allow-unwrap
120
121        // Create and register exchange_duration_seconds histogram
122        // Using buckets suitable for typical exchange durations (ms to seconds range)
123        let exchange_duration_seconds = HistogramVec::new(
124            prometheus::HistogramOpts {
125                common_opts: Opts::new(
126                    "exchange_duration_seconds",
127                    "Exchange processing duration in seconds",
128                )
129                .namespace("camel"),
130                buckets: vec![
131                    0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
132                ],
133            },
134            &["route"],
135        )
136        .expect("Failed to create exchange_duration_seconds histogram"); // allow-unwrap
137        registry
138            .register(Box::new(exchange_duration_seconds.clone()))
139            .expect("Failed to register exchange_duration_seconds histogram"); // allow-unwrap
140
141        // Create and register queue_depth gauge
142        let queue_depth = GaugeVec::new(
143            Opts::new("queue_depth", "Current queue depth").namespace("camel"),
144            &["route"],
145        )
146        .expect("Failed to create queue_depth gauge"); // allow-unwrap
147        registry
148            .register(Box::new(queue_depth.clone()))
149            .expect("Failed to register queue_depth gauge"); // allow-unwrap
150
151        // Create and register circuit_breaker_state gauge
152        let circuit_breaker_state = GaugeVec::new(
153            Opts::new(
154                "circuit_breaker_state",
155                "Circuit breaker state (0=closed, 1=open, 2=half_open)",
156            )
157            .namespace("camel"),
158            &["route"],
159        )
160        .expect("Failed to create circuit_breaker_state gauge"); // allow-unwrap
161        registry
162            .register(Box::new(circuit_breaker_state.clone()))
163            .expect("Failed to register circuit_breaker_state gauge"); // allow-unwrap
164
165        Self {
166            registry,
167            exchanges_total,
168            errors_total,
169            exchange_duration_seconds,
170            queue_depth,
171            circuit_breaker_state,
172            dyn_counters: dashmap::DashMap::new(),
173            dyn_histograms: dashmap::DashMap::new(),
174            warned: dashmap::DashSet::new(),
175        }
176    }
177
178    /// Returns a reference to the underlying Prometheus registry
179    pub fn registry(&self) -> &Registry {
180        &self.registry
181    }
182
183    /// Gathers all metrics and returns them in Prometheus text format
184    pub fn gather(&self) -> String {
185        let encoder = TextEncoder::new();
186        let metric_families = self.registry.gather();
187        encoder
188            .encode_to_string(&metric_families)
189            .unwrap_or_else(|e| format!("# Error encoding metrics: {}\n", e))
190    }
191}
192
193impl Default for PrometheusMetrics {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199impl MetricsCollector for PrometheusMetrics {
200    fn record_exchange_duration(&self, route_id: &str, duration: Duration) {
201        let duration_secs = duration.as_secs_f64();
202        self.exchange_duration_seconds
203            .with_label_values(&[route_id])
204            .observe(duration_secs);
205    }
206
207    fn increment_errors(&self, route_id: &str, error_type: &str) {
208        self.errors_total
209            .with_label_values(&[route_id, error_type])
210            .inc();
211    }
212
213    fn increment_exchanges(&self, route_id: &str) {
214        self.exchanges_total.with_label_values(&[route_id]).inc();
215    }
216
217    fn set_queue_depth(&self, route_id: &str, depth: usize) {
218        self.queue_depth
219            .with_label_values(&[route_id])
220            .set(depth as f64);
221    }
222
223    fn record_circuit_breaker_change(&self, route_id: &str, _from: &str, to: &str) {
224        // Map state names to numeric values
225        let state_value = |state: &str| -> f64 {
226            match state.to_lowercase().as_str() {
227                "closed" => 0.0,
228                "open" => 1.0,
229                "half_open" | "halfopen" => 2.0,
230                _ => -1.0, // Unknown state
231            }
232        };
233
234        // Set the new state
235        self.circuit_breaker_state
236            .with_label_values(&[route_id])
237            .set(state_value(to));
238    }
239
240    fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
241        // Guard value first — cheap check, no cache access needed for bad values.
242        if !counter_value_ok(value) {
243            if self.warned.insert(name.to_string()) {
244                tracing::warn!(
245                    name,
246                    value,
247                    "dynamic counter value rejected (NaN/negative/non-integer); \
248                     further rejections for this name will be silent"
249                );
250            }
251            return;
252        }
253
254        let normalized = normalize_prom_name(name);
255        if normalized != name && self.warned.insert(format!("sanitize:{name}")) {
256            tracing::warn!(name, %normalized, "metric name sanitized for prometheus");
257        }
258        let sorted = sort_label_pairs(labels);
259        let values: Vec<&str> = sorted.iter().map(|(_, v)| *v).collect();
260
261        use dashmap::mapref::entry::Entry;
262        match self.dyn_counters.entry(normalized.clone()) {
263            Entry::Occupied(o) => match o.get() {
264                Some(dc) => {
265                    if keys_match(&dc.keys, &sorted) {
266                        dc.cv.with_label_values(&values).inc_by(value);
267                    } else if self.warned.insert(name.to_string()) {
268                        tracing::warn!(
269                            name,
270                            "dynamic counter label arity/key drift; observation dropped \
271                             (further drift for this name will be silent)"
272                        );
273                    }
274                }
275                None => { /* tombstone — skip silently */ }
276            },
277            Entry::Vacant(v) => {
278                let keys: Vec<String> = sorted.iter().map(|(k, _)| (*k).to_string()).collect();
279                let key_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();
280                let cv = match CounterVec::new(Opts::new(&normalized, "Dynamic counter"), &key_refs)
281                {
282                    Ok(cv) => cv,
283                    Err(_) => {
284                        v.insert(None);
285                        if self.warned.insert(name.to_string()) {
286                            tracing::warn!(name, "dynamic counter creation failed; tombstoned");
287                        }
288                        return;
289                    }
290                };
291                match self.registry.register(Box::new(cv.clone())) {
292                    Ok(()) => {
293                        cv.with_label_values(&values).inc_by(value);
294                        v.insert(Some(DynCounter { cv, keys }));
295                    }
296                    Err(_) => {
297                        v.insert(None);
298                        if self.warned.insert(name.to_string()) {
299                            tracing::warn!(
300                                name,
301                                "dynamic counter registration failed (possible name collision); \
302                                 tombstoned"
303                            );
304                        }
305                    }
306                }
307            }
308        }
309    }
310
311    fn record_histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
312        // Histograms only reject NaN (fractional values are legitimate).
313        if value.is_nan() {
314            if self.warned.insert(name.to_string()) {
315                tracing::warn!(
316                    name,
317                    "dynamic histogram value rejected (NaN); \
318                     further NaN for this name will be silent"
319                );
320            }
321            return;
322        }
323
324        let normalized = normalize_prom_name(name);
325        if normalized != name && self.warned.insert(format!("sanitize:{name}")) {
326            tracing::warn!(name, %normalized, "metric name sanitized for prometheus");
327        }
328        let sorted = sort_label_pairs(labels);
329        let values: Vec<&str> = sorted.iter().map(|(_, v)| *v).collect();
330
331        use dashmap::mapref::entry::Entry;
332        match self.dyn_histograms.entry(normalized.clone()) {
333            Entry::Occupied(o) => match o.get() {
334                Some(dh) => {
335                    if keys_match(&dh.keys, &sorted) {
336                        dh.hv.with_label_values(&values).observe(value);
337                    } else if self.warned.insert(name.to_string()) {
338                        tracing::warn!(
339                            name,
340                            "dynamic histogram label arity/key drift; observation dropped"
341                        );
342                    }
343                }
344                None => { /* tombstone — skip silently */ }
345            },
346            Entry::Vacant(v) => {
347                let keys: Vec<String> = sorted.iter().map(|(k, _)| (*k).to_string()).collect();
348                let key_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();
349                let hv = match HistogramVec::new(
350                    prometheus::HistogramOpts {
351                        common_opts: Opts::new(&normalized, "Dynamic histogram"),
352                        buckets: vec![
353                            0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
354                        ],
355                    },
356                    &key_refs,
357                ) {
358                    Ok(hv) => hv,
359                    Err(_) => {
360                        v.insert(None);
361                        if self.warned.insert(name.to_string()) {
362                            tracing::warn!(name, "dynamic histogram creation failed; tombstoned");
363                        }
364                        return;
365                    }
366                };
367                match self.registry.register(Box::new(hv.clone())) {
368                    Ok(()) => {
369                        hv.with_label_values(&values).observe(value);
370                        v.insert(Some(DynHistogram { hv, keys }));
371                    }
372                    Err(_) => {
373                        v.insert(None);
374                        if self.warned.insert(name.to_string()) {
375                            tracing::warn!(
376                                name,
377                                "dynamic histogram registration failed; tombstoned"
378                            );
379                        }
380                    }
381                }
382            }
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use std::sync::Arc;
391
392    #[test]
393    fn test_create_prometheus_metrics() {
394        let metrics = PrometheusMetrics::new();
395        // Verify registry is accessible
396        let _ = metrics.registry();
397    }
398
399    #[test]
400    fn test_default_implementation() {
401        let metrics = PrometheusMetrics::default();
402        // Verify registry is accessible
403        let _ = metrics.registry();
404    }
405
406    #[test]
407    fn test_increment_exchanges() {
408        let metrics = PrometheusMetrics::new();
409
410        // Should not panic
411        metrics.increment_exchanges("test-route");
412        metrics.increment_exchanges("test-route");
413        metrics.increment_exchanges("other-route");
414
415        // Verify the metric is registered
416        let output = metrics.gather();
417        assert!(output.contains("camel_exchanges_total"));
418        assert!(output.contains("test-route"));
419        assert!(output.contains("other-route"));
420    }
421
422    #[test]
423    fn test_increment_errors() {
424        let metrics = PrometheusMetrics::new();
425
426        // Should not panic
427        metrics.increment_errors("test-route", "timeout");
428        metrics.increment_errors("test-route", "connection_failed");
429        metrics.increment_errors("other-route", "timeout");
430
431        // Verify the metric is registered
432        let output = metrics.gather();
433        assert!(output.contains("camel_errors_total"));
434        assert!(output.contains("timeout"));
435        assert!(output.contains("connection_failed"));
436    }
437
438    #[test]
439    fn test_record_exchange_duration() {
440        let metrics = PrometheusMetrics::new();
441
442        // Should not panic
443        metrics.record_exchange_duration("test-route", Duration::from_millis(50));
444        metrics.record_exchange_duration("test-route", Duration::from_millis(150));
445        metrics.record_exchange_duration("other-route", Duration::from_secs(1));
446
447        // Verify the metric is registered
448        let output = metrics.gather();
449        assert!(output.contains("camel_exchange_duration_seconds"));
450        assert!(output.contains("test-route"));
451    }
452
453    #[test]
454    fn test_set_queue_depth() {
455        let metrics = PrometheusMetrics::new();
456
457        // Should not panic
458        metrics.set_queue_depth("test-route", 5);
459        metrics.set_queue_depth("test-route", 10);
460        metrics.set_queue_depth("other-route", 3);
461
462        // Verify the metric is registered
463        let output = metrics.gather();
464        assert!(output.contains("camel_queue_depth"));
465    }
466
467    #[test]
468    fn test_record_circuit_breaker_change() {
469        let metrics = PrometheusMetrics::new();
470
471        // Should not panic
472        metrics.record_circuit_breaker_change("test-route", "closed", "open");
473        metrics.record_circuit_breaker_change("test-route", "open", "half_open");
474        metrics.record_circuit_breaker_change("test-route", "half_open", "closed");
475
476        // Verify the metric is registered
477        let output = metrics.gather();
478        assert!(output.contains("camel_circuit_breaker_state"));
479    }
480
481    #[test]
482    fn test_gather_returns_prometheus_format() {
483        let metrics = PrometheusMetrics::new();
484
485        // Record some metrics
486        metrics.increment_exchanges("route-1");
487        metrics.increment_errors("route-1", "timeout");
488        metrics.set_queue_depth("route-1", 5);
489
490        // Gather metrics
491        let output = metrics.gather();
492
493        // Verify output is valid Prometheus text format
494        assert!(output.starts_with("# HELP") || output.starts_with("# TYPE"));
495        assert!(output.contains("camel_exchanges_total"));
496        assert!(output.contains("camel_errors_total"));
497        assert!(output.contains("camel_queue_depth"));
498
499        // Verify labels use 'route' not 'route_id'
500        assert!(output.contains("route=\"route-1\""));
501        assert!(!output.contains("route_id=\"route-1\""));
502    }
503
504    #[test]
505    fn test_metrics_collector_trait_object() {
506        // Verify PrometheusMetrics can be used as a trait object
507        let metrics: Arc<dyn MetricsCollector> = Arc::new(PrometheusMetrics::new());
508
509        // All methods should work without panicking
510        metrics.increment_exchanges("test-route");
511        metrics.increment_errors("test-route", "test-error");
512        metrics.record_exchange_duration("test-route", Duration::from_millis(100));
513        metrics.set_queue_depth("test-route", 5);
514        metrics.record_circuit_breaker_change("test-route", "closed", "open");
515    }
516
517    #[test]
518    fn test_record_counter_dynamic_basic() {
519        let metrics = PrometheusMetrics::new();
520        metrics.record_counter("exec_spawns_total", 1.0, &[("route", "r1")]);
521        metrics.record_counter("exec_spawns_total", 1.0, &[("route", "r1")]);
522        let out = metrics.gather();
523        assert!(
524            out.contains("camel_exec_spawns_total"),
525            "normalized name missing: {out}"
526        );
527        assert!(out.contains("route=\"r1\""));
528    }
529
530    #[test]
531    fn test_record_counter_multi_label_ordering_invariant() {
532        let metrics = PrometheusMetrics::new();
533        // Same metric, labels in different orders — must land on the same series.
534        metrics.record_counter(
535            "exec_policy_denials_total",
536            1.0,
537            &[("reason", "denied"), ("route", "r1")],
538        );
539        metrics.record_counter(
540            "exec_policy_denials_total",
541            1.0,
542            &[("route", "r1"), ("reason", "denied")],
543        );
544        let out = metrics.gather();
545        assert!(out.contains("camel_exec_policy_denials_total"));
546        // Both observations recorded on one series (count == 2). Extract the
547        // numeric value robustly from the Prometheus text line.
548        let count: f64 = out
549            .lines()
550            .filter(|l| {
551                l.contains("camel_exec_policy_denials_total") && l.contains("reason=\"denied\"")
552            })
553            .filter_map(|l| l.rsplit(' ').next().and_then(|v| v.parse::<f64>().ok()))
554            .sum();
555        assert_eq!(count, 2.0, "expected count 2, got {count}");
556    }
557
558    #[test]
559    fn test_record_counter_arity_drift_dropped() {
560        let metrics = PrometheusMetrics::new();
561        // First observation freezes key-set {route}.
562        metrics.record_counter("drift_total", 1.0, &[("route", "r1")]);
563        // Second observation adds a key — must be dropped (arity mismatch).
564        metrics.record_counter("drift_total", 1.0, &[("route", "r1"), ("extra", "x")]);
565        let out = metrics.gather();
566        // Only the first observation recorded (count == 1).
567        let count: f64 = out
568            .lines()
569            .filter(|l| l.contains("camel_drift_total"))
570            .filter_map(|l| l.rsplit(' ').next().and_then(|v| v.parse::<f64>().ok()))
571            .sum();
572        assert_eq!(count, 1.0, "expected count 1 (drift dropped), got {count}");
573    }
574
575    #[test]
576    fn test_record_counter_value_guards() {
577        let metrics = PrometheusMetrics::new();
578        metrics.record_counter("bad_total", f64::NAN, &[("route", "r1")]);
579        metrics.record_counter("bad_total", -1.0, &[("route", "r1")]);
580        metrics.record_counter("bad_total", 1.5, &[("route", "r1")]);
581        // All values rejected — metric must NOT appear in gather output.
582        let out = metrics.gather();
583        assert!(
584            !out.contains("camel_bad_total"),
585            "value guards should prevent cache population; found metric in output"
586        );
587    }
588
589    #[test]
590    fn test_record_counter_tombstone_on_collision() {
591        let metrics = PrometheusMetrics::new();
592        // "camel_exchanges_total" already registered as a fixed metric.
593        // A dynamic call with the same normalized name must tombstone (AlreadyReg),
594        // and NOT panic.
595        metrics.record_counter("exchanges_total", 1.0, &[("route", "r1")]);
596        // Call again — tombstone should make this a silent no-op (no re-attempt).
597        metrics.record_counter("exchanges_total", 1.0, &[("route", "r2")]);
598        // Verify the tombstone was inserted (None = registration failed).
599        let tombstoned = metrics
600            .dyn_counters
601            .get("camel_exchanges_total")
602            .map(|e| e.is_none())
603            .unwrap_or(false);
604        assert!(tombstoned, "expected tombstone for colliding metric name");
605    }
606
607    #[test]
608    fn test_record_counter_warn_dedup() {
609        let metrics = PrometheusMetrics::new();
610        // Three bad-value calls for the same name.
611        metrics.record_counter("dedup_total", f64::NAN, &[("route", "r1")]);
612        metrics.record_counter("dedup_total", -1.0, &[("route", "r1")]);
613        metrics.record_counter("dedup_total", 1.5, &[("route", "r1")]);
614        // The warned set should contain the name exactly once (dedup).
615        assert!(
616            metrics.warned.contains("dedup_total"),
617            "warned set should contain the offending name"
618        );
619    }
620
621    #[test]
622    fn test_record_counter_fixed_and_dynamic_in_one_gather() {
623        let metrics = PrometheusMetrics::new();
624        metrics.increment_exchanges("r1"); // fixed metric
625        metrics.record_counter("dyn_total", 1.0, &[("route", "r1")]); // dynamic metric
626        let out = metrics.gather();
627        assert!(
628            out.contains("camel_exchanges_total"),
629            "fixed metric missing"
630        );
631        assert!(out.contains("camel_dyn_total"), "dynamic metric missing");
632    }
633
634    #[test]
635    fn test_record_counter_trait_object_dispatch() {
636        // Retain a concrete handle to verify post-call state; Arc::downcast
637        // requires `Any` which MetricsCollector does not have.
638        let concrete = Arc::new(PrometheusMetrics::new());
639        let dynref: Arc<dyn MetricsCollector> = concrete.clone();
640        dynref.record_counter("trait_total", 1.0, &[("route", "r1")]);
641        // Verify it dispatched to the real impl (not the no-op default).
642        let out = concrete.gather();
643        assert!(out.contains("camel_trait_total"));
644    }
645
646    #[test]
647    fn test_record_counter_concurrent_no_panic() {
648        use std::thread;
649        let metrics = Arc::new(PrometheusMetrics::new());
650        let mut handles = Vec::new();
651        for i in 0..4 {
652            let m = Arc::clone(&metrics);
653            handles.push(thread::spawn(move || {
654                let route = format!("route-{i}");
655                for _ in 0..100 {
656                    m.record_counter("concurrent_total", 1.0, &[("route", &route)]);
657                }
658            }));
659        }
660        for h in handles {
661            h.join().expect("thread panicked under contention");
662        }
663        // Verify the metric was recorded (total == 400).
664        let out = metrics.gather();
665        let total: f64 = out
666            .lines()
667            .filter(|l| l.contains("camel_concurrent_total"))
668            .filter_map(|l| l.rsplit(' ').next().and_then(|v| v.parse::<f64>().ok()))
669            .sum();
670        assert_eq!(total, 400.0, "expected 400 total observations, got {total}");
671    }
672
673    #[test]
674    fn test_record_histogram_dynamic_basic() {
675        let metrics = PrometheusMetrics::new();
676        metrics.record_histogram("exec_duration_secs", 0.15, &[("route", "r1")]);
677        metrics.record_histogram("exec_duration_secs", 0.5, &[("route", "r1")]);
678        let out = metrics.gather();
679        assert!(
680            out.contains("camel_exec_duration_secs"),
681            "normalized name missing: {out}"
682        );
683        assert!(out.contains("route=\"r1\""));
684        // Prometheus text format emits histogram count as a _count suffixed series.
685        assert!(
686            out.contains("camel_exec_duration_secs_count"),
687            "expected histogram count series, got: {out}"
688        );
689    }
690
691    #[test]
692    fn test_record_histogram_nan_rejected() {
693        let metrics = PrometheusMetrics::new();
694        metrics.record_histogram("nan_hist", f64::NAN, &[("route", "r1")]);
695        let out = metrics.gather();
696        assert!(
697            !out.contains("camel_nan_hist"),
698            "NaN value should not create a histogram; found it in output: {out}"
699        );
700    }
701
702    #[test]
703    fn test_record_histogram_accepts_fractional() {
704        let metrics = PrometheusMetrics::new();
705        // Histograms legitimately accept fractional values (durations, cost).
706        metrics.record_histogram("frac_hist", 1.5, &[("route", "r1")]);
707        let out = metrics.gather();
708        assert!(out.contains("camel_frac_hist"));
709    }
710
711    #[test]
712    fn test_record_histogram_trait_object_dispatch() {
713        let concrete = Arc::new(PrometheusMetrics::new());
714        let dynref: Arc<dyn MetricsCollector> = concrete.clone();
715        dynref.record_histogram("trait_hist", 0.25, &[("route", "r1")]);
716        let out = concrete.gather();
717        assert!(out.contains("camel_trait_hist"));
718    }
719}
720
721#[cfg(test)]
722mod helper_tests {
723    use super::*;
724
725    #[test]
726    fn normalize_prom_name_prepends_camel_prefix() {
727        assert_eq!(
728            normalize_prom_name("exec_spawns_total"),
729            "camel_exec_spawns_total"
730        );
731    }
732
733    #[test]
734    fn normalize_prom_name_keeps_existing_camel_prefix() {
735        assert_eq!(normalize_prom_name("camel_foo_total"), "camel_foo_total");
736    }
737
738    #[test]
739    fn normalize_prom_name_replaces_invalid_chars() {
740        // dots and spaces are invalid in Prometheus metric names
741        assert_eq!(
742            normalize_prom_name("my.metric name"),
743            "camel_my_metric_name"
744        );
745    }
746
747    #[test]
748    fn normalize_prom_name_numeric_name_gets_camel_prefix() {
749        assert_eq!(normalize_prom_name("123foo"), "camel_123foo");
750    }
751
752    #[test]
753    fn sort_label_pairs_orders_by_key() {
754        let labels = [("route", "r1"), ("reason", "denied")];
755        let sorted = sort_label_pairs(&labels);
756        assert_eq!(sorted, vec![("reason", "denied"), ("route", "r1")]);
757    }
758
759    #[test]
760    fn sort_label_pairs_already_sorted_is_noop() {
761        let labels = [("code", "0"), ("route", "r1")];
762        let sorted = sort_label_pairs(&labels);
763        assert_eq!(sorted, vec![("code", "0"), ("route", "r1")]);
764    }
765
766    #[test]
767    fn counter_value_ok_accepts_positive_integers() {
768        assert!(counter_value_ok(1.0));
769        assert!(counter_value_ok(5.0));
770        assert!(counter_value_ok(0.0));
771    }
772
773    #[test]
774    fn counter_value_ok_rejects_nan_negative_and_fractional() {
775        assert!(!counter_value_ok(f64::NAN));
776        assert!(!counter_value_ok(-1.0));
777        assert!(!counter_value_ok(1.5));
778    }
779}