Skip to main content

camel_api/
metrics.rs

1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use arc_swap::ArcSwap;
5
6/// The closed set of allocator memory statistics published through
7/// [`MetricsCollector::set_allocator_memory`].
8///
9/// # exhaustive-by-contract
10///
11/// exhaustive-by-contract: a closed 4-variant allocator stat set whose
12/// label values (`allocated | resident | active | mapped`) are fixed by the
13/// metrics spec; out-of-crate emitters (the camel-cli jemalloc sampler) match
14/// every variant, so adding one is a contract change, not a compatible
15/// extension.
16#[derive(Clone, Copy, PartialEq, Eq, Debug)]
17pub enum AllocatorStat {
18    /// Total bytes allocated by the allocator (in-use).
19    Allocated,
20    /// Resident bytes backed by physical pages (RSS contribution).
21    Resident,
22    /// Bytes in active pages.
23    Active,
24    /// Bytes in mapped virtual ranges.
25    Mapped,
26}
27
28impl AllocatorStat {
29    /// The Prometheus `stat` label value for this statistic.
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            AllocatorStat::Allocated => "allocated",
33            AllocatorStat::Resident => "resident",
34            AllocatorStat::Active => "active",
35            AllocatorStat::Mapped => "mapped",
36        }
37    }
38}
39
40/// Trait for collecting metrics from the Camel runtime.
41/// Implementations can integrate with Prometheus, OpenTelemetry, etc.
42pub trait MetricsCollector: Send + Sync {
43    /// Record exchange processing time
44    fn record_exchange_duration(&self, route_id: &str, duration: Duration);
45
46    /// Increment error counter
47    fn increment_errors(&self, route_id: &str, error_type: &str);
48
49    /// Increment exchange counter
50    fn increment_exchanges(&self, route_id: &str);
51
52    /// Update the depth of a buffered stage's queue
53    /// (`camel_queue_depth{queue}`). The `queue` label is a closed set of
54    /// component-declared identifiers (`seda:<endpoint-name>`,
55    /// `aggregator:<route>`, `resequencer:<route>`).
56    fn set_queue_depth(&self, queue: &str, depth: usize);
57
58    /// Record circuit breaker state change
59    fn record_circuit_breaker_change(&self, route_id: &str, from: &str, to: &str);
60
61    /// Record a histogram observation (e.g., cost, latency distribution).
62    /// Default: no-op (backward-compatible).
63    fn record_histogram(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {}
64
65    /// Record a monotonically-increasing counter (e.g. `foo_total`).
66    /// Default: no-op (backward-compatible).
67    fn record_counter(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {}
68
69    /// Increment the per-attempt retry counter (`camel_retry_attempts_total`,
70    /// labels scheme+operation). Called once per retry attempt, including the
71    /// first. Default: no-op (backward-compatible).
72    fn increment_retry_attempt(&self, _scheme: &str, _operation: &str) {}
73
74    /// Increment the circuit-breaker rejection counter
75    /// (`camel_circuit_breaker_rejections_total`, label route). Open-breaker
76    /// fast-fails count here, not as errors. Default: no-op
77    /// (backward-compatible).
78    fn increment_circuit_breaker_rejection(&self, _route: &str) {}
79
80    /// Publish a route lifecycle-state transition (`camel_route_state`,
81    /// labels route+state). `state` is the projection's state label — a
82    /// closed set by construction (`Registered`, `Starting`, `Started`,
83    /// `Suspended`, `Stopping`, `Stopped`, `Failed`). Implementations keep
84    /// the route's last-published state so a transition sets the new series
85    /// to 1 and zeroes the previous one. Default: no-op
86    /// (backward-compatible).
87    fn set_route_state(&self, _route: &str, _state: &str) {}
88
89    /// Drop a route's state series (route removed/undeployed) so a
90    /// scrape reflects only routes that exist.
91    fn clear_route_state(&self, _route: &str) {}
92
93    /// Publish build identification (`camel_build_info{git_sha,version}`,
94    /// value 1). Called once when the context is built. Default: no-op
95    /// (backward-compatible).
96    fn record_build_info(&self, _version: &str, _git_sha: &str) {}
97
98    /// Publish process uptime in seconds (`camel_uptime_seconds`),
99    /// refreshed periodically by the runtime. Default: no-op
100    /// (backward-compatible).
101    fn record_uptime(&self, _seconds: f64) {}
102
103    /// Increment the uniform component-operations counter
104    /// (`camel_component_operations_total`, labels component+operation+
105    /// outcome). `outcome` is a closed set — "success" or "failure"
106    /// only; callers derive it from a bool (see `ComponentMetrics`),
107    /// never pass free text. Default: no-op (backward-compatible).
108    fn record_component_operation(&self, _component: &str, _operation: &str, _outcome: &str) {}
109
110    /// Publish the pinned client cache size for a component
111    /// (`camel_pinned_client_cache_size{component}`, gauge, unit: entries).
112    /// Emitted by the owning component after each lookup, reflecting the
113    /// current (approximate) entry count. Default:
114    /// no-op (backward-compatible).
115    fn set_pinned_client_cache_size(&self, _component: &str, _entries: u64) {}
116
117    /// Increment the pinned client cache hit counter for a component
118    /// (`camel_pinned_client_cache_hits_total{component}`) — a pinned
119    /// lookup served by the cache without a rebuild. Default: no-op
120    /// (backward-compatible).
121    fn increment_pinned_client_cache_hit(&self, _component: &str) {}
122
123    /// Increment the pinned client cache miss counter for a component
124    /// (`camel_pinned_client_cache_misses_total{component}`) — a pinned
125    /// lookup that required a client rebuild. Default: no-op
126    /// (backward-compatible).
127    fn increment_pinned_client_cache_miss(&self, _component: &str) {}
128
129    /// Publish an allocator memory statistic
130    /// (`camel_allocator_memory_bytes{stat}`, gauge, unit: bytes). `stat`
131    /// is a closed [`AllocatorStat`] variant; the sampler refreshes the
132    /// current value periodically. Default: no-op (backward-compatible).
133    fn set_allocator_memory(&self, _stat: AllocatorStat, _bytes: u64) {}
134}
135
136/// No-op metrics collector for default behavior
137pub struct NoOpMetrics;
138
139impl MetricsCollector for NoOpMetrics {
140    fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {}
141    fn increment_errors(&self, _route_id: &str, _error_type: &str) {}
142    fn increment_exchanges(&self, _route_id: &str) {}
143    fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
144    fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
145}
146
147/// Sized slot around `Arc<dyn MetricsCollector>`.
148///
149/// `ArcSwap`'s `RefCnt` implementation requires a `Sized` target, so a bare
150/// `ArcSwap<dyn MetricsCollector>` does not compile; this newtype restores
151/// `Sized`-ness without changing the stored pointee.
152struct CollectorSlot(Arc<dyn MetricsCollector>);
153
154/// A late-bound [`MetricsCollector`] cell.
155///
156/// Contract:
157///
158/// - **Late binding:** a `MetricsHandle` can be handed to consumers before any real
159///   collector exists; it seeds itself with [`NoOpMetrics`] so calls before (and
160///   without) registration are safe no-ops.
161/// - **Composition, not replacement:** each [`MetricsHandle::register`] composes the
162///   new collector *over* the currently stored one (see [`CompositeMetricsCollector`]);
163///   previously registered collectors keep observing.
164/// - **Same-Arc idempotence:** registering the same collector `Arc` twice is a no-op
165///   (detected via `Arc::ptr_eq` against the membership list), so a call site that
166///   wires the same collector through two builder paths does not double-count.
167/// - **Delegation cost:** each trait-method call costs one atomic load of the stored
168///   `Arc` (`ArcSwap::load`); the hot path never clones the `Arc`.
169pub struct MetricsHandle {
170    inner: ArcSwap<CollectorSlot>,
171    /// Membership list of every accepted collector, parallel to `inner`.
172    /// Kept because the stored `dyn` composite cannot be introspected for
173    /// `Arc::ptr_eq` dedupe.
174    members: Mutex<Vec<Arc<dyn MetricsCollector>>>,
175}
176
177impl MetricsHandle {
178    /// Creates a handle that delegates to [`NoOpMetrics`] until a collector is
179    /// registered.
180    pub fn new() -> Self {
181        Self {
182            inner: ArcSwap::from_pointee(CollectorSlot(Arc::new(NoOpMetrics))),
183            members: Mutex::new(Vec::new()),
184        }
185    }
186
187    /// Registers `collector`, composing it over whatever is currently stored.
188    ///
189    /// If the exact same `Arc` was already registered, this is a no-op
190    /// (see *same-Arc idempotence* in the type-level docs).
191    pub fn register(&self, collector: Arc<dyn MetricsCollector>) {
192        let mut members = self
193            .members
194            .lock()
195            .expect("metrics members lock poisoned by a panicked register"); // allow-unwrap
196        if members.iter().any(|m| Arc::ptr_eq(m, &collector)) {
197            return;
198        }
199        let first = members.is_empty();
200        members.push(Arc::clone(&collector));
201        if first {
202            // Store directly — composing over the seeded NoOp would leave a
203            // permanent dead leg in every later composite chain.
204            self.inner.store(Arc::new(CollectorSlot(collector)));
205            return;
206        }
207        let prev = Arc::clone(&self.inner.load().0);
208        self.inner.store(Arc::new(CollectorSlot(Arc::new(
209            CompositeMetricsCollector::new(vec![prev, collector]),
210        ))));
211    }
212}
213
214impl Default for MetricsHandle {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220impl MetricsCollector for MetricsHandle {
221    fn record_exchange_duration(&self, route_id: &str, duration: Duration) {
222        self.inner
223            .load()
224            .0
225            .record_exchange_duration(route_id, duration)
226    }
227
228    fn increment_errors(&self, route_id: &str, error_type: &str) {
229        self.inner.load().0.increment_errors(route_id, error_type)
230    }
231
232    fn increment_exchanges(&self, route_id: &str) {
233        self.inner.load().0.increment_exchanges(route_id)
234    }
235
236    fn set_queue_depth(&self, queue: &str, depth: usize) {
237        self.inner.load().0.set_queue_depth(queue, depth)
238    }
239
240    fn record_circuit_breaker_change(&self, route_id: &str, from: &str, to: &str) {
241        self.inner
242            .load()
243            .0
244            .record_circuit_breaker_change(route_id, from, to)
245    }
246
247    fn record_histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
248        self.inner.load().0.record_histogram(name, value, labels)
249    }
250
251    fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
252        self.inner.load().0.record_counter(name, value, labels)
253    }
254
255    fn increment_retry_attempt(&self, scheme: &str, operation: &str) {
256        self.inner
257            .load()
258            .0
259            .increment_retry_attempt(scheme, operation)
260    }
261
262    fn increment_circuit_breaker_rejection(&self, route: &str) {
263        self.inner
264            .load()
265            .0
266            .increment_circuit_breaker_rejection(route)
267    }
268
269    fn set_route_state(&self, route: &str, state: &str) {
270        self.inner.load().0.set_route_state(route, state)
271    }
272
273    fn clear_route_state(&self, route: &str) {
274        self.inner.load().0.clear_route_state(route)
275    }
276
277    fn record_build_info(&self, version: &str, git_sha: &str) {
278        self.inner.load().0.record_build_info(version, git_sha)
279    }
280
281    fn record_uptime(&self, seconds: f64) {
282        self.inner.load().0.record_uptime(seconds)
283    }
284
285    fn record_component_operation(&self, component: &str, operation: &str, outcome: &str) {
286        self.inner
287            .load()
288            .0
289            .record_component_operation(component, operation, outcome)
290    }
291
292    fn set_pinned_client_cache_size(&self, component: &str, entries: u64) {
293        self.inner
294            .load()
295            .0
296            .set_pinned_client_cache_size(component, entries)
297    }
298
299    fn increment_pinned_client_cache_hit(&self, component: &str) {
300        self.inner
301            .load()
302            .0
303            .increment_pinned_client_cache_hit(component)
304    }
305
306    fn increment_pinned_client_cache_miss(&self, component: &str) {
307        self.inner
308            .load()
309            .0
310            .increment_pinned_client_cache_miss(component)
311    }
312
313    fn set_allocator_memory(&self, stat: AllocatorStat, bytes: u64) {
314        self.inner.load().0.set_allocator_memory(stat, bytes)
315    }
316}
317
318/// A [`MetricsCollector`] that fans every observation out to a list of collectors,
319/// in registration order.
320///
321/// Built by [`MetricsHandle::register`] — the second registration stores a
322/// composite of `[first, second]`; a third composes over that composite, so
323/// ordering and prior observation are preserved (composition, not replacement).
324///
325/// Internal type, hidden from the published docs. Out-of-tree code must not
326/// construct composites directly: registering an externally built composite
327/// plus its inner collector double-counts (the handle's opaque-Arc dedupe
328/// cannot see inside a composite). Register collectors via
329/// [`MetricsHandle::register`] instead.
330#[doc(hidden)]
331pub struct CompositeMetricsCollector {
332    collectors: Vec<Arc<dyn MetricsCollector>>,
333}
334
335impl CompositeMetricsCollector {
336    /// Creates a composite that delegates to `collectors` in order.
337    ///
338    /// Internal constructor, hidden from the published docs. Prefer
339    /// [`MetricsHandle::register`], which composes while deduplicating by
340    /// `Arc` pointer identity; direct construction bypasses that dedupe and
341    /// can double-count.
342    #[doc(hidden)]
343    pub fn new(collectors: Vec<Arc<dyn MetricsCollector>>) -> Self {
344        Self { collectors }
345    }
346}
347
348impl MetricsCollector for CompositeMetricsCollector {
349    fn record_exchange_duration(&self, route_id: &str, duration: Duration) {
350        for collector in &self.collectors {
351            collector.record_exchange_duration(route_id, duration);
352        }
353    }
354
355    fn increment_errors(&self, route_id: &str, error_type: &str) {
356        for collector in &self.collectors {
357            collector.increment_errors(route_id, error_type);
358        }
359    }
360
361    fn increment_exchanges(&self, route_id: &str) {
362        for collector in &self.collectors {
363            collector.increment_exchanges(route_id);
364        }
365    }
366
367    fn set_queue_depth(&self, queue: &str, depth: usize) {
368        for collector in &self.collectors {
369            collector.set_queue_depth(queue, depth);
370        }
371    }
372
373    fn record_circuit_breaker_change(&self, route_id: &str, from: &str, to: &str) {
374        for collector in &self.collectors {
375            collector.record_circuit_breaker_change(route_id, from, to);
376        }
377    }
378
379    fn record_histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
380        for collector in &self.collectors {
381            collector.record_histogram(name, value, labels);
382        }
383    }
384
385    fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
386        for collector in &self.collectors {
387            collector.record_counter(name, value, labels);
388        }
389    }
390
391    fn increment_retry_attempt(&self, scheme: &str, operation: &str) {
392        for collector in &self.collectors {
393            collector.increment_retry_attempt(scheme, operation);
394        }
395    }
396
397    fn increment_circuit_breaker_rejection(&self, route: &str) {
398        for collector in &self.collectors {
399            collector.increment_circuit_breaker_rejection(route);
400        }
401    }
402
403    fn set_route_state(&self, route: &str, state: &str) {
404        for collector in &self.collectors {
405            collector.set_route_state(route, state);
406        }
407    }
408
409    fn clear_route_state(&self, route: &str) {
410        for collector in &self.collectors {
411            collector.clear_route_state(route);
412        }
413    }
414
415    fn record_build_info(&self, version: &str, git_sha: &str) {
416        for collector in &self.collectors {
417            collector.record_build_info(version, git_sha);
418        }
419    }
420
421    fn record_uptime(&self, seconds: f64) {
422        for collector in &self.collectors {
423            collector.record_uptime(seconds);
424        }
425    }
426
427    fn record_component_operation(&self, component: &str, operation: &str, outcome: &str) {
428        for collector in &self.collectors {
429            collector.record_component_operation(component, operation, outcome);
430        }
431    }
432
433    fn set_pinned_client_cache_size(&self, component: &str, entries: u64) {
434        for collector in &self.collectors {
435            collector.set_pinned_client_cache_size(component, entries);
436        }
437    }
438
439    fn increment_pinned_client_cache_hit(&self, component: &str) {
440        for collector in &self.collectors {
441            collector.increment_pinned_client_cache_hit(component);
442        }
443    }
444
445    fn increment_pinned_client_cache_miss(&self, component: &str) {
446        for collector in &self.collectors {
447            collector.increment_pinned_client_cache_miss(component);
448        }
449    }
450
451    fn set_allocator_memory(&self, stat: AllocatorStat, bytes: u64) {
452        for collector in &self.collectors {
453            collector.set_allocator_memory(stat, bytes);
454        }
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::sync::{Arc, Mutex};
462
463    /// Test double that records observations for later inspection.
464    struct RecordingMetrics {
465        durations: Mutex<Vec<(String, Duration)>>,
466        errors: Mutex<Vec<(String, String)>>,
467        exchanges: Mutex<Vec<String>>,
468        retries: Mutex<Vec<(String, String)>>,
469        rejections: Mutex<Vec<String>>,
470        pinned: Mutex<Vec<(&'static str, String, u64)>>,
471        allocator: Mutex<Vec<(AllocatorStat, u64)>>,
472    }
473
474    impl RecordingMetrics {
475        fn new() -> Self {
476            Self {
477                durations: Mutex::new(Vec::new()),
478                errors: Mutex::new(Vec::new()),
479                exchanges: Mutex::new(Vec::new()),
480                retries: Mutex::new(Vec::new()),
481                rejections: Mutex::new(Vec::new()),
482                pinned: Mutex::new(Vec::new()),
483                allocator: Mutex::new(Vec::new()),
484            }
485        }
486    }
487
488    impl MetricsCollector for RecordingMetrics {
489        fn record_exchange_duration(&self, route_id: &str, duration: Duration) {
490            self.durations
491                .lock()
492                .expect("durations lock")
493                .push((route_id.to_string(), duration));
494        }
495
496        fn increment_errors(&self, route_id: &str, error_type: &str) {
497            self.errors
498                .lock()
499                .expect("errors lock")
500                .push((route_id.to_string(), error_type.to_string()));
501        }
502
503        fn increment_exchanges(&self, route_id: &str) {
504            self.exchanges
505                .lock()
506                .expect("exchanges lock")
507                .push(route_id.to_string());
508        }
509
510        fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
511
512        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
513
514        fn increment_retry_attempt(&self, scheme: &str, operation: &str) {
515            self.retries
516                .lock()
517                .expect("retries lock")
518                .push((scheme.to_string(), operation.to_string()));
519        }
520
521        fn increment_circuit_breaker_rejection(&self, route: &str) {
522            self.rejections
523                .lock()
524                .expect("rejections lock")
525                .push(route.to_string());
526        }
527
528        fn set_pinned_client_cache_size(&self, component: &str, entries: u64) {
529            self.pinned.lock().expect("pinned lock").push((
530                "set_pinned_client_cache_size",
531                component.to_string(),
532                entries,
533            ));
534        }
535
536        fn increment_pinned_client_cache_hit(&self, component: &str) {
537            self.pinned.lock().expect("pinned lock").push((
538                "increment_pinned_client_cache_hit",
539                component.to_string(),
540                1,
541            ));
542        }
543
544        fn increment_pinned_client_cache_miss(&self, component: &str) {
545            self.pinned.lock().expect("pinned lock").push((
546                "increment_pinned_client_cache_miss",
547                component.to_string(),
548                1,
549            ));
550        }
551
552        fn set_allocator_memory(&self, stat: AllocatorStat, bytes: u64) {
553            self.allocator
554                .lock()
555                .expect("allocator lock")
556                .push((stat, bytes));
557        }
558    }
559
560    /// Test double that tags every trait-method call by name, for
561    /// delegation-parity assertions over the full `MetricsCollector` surface.
562    struct SurfaceProbe {
563        calls: Mutex<Vec<&'static str>>,
564    }
565
566    impl SurfaceProbe {
567        fn new() -> Self {
568            Self {
569                calls: Mutex::new(Vec::new()),
570            }
571        }
572
573        fn tag(&self, name: &'static str) {
574            self.calls.lock().expect("calls lock").push(name);
575        }
576    }
577
578    impl MetricsCollector for SurfaceProbe {
579        fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {
580            self.tag("record_exchange_duration");
581        }
582        fn increment_errors(&self, _route_id: &str, _error_type: &str) {
583            self.tag("increment_errors");
584        }
585        fn increment_exchanges(&self, _route_id: &str) {
586            self.tag("increment_exchanges");
587        }
588        fn set_queue_depth(&self, _queue: &str, _depth: usize) {
589            self.tag("set_queue_depth");
590        }
591        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {
592            self.tag("record_circuit_breaker_change");
593        }
594        fn record_histogram(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {
595            self.tag("record_histogram");
596        }
597        fn record_counter(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {
598            self.tag("record_counter");
599        }
600        fn increment_retry_attempt(&self, _scheme: &str, _operation: &str) {
601            self.tag("increment_retry_attempt");
602        }
603        fn increment_circuit_breaker_rejection(&self, _route: &str) {
604            self.tag("increment_circuit_breaker_rejection");
605        }
606        fn set_route_state(&self, _route: &str, _state: &str) {
607            self.tag("set_route_state");
608        }
609
610        fn clear_route_state(&self, _route: &str) {
611            self.tag("clear_route_state");
612        }
613        fn record_build_info(&self, _version: &str, _git_sha: &str) {
614            self.tag("record_build_info");
615        }
616        fn record_uptime(&self, _seconds: f64) {
617            self.tag("record_uptime");
618        }
619        fn record_component_operation(&self, _component: &str, _operation: &str, _outcome: &str) {
620            self.tag("record_component_operation");
621        }
622    }
623
624    #[test]
625    fn test_noop_metrics_implements_trait() {
626        let metrics = NoOpMetrics;
627        let metrics_arc: Arc<dyn MetricsCollector> = Arc::new(metrics);
628
629        // All methods should execute without panicking
630        metrics_arc.record_exchange_duration("test-route", Duration::from_millis(100));
631        metrics_arc.increment_errors("test-route", "test-error");
632        metrics_arc.increment_exchanges("test-route");
633        metrics_arc.set_queue_depth("test-route", 5);
634        metrics_arc.record_circuit_breaker_change("test-route", "closed", "open");
635    }
636
637    #[test]
638    fn test_custom_metrics_collector() {
639        struct TestMetrics {
640            exchange_count: std::sync::atomic::AtomicU64,
641        }
642
643        impl MetricsCollector for TestMetrics {
644            fn record_exchange_duration(&self, route_id: &str, duration: Duration) {
645                // In a real implementation, this would record the duration
646                println!("Route {} took {}ms", route_id, duration.as_millis());
647            }
648
649            fn increment_errors(&self, route_id: &str, error_type: &str) {
650                // In a real implementation, this would increment an error counter
651                println!("Route {} had error: {}", route_id, error_type);
652            }
653
654            fn increment_exchanges(&self, route_id: &str) {
655                // In a real implementation, this would increment an exchange counter
656                self.exchange_count
657                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
658                println!("Route {} processed exchange", route_id);
659            }
660
661            fn set_queue_depth(&self, queue: &str, depth: usize) {
662                // In a real implementation, this would update a gauge
663                println!("Queue {queue} depth: {depth}");
664            }
665
666            fn record_circuit_breaker_change(&self, route_id: &str, from: &str, to: &str) {
667                // In a real implementation, this would record the state change
668                println!("Route {} circuit breaker: {} -> {}", route_id, from, to);
669            }
670        }
671
672        let test_metrics = TestMetrics {
673            exchange_count: std::sync::atomic::AtomicU64::new(0),
674        };
675        let metrics_arc: Arc<dyn MetricsCollector> = Arc::new(test_metrics);
676
677        // Test that all methods work
678        metrics_arc.record_exchange_duration("test-route", Duration::from_millis(100));
679        metrics_arc.increment_errors("test-route", "test-error");
680        metrics_arc.increment_exchanges("test-route");
681        metrics_arc.set_queue_depth("test-route", 5);
682        metrics_arc.record_circuit_breaker_change("test-route", "closed", "open");
683
684        // Note: We can't easily test the counter value without additional accessors
685        // This is just to verify the trait implementation works
686    }
687
688    #[test]
689    fn handle_delegates_to_stored_collector() {
690        let collector = Arc::new(RecordingMetrics::new());
691        let handle = MetricsHandle::new();
692        handle.register(collector.clone());
693
694        handle.record_exchange_duration("r", Duration::from_millis(1));
695
696        let recorded = collector.durations.lock().expect("durations lock").clone();
697        assert_eq!(recorded, vec![("r".to_string(), Duration::from_millis(1))]);
698    }
699
700    #[test]
701    fn second_registration_composes_both_observe() {
702        let a = Arc::new(RecordingMetrics::new());
703        let b = Arc::new(RecordingMetrics::new());
704        let handle = MetricsHandle::new();
705        handle.register(a.clone());
706        handle.register(b.clone());
707
708        handle.increment_errors("r", "x");
709
710        let a_errors = a.errors.lock().expect("errors lock").clone();
711        let b_errors = b.errors.lock().expect("errors lock").clone();
712        assert_eq!(a_errors, vec![("r".to_string(), "x".to_string())]);
713        assert_eq!(b_errors, vec![("r".to_string(), "x".to_string())]);
714    }
715
716    #[test]
717    fn register_same_arc_is_idempotent() {
718        let a = Arc::new(RecordingMetrics::new());
719        let handle = MetricsHandle::new();
720        handle.register(a.clone());
721        handle.register(a.clone());
722
723        handle.increment_exchanges("r");
724
725        let recorded = a.exchanges.lock().expect("exchanges lock").clone();
726        assert_eq!(recorded, vec!["r".to_string()]);
727    }
728
729    #[test]
730    fn handle_defaults_to_noop() {
731        let handle = MetricsHandle::new();
732        handle.record_exchange_duration("r", Duration::from_millis(1));
733        handle.increment_errors("r", "x");
734        handle.increment_exchanges("r");
735        handle.set_queue_depth("r", 5);
736        handle.record_circuit_breaker_change("r", "closed", "open");
737        handle.record_histogram("h", 1.0, &[("k", "v")]);
738        handle.record_counter("c", 1.0, &[("k", "v")]);
739    }
740
741    #[test]
742    fn composite_delegates_retry_and_rejection() {
743        let a = Arc::new(RecordingMetrics::new());
744        let b = Arc::new(RecordingMetrics::new());
745        let composite = CompositeMetricsCollector::new(vec![
746            Arc::clone(&a) as Arc<dyn MetricsCollector>,
747            Arc::clone(&b) as Arc<dyn MetricsCollector>,
748        ]);
749
750        composite.increment_retry_attempt("kafka", "connect");
751        composite.increment_circuit_breaker_rejection("r1");
752
753        for member in [&a, &b] {
754            assert_eq!(
755                member.retries.lock().expect("retries lock").clone(),
756                vec![("kafka".to_string(), "connect".to_string())]
757            );
758            assert_eq!(
759                member.rejections.lock().expect("rejections lock").clone(),
760                vec!["r1".to_string()]
761            );
762        }
763    }
764
765    #[test]
766    fn noop_defaults_compile_and_do_nothing() {
767        let collector: Arc<dyn MetricsCollector> = Arc::new(NoOpMetrics);
768        // Both new methods must exist as no-op defaults: compile + no panic.
769        collector.increment_retry_attempt("kafka", "connect");
770        collector.increment_circuit_breaker_rejection("r1");
771    }
772
773    /// Delegation parity: the composite fans the full `MetricsCollector`
774    /// surface out to every member.
775    #[test]
776    fn composite_delegates_full_trait_surface() {
777        let a = Arc::new(SurfaceProbe::new());
778        let b = Arc::new(SurfaceProbe::new());
779        let composite = CompositeMetricsCollector::new(vec![
780            Arc::clone(&a) as Arc<dyn MetricsCollector>,
781            Arc::clone(&b) as Arc<dyn MetricsCollector>,
782        ]);
783
784        composite.record_exchange_duration("r", Duration::from_millis(1));
785        composite.increment_errors("r", "x");
786        composite.increment_exchanges("r");
787        composite.set_queue_depth("r", 1);
788        composite.record_circuit_breaker_change("r", "closed", "open");
789        composite.record_histogram("h", 1.0, &[("k", "v")]);
790        composite.record_counter("c", 1.0, &[("k", "v")]);
791        composite.increment_retry_attempt("kafka", "connect");
792        composite.increment_circuit_breaker_rejection("r1");
793        composite.set_route_state("r", "Started");
794        composite.clear_route_state("r");
795        composite.record_build_info("1.2.3", "abc1234");
796        composite.record_uptime(0.5);
797        composite.record_component_operation("redis", "command", "success");
798
799        let expected = vec![
800            "record_exchange_duration",
801            "increment_errors",
802            "increment_exchanges",
803            "set_queue_depth",
804            "record_circuit_breaker_change",
805            "record_histogram",
806            "record_counter",
807            "increment_retry_attempt",
808            "increment_circuit_breaker_rejection",
809            "set_route_state",
810            "clear_route_state",
811            "record_build_info",
812            "record_uptime",
813            "record_component_operation",
814        ];
815        for member in [&a, &b] {
816            let calls = member.calls.lock().expect("calls lock").clone();
817            assert_eq!(calls, expected, "member missed part of the trait surface");
818        }
819    }
820
821    /// Expected pinned-cache triple captures for one call of each method
822    /// with component `"camel-https"` and entries `3` (counters record 1).
823    fn pinned_trio_expected() -> Vec<(&'static str, String, u64)> {
824        vec![
825            ("set_pinned_client_cache_size", "camel-https".to_string(), 3),
826            (
827                "increment_pinned_client_cache_hit",
828                "camel-https".to_string(),
829                1,
830            ),
831            (
832                "increment_pinned_client_cache_miss",
833                "camel-https".to_string(),
834                1,
835            ),
836        ]
837    }
838
839    #[test]
840    fn handle_forwards_pinned_cache_trio() {
841        let collector = Arc::new(RecordingMetrics::new());
842        let handle = MetricsHandle::new();
843        handle.register(collector.clone());
844
845        handle.set_pinned_client_cache_size("camel-https", 3);
846        handle.increment_pinned_client_cache_hit("camel-https");
847        handle.increment_pinned_client_cache_miss("camel-https");
848
849        let captured = collector.pinned.lock().expect("pinned lock").clone();
850        assert_eq!(captured, pinned_trio_expected());
851
852        // An unwired handle delegates to the seeded NoOp: neither panics
853        // nor records into any collector double. Emissions made before
854        // registration are dropped, not buffered and replayed.
855        let bystander = Arc::new(RecordingMetrics::new());
856        let unwired = MetricsHandle::new();
857        unwired.set_pinned_client_cache_size("camel-https", 3);
858        unwired.increment_pinned_client_cache_hit("camel-https");
859        unwired.increment_pinned_client_cache_miss("camel-https");
860        unwired.register(bystander.clone());
861        assert!(bystander.pinned.lock().expect("pinned lock").is_empty());
862    }
863
864    #[test]
865    fn composite_forwards_pinned_cache_trio_to_all_collectors() {
866        let a = Arc::new(RecordingMetrics::new());
867        let b = Arc::new(RecordingMetrics::new());
868        let composite = CompositeMetricsCollector::new(vec![
869            Arc::clone(&a) as Arc<dyn MetricsCollector>,
870            Arc::clone(&b) as Arc<dyn MetricsCollector>,
871        ]);
872
873        composite.set_pinned_client_cache_size("camel-https", 3);
874        composite.increment_pinned_client_cache_hit("camel-https");
875        composite.increment_pinned_client_cache_miss("camel-https");
876
877        for member in [&a, &b] {
878            let captured = member.pinned.lock().expect("pinned lock").clone();
879            assert_eq!(
880                captured,
881                pinned_trio_expected(),
882                "member missed part of the pinned-cache trio"
883            );
884        }
885    }
886
887    /// The `as_str()` image of `AllocatorStat` is the closed label-value set
888    /// (spec: `allocated | resident | active | mapped`).
889    #[test]
890    fn allocator_stat_as_str_image_is_closed_set() {
891        let image: std::collections::BTreeSet<&'static str> = [
892            AllocatorStat::Allocated,
893            AllocatorStat::Resident,
894            AllocatorStat::Active,
895            AllocatorStat::Mapped,
896        ]
897        .iter()
898        .map(|stat| stat.as_str())
899        .collect();
900        let expected: std::collections::BTreeSet<&'static str> =
901            ["active", "allocated", "mapped", "resident"]
902                .into_iter()
903                .collect();
904        assert_eq!(image, expected);
905    }
906
907    /// `set_allocator_memory` forwards through a wired `MetricsHandle` and a
908    /// `CompositeMetricsCollector` (exactly one capture each); an unwired
909    /// handle neither panics nor records into a later-registered double.
910    #[test]
911    fn handle_and_composite_forward_set_allocator_memory() {
912        let expected = vec![(AllocatorStat::Resident, 4096)];
913
914        let handle_collector = Arc::new(RecordingMetrics::new());
915        let handle = MetricsHandle::new();
916        handle.register(handle_collector.clone());
917        handle.set_allocator_memory(AllocatorStat::Resident, 4096);
918        assert_eq!(
919            handle_collector
920                .allocator
921                .lock()
922                .expect("allocator lock")
923                .clone(),
924            expected,
925            "wired handle must forward exactly one allocator emission"
926        );
927
928        let composite_collector = Arc::new(RecordingMetrics::new());
929        let composite = CompositeMetricsCollector::new(vec![
930            composite_collector.clone() as Arc<dyn MetricsCollector>
931        ]);
932        composite.set_allocator_memory(AllocatorStat::Resident, 4096);
933        assert_eq!(
934            composite_collector
935                .allocator
936                .lock()
937                .expect("allocator lock")
938                .clone(),
939            expected,
940            "composite must forward exactly one allocator emission"
941        );
942
943        let bystander = Arc::new(RecordingMetrics::new());
944        let unwired = MetricsHandle::new();
945        unwired.set_allocator_memory(AllocatorStat::Resident, 4096);
946        unwired.register(bystander.clone());
947        assert!(
948            bystander
949                .allocator
950                .lock()
951                .expect("allocator lock")
952                .is_empty(),
953            "unwired-handle emissions are dropped, not replayed"
954        );
955    }
956}