1use std::time::Duration;
2
3use camel_api::metrics::MetricsCollector;
4use prometheus::{CounterVec, GaugeVec, HistogramVec, Opts, Registry, TextEncoder};
5
6fn 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
28fn 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
36fn counter_value_ok(v: f64) -> bool {
40 !v.is_nan() && v >= 0.0 && v.fract() == 0.0
41}
42
43struct DynCounter {
45 cv: CounterVec,
46 keys: Vec<String>,
47}
48
49struct DynHistogram {
51 hv: HistogramVec,
52 keys: Vec<String>,
53}
54
55fn 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
63pub 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 dyn_counters: dashmap::DashMap<String, Option<DynCounter>>,
77 dyn_histograms: dashmap::DashMap<String, Option<DynHistogram>>,
80 warned: dashmap::DashSet<String>,
83}
84
85impl PrometheusMetrics {
86 pub fn new() -> Self {
99 let registry = Registry::new();
100
101 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"); registry
108 .register(Box::new(exchanges_total.clone()))
109 .expect("Failed to register exchanges_total counter"); 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"); registry
118 .register(Box::new(errors_total.clone()))
119 .expect("Failed to register errors_total counter"); 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"); registry
138 .register(Box::new(exchange_duration_seconds.clone()))
139 .expect("Failed to register exchange_duration_seconds histogram"); 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"); registry
148 .register(Box::new(queue_depth.clone()))
149 .expect("Failed to register queue_depth gauge"); 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"); registry
162 .register(Box::new(circuit_breaker_state.clone()))
163 .expect("Failed to register circuit_breaker_state gauge"); 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 pub fn registry(&self) -> &Registry {
180 &self.registry
181 }
182
183 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 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, }
232 };
233
234 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 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 => { }
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 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 => { }
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 let _ = metrics.registry();
397 }
398
399 #[test]
400 fn test_default_implementation() {
401 let metrics = PrometheusMetrics::default();
402 let _ = metrics.registry();
404 }
405
406 #[test]
407 fn test_increment_exchanges() {
408 let metrics = PrometheusMetrics::new();
409
410 metrics.increment_exchanges("test-route");
412 metrics.increment_exchanges("test-route");
413 metrics.increment_exchanges("other-route");
414
415 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 metrics.increment_errors("test-route", "timeout");
428 metrics.increment_errors("test-route", "connection_failed");
429 metrics.increment_errors("other-route", "timeout");
430
431 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 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 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 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 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 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 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 metrics.increment_exchanges("route-1");
487 metrics.increment_errors("route-1", "timeout");
488 metrics.set_queue_depth("route-1", 5);
489
490 let output = metrics.gather();
492
493 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 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 let metrics: Arc<dyn MetricsCollector> = Arc::new(PrometheusMetrics::new());
508
509 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 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 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 metrics.record_counter("drift_total", 1.0, &[("route", "r1")]);
563 metrics.record_counter("drift_total", 1.0, &[("route", "r1"), ("extra", "x")]);
565 let out = metrics.gather();
566 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 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 metrics.record_counter("exchanges_total", 1.0, &[("route", "r1")]);
596 metrics.record_counter("exchanges_total", 1.0, &[("route", "r2")]);
598 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 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 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"); metrics.record_counter("dyn_total", 1.0, &[("route", "r1")]); 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 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 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 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 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 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 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}