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    /// Publish the leadership state for a master lock
136    /// (`camel_master_is_leader{lock}`, gauge): 1 while leadership is
137    /// held, 0 after it is lost. Emitted on the same observed state edges
138    /// as the `master_leadership_transitions_total` counter; the gauge
139    /// exists for steady-state readability ("who leads lock X now"), not
140    /// transition counting. Default: no-op (backward-compatible).
141    fn set_master_leadership(&self, _lock: &str, _leader: bool) {}
142}
143
144/// No-op metrics collector for default behavior
145pub struct NoOpMetrics;
146
147impl MetricsCollector for NoOpMetrics {
148    fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {}
149    fn increment_errors(&self, _route_id: &str, _error_type: &str) {}
150    fn increment_exchanges(&self, _route_id: &str) {}
151    fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
152    fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
153}
154
155/// Sized slot around `Arc<dyn MetricsCollector>`.
156///
157/// `ArcSwap`'s `RefCnt` implementation requires a `Sized` target, so a bare
158/// `ArcSwap<dyn MetricsCollector>` does not compile; this newtype restores
159/// `Sized`-ness without changing the stored pointee.
160struct CollectorSlot(Arc<dyn MetricsCollector>);
161
162/// A late-bound [`MetricsCollector`] cell.
163///
164/// Contract:
165///
166/// - **Late binding:** a `MetricsHandle` can be handed to consumers before any real
167///   collector exists; it seeds itself with [`NoOpMetrics`] so calls before (and
168///   without) registration are safe no-ops.
169/// - **Composition, not replacement:** each [`MetricsHandle::register`] composes the
170///   new collector *over* the currently stored one (see [`CompositeMetricsCollector`]);
171///   previously registered collectors keep observing.
172/// - **Same-Arc idempotence:** registering the same collector `Arc` twice is a no-op
173///   (detected via `Arc::ptr_eq` against the membership list), so a call site that
174///   wires the same collector through two builder paths does not double-count.
175/// - **Delegation cost:** each trait-method call costs one atomic load of the stored
176///   `Arc` (`ArcSwap::load`); the hot path never clones the `Arc`.
177pub struct MetricsHandle {
178    inner: ArcSwap<CollectorSlot>,
179    /// Membership list of every accepted collector, parallel to `inner`.
180    /// Kept because the stored `dyn` composite cannot be introspected for
181    /// `Arc::ptr_eq` dedupe.
182    members: Mutex<Vec<Arc<dyn MetricsCollector>>>,
183}
184
185impl MetricsHandle {
186    /// Creates a handle that delegates to [`NoOpMetrics`] until a collector is
187    /// registered.
188    pub fn new() -> Self {
189        Self {
190            inner: ArcSwap::from_pointee(CollectorSlot(Arc::new(NoOpMetrics))),
191            members: Mutex::new(Vec::new()),
192        }
193    }
194
195    /// Registers `collector`, composing it over whatever is currently stored.
196    ///
197    /// If the exact same `Arc` was already registered, this is a no-op
198    /// (see *same-Arc idempotence* in the type-level docs).
199    pub fn register(&self, collector: Arc<dyn MetricsCollector>) {
200        let mut members = self
201            .members
202            .lock()
203            .expect("metrics members lock poisoned by a panicked register"); // allow-unwrap
204        if members.iter().any(|m| Arc::ptr_eq(m, &collector)) {
205            return;
206        }
207        let first = members.is_empty();
208        members.push(Arc::clone(&collector));
209        if first {
210            // Store directly — composing over the seeded NoOp would leave a
211            // permanent dead leg in every later composite chain.
212            self.inner.store(Arc::new(CollectorSlot(collector)));
213            return;
214        }
215        let prev = Arc::clone(&self.inner.load().0);
216        self.inner.store(Arc::new(CollectorSlot(Arc::new(
217            CompositeMetricsCollector::new(vec![prev, collector]),
218        ))));
219    }
220}
221
222impl Default for MetricsHandle {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228impl MetricsCollector for MetricsHandle {
229    fn record_exchange_duration(&self, route_id: &str, duration: Duration) {
230        self.inner
231            .load()
232            .0
233            .record_exchange_duration(route_id, duration)
234    }
235
236    fn increment_errors(&self, route_id: &str, error_type: &str) {
237        self.inner.load().0.increment_errors(route_id, error_type)
238    }
239
240    fn increment_exchanges(&self, route_id: &str) {
241        self.inner.load().0.increment_exchanges(route_id)
242    }
243
244    fn set_queue_depth(&self, queue: &str, depth: usize) {
245        self.inner.load().0.set_queue_depth(queue, depth)
246    }
247
248    fn record_circuit_breaker_change(&self, route_id: &str, from: &str, to: &str) {
249        self.inner
250            .load()
251            .0
252            .record_circuit_breaker_change(route_id, from, to)
253    }
254
255    fn record_histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
256        self.inner.load().0.record_histogram(name, value, labels)
257    }
258
259    fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
260        self.inner.load().0.record_counter(name, value, labels)
261    }
262
263    fn increment_retry_attempt(&self, scheme: &str, operation: &str) {
264        self.inner
265            .load()
266            .0
267            .increment_retry_attempt(scheme, operation)
268    }
269
270    fn increment_circuit_breaker_rejection(&self, route: &str) {
271        self.inner
272            .load()
273            .0
274            .increment_circuit_breaker_rejection(route)
275    }
276
277    fn set_route_state(&self, route: &str, state: &str) {
278        self.inner.load().0.set_route_state(route, state)
279    }
280
281    fn clear_route_state(&self, route: &str) {
282        self.inner.load().0.clear_route_state(route)
283    }
284
285    fn record_build_info(&self, version: &str, git_sha: &str) {
286        self.inner.load().0.record_build_info(version, git_sha)
287    }
288
289    fn record_uptime(&self, seconds: f64) {
290        self.inner.load().0.record_uptime(seconds)
291    }
292
293    fn record_component_operation(&self, component: &str, operation: &str, outcome: &str) {
294        self.inner
295            .load()
296            .0
297            .record_component_operation(component, operation, outcome)
298    }
299
300    fn set_pinned_client_cache_size(&self, component: &str, entries: u64) {
301        self.inner
302            .load()
303            .0
304            .set_pinned_client_cache_size(component, entries)
305    }
306
307    fn increment_pinned_client_cache_hit(&self, component: &str) {
308        self.inner
309            .load()
310            .0
311            .increment_pinned_client_cache_hit(component)
312    }
313
314    fn increment_pinned_client_cache_miss(&self, component: &str) {
315        self.inner
316            .load()
317            .0
318            .increment_pinned_client_cache_miss(component)
319    }
320
321    fn set_allocator_memory(&self, stat: AllocatorStat, bytes: u64) {
322        self.inner.load().0.set_allocator_memory(stat, bytes)
323    }
324
325    fn set_master_leadership(&self, lock: &str, leader: bool) {
326        self.inner.load().0.set_master_leadership(lock, leader)
327    }
328}
329
330/// A [`MetricsCollector`] that fans every observation out to a list of collectors,
331/// in registration order.
332///
333/// Built by [`MetricsHandle::register`] — the second registration stores a
334/// composite of `[first, second]`; a third composes over that composite, so
335/// ordering and prior observation are preserved (composition, not replacement).
336///
337/// Internal type, hidden from the published docs. Out-of-tree code must not
338/// construct composites directly: registering an externally built composite
339/// plus its inner collector double-counts (the handle's opaque-Arc dedupe
340/// cannot see inside a composite). Register collectors via
341/// [`MetricsHandle::register`] instead.
342#[doc(hidden)]
343pub struct CompositeMetricsCollector {
344    collectors: Vec<Arc<dyn MetricsCollector>>,
345}
346
347impl CompositeMetricsCollector {
348    /// Creates a composite that delegates to `collectors` in order.
349    ///
350    /// Internal constructor, hidden from the published docs. Prefer
351    /// [`MetricsHandle::register`], which composes while deduplicating by
352    /// `Arc` pointer identity; direct construction bypasses that dedupe and
353    /// can double-count.
354    #[doc(hidden)]
355    pub fn new(collectors: Vec<Arc<dyn MetricsCollector>>) -> Self {
356        Self { collectors }
357    }
358}
359
360impl MetricsCollector for CompositeMetricsCollector {
361    fn record_exchange_duration(&self, route_id: &str, duration: Duration) {
362        for collector in &self.collectors {
363            collector.record_exchange_duration(route_id, duration);
364        }
365    }
366
367    fn increment_errors(&self, route_id: &str, error_type: &str) {
368        for collector in &self.collectors {
369            collector.increment_errors(route_id, error_type);
370        }
371    }
372
373    fn increment_exchanges(&self, route_id: &str) {
374        for collector in &self.collectors {
375            collector.increment_exchanges(route_id);
376        }
377    }
378
379    fn set_queue_depth(&self, queue: &str, depth: usize) {
380        for collector in &self.collectors {
381            collector.set_queue_depth(queue, depth);
382        }
383    }
384
385    fn record_circuit_breaker_change(&self, route_id: &str, from: &str, to: &str) {
386        for collector in &self.collectors {
387            collector.record_circuit_breaker_change(route_id, from, to);
388        }
389    }
390
391    fn record_histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
392        for collector in &self.collectors {
393            collector.record_histogram(name, value, labels);
394        }
395    }
396
397    fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
398        for collector in &self.collectors {
399            collector.record_counter(name, value, labels);
400        }
401    }
402
403    fn increment_retry_attempt(&self, scheme: &str, operation: &str) {
404        for collector in &self.collectors {
405            collector.increment_retry_attempt(scheme, operation);
406        }
407    }
408
409    fn increment_circuit_breaker_rejection(&self, route: &str) {
410        for collector in &self.collectors {
411            collector.increment_circuit_breaker_rejection(route);
412        }
413    }
414
415    fn set_route_state(&self, route: &str, state: &str) {
416        for collector in &self.collectors {
417            collector.set_route_state(route, state);
418        }
419    }
420
421    fn clear_route_state(&self, route: &str) {
422        for collector in &self.collectors {
423            collector.clear_route_state(route);
424        }
425    }
426
427    fn record_build_info(&self, version: &str, git_sha: &str) {
428        for collector in &self.collectors {
429            collector.record_build_info(version, git_sha);
430        }
431    }
432
433    fn record_uptime(&self, seconds: f64) {
434        for collector in &self.collectors {
435            collector.record_uptime(seconds);
436        }
437    }
438
439    fn record_component_operation(&self, component: &str, operation: &str, outcome: &str) {
440        for collector in &self.collectors {
441            collector.record_component_operation(component, operation, outcome);
442        }
443    }
444
445    fn set_pinned_client_cache_size(&self, component: &str, entries: u64) {
446        for collector in &self.collectors {
447            collector.set_pinned_client_cache_size(component, entries);
448        }
449    }
450
451    fn increment_pinned_client_cache_hit(&self, component: &str) {
452        for collector in &self.collectors {
453            collector.increment_pinned_client_cache_hit(component);
454        }
455    }
456
457    fn increment_pinned_client_cache_miss(&self, component: &str) {
458        for collector in &self.collectors {
459            collector.increment_pinned_client_cache_miss(component);
460        }
461    }
462
463    fn set_allocator_memory(&self, stat: AllocatorStat, bytes: u64) {
464        for collector in &self.collectors {
465            collector.set_allocator_memory(stat, bytes);
466        }
467    }
468
469    fn set_master_leadership(&self, lock: &str, leader: bool) {
470        for collector in &self.collectors {
471            collector.set_master_leadership(lock, leader);
472        }
473    }
474}
475
476#[cfg(test)]
477#[path = "metrics_tests.rs"]
478mod tests;