Skip to main content

haproxy_spoa_hub_plugin_api/
metrics.rs

1//! FFI-safe metric types and the recorder helper plugins use to emit
2//! Prometheus metrics through the hub.
3//!
4//! # Design
5//!
6//! Plugins don't talk to a Prometheus exporter directly — the hub owns
7//! the `/metrics` endpoint and the `metrics` crate registry. Instead the
8//! hub hands each plugin a `RecordMetricFn` callback at init time, and
9//! the plugin invokes it whenever it wants to emit a counter increment,
10//! gauge set, or histogram observation. The callback routes into the
11//! hub's existing `metrics::{counter, gauge, histogram}` macros,
12//! prefixing the metric name with `plugin_<plugin>_` so plugin metrics
13//! are namespaced (matches the hub's own `spoa_*` and `spoa_hub_*`
14//! prefix discipline).
15//!
16//! ## Why push, not pull
17//!
18//! A pull-based design (hub asks each plugin "what are your current
19//! metric values?" on every Prometheus scrape) would require plugins to
20//! re-aggregate histograms into Prometheus bucket counts on each scrape
21//! and serialise them across the FFI boundary every time. The push
22//! design lets each event go straight into the hub's existing recorder
23//! at the moment it happens — same code path the hub's own metrics use,
24//! with native histogram observation support and no per-scrape FFI
25//! traffic.
26//!
27//! ## Lifetime contract
28//!
29//! The recorder callback and its `ctx` pointer remain valid from the
30//! moment the hub calls `set_metric_recorder` on a plugin until that
31//! plugin's `destroy` returns. Plugins MUST NOT call the recorder
32//! after returning from `destroy`. The hub MUST NOT free the ctx until
33//! it has called `destroy` on every plugin that received it.
34
35use std::os::raw::c_void;
36use std::sync::OnceLock;
37
38use abi_stable::std_types::{RSlice, RStr};
39
40/// Kind of metric being recorded. The hub dispatches each call into the
41/// matching `metrics` crate primitive (`counter!` / `gauge!` /
42/// `histogram!`). `#[repr(u8)]` pins the discriminant so plugins built
43/// against any version of this crate agree on the wire encoding.
44#[repr(u8)]
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum MetricKind {
47    /// Monotonically-increasing counter. `value` is the increment
48    /// (commonly `1.0`, but plugins recording batch counts may pass
49    /// larger values). The hub casts via `value as u64` — fractional
50    /// values are truncated.
51    Counter = 0,
52    /// Point-in-time gauge. `value` replaces the previous value for
53    /// the matching `(name, labels)` tuple.
54    Gauge = 1,
55    /// Histogram observation. `value` is the observed quantity (e.g.
56    /// a latency in seconds). The hub's exporter is configured with
57    /// default bucket boundaries; operators that need custom buckets
58    /// for a specific plugin metric can declare them via the hub's
59    /// `PrometheusBuilder::set_buckets_for_metric` config (see the
60    /// hub's `init_metrics` for the existing `spoa_message_duration`
61    /// bucket override).
62    Histogram = 2,
63}
64
65/// One label pair `(key, value)` on a metric sample. Borrowed from the
66/// caller; the recorder copies into the hub's registry before returning.
67pub type MetricLabel<'a> = (RStr<'a>, RStr<'a>);
68
69/// FFI signature the hub provides to each plugin via
70/// [`PluginVTable::set_metric_recorder`].
71///
72/// `ctx` is an opaque hub-owned pointer; the plugin MUST pass it back
73/// verbatim on every record call. The hub uses it to look up the
74/// per-plugin metric namespace.
75///
76/// `name` is the metric short name (without the `plugin_<plugin>_`
77/// prefix the hub adds). Stable identifiers only — names must not
78/// embed dynamic data; put that in labels.
79///
80/// [`PluginVTable::set_metric_recorder`]: crate::PluginVTable::set_metric_recorder
81pub type RecordMetricFn = extern "C" fn(
82    ctx: *const c_void,
83    name: RStr<'_>,
84    labels: RSlice<'_, MetricLabel<'_>>,
85    value: f64,
86    kind: MetricKind,
87);
88
89/// Ergonomic plugin-side helper that wraps the FFI recorder.
90///
91/// Plugins embed a `MetricRecorder` in their state, the
92/// `define_plugin!` macro wires it via the `set_metric_recorder`
93/// vtable entry, and plugin code calls the typed `counter` / `gauge` /
94/// `histogram` methods from anywhere with `&self`.
95///
96/// ```rust,ignore
97/// pub struct MyPlugin {
98///     metrics: MetricRecorder,
99///     // ...other state
100/// }
101///
102/// impl MyPlugin {
103///     fn process(&self, msg: &SpoeMessage) -> Result<ProcessingResult, _> {
104///         self.metrics.counter("dispatches_total", &[("kind", "ok")], 1);
105///         self.metrics.histogram("latency_seconds", &[], 0.012);
106///         // ...
107///     }
108/// }
109/// ```
110///
111/// When the recorder is not yet installed (between `create` and the
112/// hub's `set_metric_recorder` call) or the host is running a v1 hub
113/// that never installs one, the methods are inexpensive no-ops — a
114/// single relaxed pointer load that observes `None`.
115pub struct MetricRecorder {
116    inner: OnceLock<RecorderInner>,
117}
118
119struct RecorderInner {
120    record_fn: RecordMetricFn,
121    ctx: *const c_void,
122}
123
124// SAFETY: `RecorderInner` holds a `*const c_void` pointing into hub-
125// owned memory. The hub guarantees that pointer remains valid (and
126// addressable from any thread) for the entire lifetime of the plugin
127// instance, and that the recorder function itself is reentrant /
128// thread-safe (it routes into the `metrics` crate, which is). The
129// plugin treats both fields as read-only after installation. With
130// those guarantees, sending and sharing the inner across threads is
131// safe.
132unsafe impl Send for RecorderInner {}
133unsafe impl Sync for RecorderInner {}
134
135impl MetricRecorder {
136    /// Construct an uninitialised recorder. Plugins embed this in their
137    /// state; the `define_plugin!` macro fills it in when the hub calls
138    /// `set_metric_recorder`.
139    #[must_use]
140    pub const fn new() -> Self {
141        Self {
142            inner: OnceLock::new(),
143        }
144    }
145
146    /// Install the hub-provided recorder. Idempotent; subsequent calls
147    /// are no-ops (the OnceLock retains the first installation). The
148    /// `define_plugin!` macro's `set_metric_recorder` thunk calls this.
149    #[doc(hidden)]
150    pub fn install(&self, record_fn: RecordMetricFn, ctx: *const c_void) {
151        let _ = self.inner.set(RecorderInner { record_fn, ctx });
152    }
153
154    /// Increment a counter. `increment` is typically `1`. Names are
155    /// hub-namespaced — emitting `"dispatches_total"` from the mirror
156    /// plugin lands as `plugin_mirror_dispatches_total` in Prometheus.
157    pub fn counter(&self, name: &str, labels: &[(&str, &str)], increment: u64) {
158        #[allow(clippy::cast_precision_loss)]
159        self.emit(name, labels, increment as f64, MetricKind::Counter);
160    }
161
162    /// Set a gauge. Replaces the previous value for this `(name, labels)`
163    /// tuple in the registry.
164    pub fn gauge(&self, name: &str, labels: &[(&str, &str)], value: f64) {
165        self.emit(name, labels, value, MetricKind::Gauge);
166    }
167
168    /// Record a histogram observation. Bucket boundaries are governed
169    /// by the hub's Prometheus exporter config; plugins that need
170    /// custom buckets coordinate with the hub at deploy time.
171    pub fn histogram(&self, name: &str, labels: &[(&str, &str)], value: f64) {
172        self.emit(name, labels, value, MetricKind::Histogram);
173    }
174
175    fn emit(&self, name: &str, labels: &[(&str, &str)], value: f64, kind: MetricKind) {
176        let Some(inner) = self.inner.get() else {
177            // No recorder installed — running against a v1 hub, or
178            // emit fired before install (between create and
179            // set_metric_recorder). Silently drop; no panic, no log
180            // spam in the hot path.
181            return;
182        };
183        // Translate the borrowed Rust slices into the FFI shape. The
184        // RStr / RSlice constructors are zero-copy borrows.
185        // Allocating a small Vec for labels is unavoidable since the
186        // hub callback wants RSlice<MetricLabel> — but the hot path
187        // (the recorder callback itself) avoids any extra copies.
188        let ffi_labels: Vec<MetricLabel<'_>> = labels
189            .iter()
190            .map(|(k, v)| (RStr::from(*k), RStr::from(*v)))
191            .collect();
192        (inner.record_fn)(
193            inner.ctx,
194            RStr::from(name),
195            RSlice::from(ffi_labels.as_slice()),
196            value,
197            kind,
198        );
199    }
200}
201
202impl Default for MetricRecorder {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208impl std::fmt::Debug for MetricRecorder {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        f.debug_struct("MetricRecorder")
211            .field("installed", &self.inner.get().is_some())
212            .finish()
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::ptr;
220    use std::sync::atomic::{AtomicU64, Ordering};
221
222    static EMITTED_COUNT: AtomicU64 = AtomicU64::new(0);
223
224    extern "C" fn test_recorder(
225        _ctx: *const c_void,
226        _name: RStr<'_>,
227        _labels: RSlice<'_, MetricLabel<'_>>,
228        _value: f64,
229        _kind: MetricKind,
230    ) {
231        EMITTED_COUNT.fetch_add(1, Ordering::Relaxed);
232    }
233
234    #[test]
235    fn emit_without_install_is_silent_noop() {
236        let r = MetricRecorder::new();
237        // Should not panic, should not invoke any recorder.
238        r.counter("test", &[], 1);
239        r.gauge("test", &[], 1.0);
240        r.histogram("test", &[], 1.0);
241        // (No external observer here — the actual test is "no panic".)
242    }
243
244    #[test]
245    fn install_then_emit_routes_to_recorder() {
246        let before = EMITTED_COUNT.load(Ordering::Relaxed);
247        let r = MetricRecorder::new();
248        r.install(test_recorder, ptr::null());
249        r.counter("dispatches_total", &[("kind", "ok")], 1);
250        r.gauge("in_flight", &[], 7.0);
251        r.histogram("latency_seconds", &[("path", "/x")], 0.012);
252        assert_eq!(EMITTED_COUNT.load(Ordering::Relaxed) - before, 3);
253    }
254
255    #[test]
256    fn install_is_idempotent() {
257        let r = MetricRecorder::new();
258        r.install(test_recorder, ptr::null());
259        // Second install should be a no-op — OnceLock retains the
260        // first value. The plugin's `process()` would otherwise see
261        // mid-flight churn if the hub re-installs on config reload.
262        r.install(test_recorder, 0xdead_beef as *const c_void);
263        // No assertion needed beyond "no panic"; this would have
264        // failed if the OnceLock's set() panicked on the second call.
265    }
266}