camel_api/component_metrics.rs
1//! `ComponentMetrics` — lever-gated facade for the uniform
2//! component-operations family (dashboard-observability Task 4.1).
3//!
4//! Components call [`ComponentMetrics::observe`] at their principal
5//! operation boundary. The facade owns two concerns so individual
6//! components do not:
7//!
8//! - **Lever gating:** the `[observability.metrics].components` lever
9//! (default off, metrics-configuration Req 3) suppresses only the
10//! `camel_component_operations_total` family.
11//! - **Unconditional error forwarding:** failures always increment the
12//! non-disableable error family (`camel_errors_total`) as
13//! `increment_errors(component, "e:{component}:{operation}")` — never
14//! lever-gated (metrics-configuration Req 2).
15//!
16//! The lever arrives as a plain `bool` because `camel-api` cannot depend
17//! on `camel-core`, where `MetricsLeversConfig` lives: the construction
18//! site (camel-core, via the `RuntimeObservability` blanket impl)
19//! snapshots the current levers into the facade at build time.
20
21use std::sync::Arc;
22
23use crate::metrics::MetricsCollector;
24
25/// Facade over a [`MetricsCollector`] for uniform component-operation
26/// emission. Construct via `RuntimeObservability::component_metrics()`
27/// or directly in tests.
28///
29/// Cheap to clone: the collector sits behind an `Arc`, so one facade can
30/// be shared across objects built from the same connection (e.g. a
31/// repository and its payload store).
32#[derive(Clone)]
33pub struct ComponentMetrics {
34 collector: Arc<dyn MetricsCollector>,
35 components_enabled: bool,
36}
37
38impl ComponentMetrics {
39 /// Builds a facade over `collector`; `components_enabled` is the
40 /// snapshot of the `[observability.metrics].components` lever taken
41 /// at construction time.
42 pub fn new(collector: Arc<dyn MetricsCollector>, components_enabled: bool) -> Self {
43 Self {
44 collector,
45 components_enabled,
46 }
47 }
48
49 /// Observes one component operation. `failed` selects the closed-set
50 /// outcome label ("failure"/"success") and, when true, unconditionally
51 /// forwards to the error family with the `e:{component}:{operation}`
52 /// label — error-family emission is never lever-gated.
53 pub fn observe(&self, component: &str, operation: &str, failed: bool) {
54 if failed {
55 // allow-open-label rc-otxh (facade builds the e:{component}:{operation} label per ADR-0012; names bounded at observe() call sites)
56 self.collector
57 .increment_errors(component, &format!("e:{component}:{operation}"));
58 }
59 if self.components_enabled {
60 // allow-open-label rc-gm6s (component/operation: caller-bounded literals through the facade; outcome is a two-literal if/else)
61 self.collector.record_component_operation(
62 component,
63 operation,
64 if failed { "failure" } else { "success" },
65 );
66 }
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use crate::metrics::MetricsCollector;
74 use std::sync::{Arc, Mutex};
75 use std::time::Duration;
76
77 /// Recording double capturing error-family and component-op emissions.
78 struct RecordingComponentMetrics {
79 errors: Mutex<Vec<(String, String)>>,
80 ops: Mutex<Vec<(String, String, String)>>,
81 }
82
83 impl RecordingComponentMetrics {
84 fn new() -> Self {
85 Self {
86 errors: Mutex::new(Vec::new()),
87 ops: Mutex::new(Vec::new()),
88 }
89 }
90 }
91
92 impl MetricsCollector for RecordingComponentMetrics {
93 fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {}
94 fn increment_errors(&self, route_id: &str, error_type: &str) {
95 self.errors
96 .lock()
97 .expect("errors lock")
98 .push((route_id.to_string(), error_type.to_string()));
99 }
100 fn increment_exchanges(&self, _route_id: &str) {}
101 fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
102 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
103 fn record_component_operation(&self, component: &str, operation: &str, outcome: &str) {
104 self.ops.lock().expect("ops lock").push((
105 component.to_string(),
106 operation.to_string(),
107 outcome.to_string(),
108 ));
109 }
110 }
111
112 /// Task 4.1: the components lever gates ONLY the component-operations
113 /// family; error-family forwarding is unconditional.
114 #[test]
115 fn facade_gates_components_not_errors() {
116 // Lever OFF: a failed observe forwards to the error family and
117 // records no component-op.
118 let off_collector = Arc::new(RecordingComponentMetrics::new());
119 let off = ComponentMetrics::new(
120 Arc::clone(&off_collector) as Arc<dyn MetricsCollector>,
121 false,
122 );
123 off.observe("redis", "command", true);
124 assert_eq!(
125 off_collector.errors.lock().expect("errors lock").clone(),
126 vec![("redis".to_string(), "e:redis:command".to_string())],
127 "failure must hit the error family with the lever off"
128 );
129 assert!(
130 off_collector.ops.lock().expect("ops lock").is_empty(),
131 "component ops must be suppressed with the lever off"
132 );
133
134 // Lever ON: both outcomes recorded; the failure ALSO increments
135 // errors (error family is never lever-gated).
136 let on_collector = Arc::new(RecordingComponentMetrics::new());
137 let on =
138 ComponentMetrics::new(Arc::clone(&on_collector) as Arc<dyn MetricsCollector>, true);
139 on.observe("redis", "command", false);
140 on.observe("redis", "command", true);
141 assert_eq!(
142 on_collector.ops.lock().expect("ops lock").clone(),
143 vec![
144 (
145 "redis".to_string(),
146 "command".to_string(),
147 "success".to_string()
148 ),
149 (
150 "redis".to_string(),
151 "command".to_string(),
152 "failure".to_string()
153 ),
154 ],
155 "lever on must record both outcomes"
156 );
157 assert_eq!(
158 on_collector.errors.lock().expect("errors lock").clone(),
159 vec![("redis".to_string(), "e:redis:command".to_string())],
160 "failure must still increment errors with the lever on"
161 );
162 }
163}