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::RwLock;
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 uncontended read-lock acquire that observes `None`.
115///
116/// # Why `RwLock<Option<…>>` and not `OnceLock<…>`
117///
118/// Plugins are typically declared as `pub(crate) static METRICS:
119/// MetricRecorder = MetricRecorder::new();` — a process-wide singleton
120/// living in the `.so`'s BSS. The hub keeps the `.so` mapped across
121/// plugin reloads via `libloading::Library` refcounting, so the static
122/// **survives** every reload and accumulates state across plugin
123/// instances. With an internal `OnceLock`, `install()` was silently
124/// no-op on the second-and-later plugin loads: the NEW plugin's hub
125/// `ctx` pointer never replaced the OLD one. When the hub then dropped
126/// the OLD plugin's `Box<MetricCtx>` (after the OLD plugin's last
127/// in-flight `process()` call retired), the stale OLD pointer in
128/// `MetricRecorder` became a use-after-free; the next `counter()` /
129/// `gauge()` / `histogram()` from any plugin instance — old or new —
130/// dereferenced freed memory and segfaulted the hub process. The
131/// haptic conformance suite reproduces this on every conformance shard
132/// run because its `reconciliationDebounceInterval=10ms` triggers
133/// rapid back-to-back `HAProxy` general-storage writes, each of which
134/// the hub's file-watcher turns into a plugin reload.
135///
136/// `RwLock` lets `install()` actually REPLACE the inner on every call,
137/// which is what plugin reload requires. Read paths (`emit`) take the
138/// read lock for the duration of the recorder callback, holding the
139/// pointer valid against a racing `install()` from a concurrent
140/// reload. The hub's lifetime contract — "ctx is valid until the
141/// plugin instance that received it is destroyed" — combined with the
142/// hub's drop ordering (`Plugin::drop` calls `destroy()` BEFORE
143/// dropping `Box<MetricCtx>`) means a NEW install always lands BEFORE
144/// the corresponding OLD `Box` is freed, so once `emit` reads NEW
145/// inner under its read lock, the ctx it observes is the NEW one and
146/// stays valid for the call.
147pub struct MetricRecorder {
148    inner: RwLock<Option<RecorderInner>>,
149}
150
151struct RecorderInner {
152    record_fn: RecordMetricFn,
153    ctx: *const c_void,
154}
155
156// SAFETY: `RecorderInner` holds a `*const c_void` pointing into hub-
157// owned memory. The hub guarantees that pointer remains valid (and
158// addressable from any thread) for the entire lifetime of the plugin
159// instance, and that the recorder function itself is reentrant /
160// thread-safe (it routes into the `metrics` crate, which is). The
161// plugin treats both fields as read-only after installation. With
162// those guarantees, sending and sharing the inner across threads is
163// safe.
164unsafe impl Send for RecorderInner {}
165unsafe impl Sync for RecorderInner {}
166
167impl MetricRecorder {
168    /// Construct an uninitialised recorder. Plugins embed this in their
169    /// state; the `define_plugin!` macro fills it in when the hub calls
170    /// `set_metric_recorder`.
171    #[must_use]
172    pub const fn new() -> Self {
173        Self {
174            inner: RwLock::new(None),
175        }
176    }
177
178    /// Install the hub-provided recorder. REPLACES any previously
179    /// installed recorder — this is intentional and load-bearing: when
180    /// the hub reloads plugins, the NEW plugin's `set_metric_recorder`
181    /// thunk calls this with a fresh `ctx` pointer, and the OLD `ctx`
182    /// (which the hub is about to free) must be evicted from this
183    /// recorder. See the struct-level docs for the use-after-free that
184    /// the previous OnceLock-based no-op-on-second-set behaviour
185    /// produced. The `define_plugin!` macro's `set_metric_recorder`
186    /// thunk calls this.
187    ///
188    /// On lock poisoning we fall back to recovering the inner via
189    /// `into_inner()` — install is rare and a poisoned lock would
190    /// otherwise leak a dangling pointer; the hub's panic-catching
191    /// thunks make poisoning unlikely in the first place.
192    #[doc(hidden)]
193    pub fn install(&self, record_fn: RecordMetricFn, ctx: *const c_void) {
194        let new = RecorderInner { record_fn, ctx };
195        match self.inner.write() {
196            Ok(mut guard) => *guard = Some(new),
197            Err(poisoned) => *poisoned.into_inner() = Some(new),
198        }
199    }
200
201    /// Increment a counter. `increment` is typically `1`. Names are
202    /// hub-namespaced — emitting `"dispatches_total"` from the mirror
203    /// plugin lands as `plugin_mirror_dispatches_total` in Prometheus.
204    pub fn counter(&self, name: &str, labels: &[(&str, &str)], increment: u64) {
205        #[allow(clippy::cast_precision_loss)]
206        self.emit(name, labels, increment as f64, MetricKind::Counter);
207    }
208
209    /// Set a gauge. Replaces the previous value for this `(name, labels)`
210    /// tuple in the registry.
211    pub fn gauge(&self, name: &str, labels: &[(&str, &str)], value: f64) {
212        self.emit(name, labels, value, MetricKind::Gauge);
213    }
214
215    /// Record a histogram observation. Bucket boundaries are governed
216    /// by the hub's Prometheus exporter config; plugins that need
217    /// custom buckets coordinate with the hub at deploy time.
218    pub fn histogram(&self, name: &str, labels: &[(&str, &str)], value: f64) {
219        self.emit(name, labels, value, MetricKind::Histogram);
220    }
221
222    fn emit(&self, name: &str, labels: &[(&str, &str)], value: f64, kind: MetricKind) {
223        // Read-lock guard kept alive across the callback invocation:
224        // a racing reload's `install()` blocks on the write lock until
225        // we release, so the `ctx` we hand to `record_fn` cannot be
226        // freed mid-call by the hub dropping its `Box<MetricCtx>`
227        // (which only happens AFTER the OLD plugin's `process` calls
228        // have all retired — see `Plugin::drop` in the hub).
229        let guard = match self.inner.read() {
230            Ok(g) => g,
231            // Poisoned read recovers by reading the inner anyway; we
232            // can't propagate the panic from a method that's called
233            // from arbitrary metric-emission sites in plugin code.
234            Err(poisoned) => poisoned.into_inner(),
235        };
236        let Some(ref inner) = *guard else {
237            // No recorder installed — running against a v1 hub, or
238            // emit fired before install (between create and
239            // set_metric_recorder). Silently drop; no panic, no log
240            // spam in the hot path.
241            return;
242        };
243        // Translate the borrowed Rust slices into the FFI shape. The
244        // RStr / RSlice constructors are zero-copy borrows.
245        // Allocating a small Vec for labels is unavoidable since the
246        // hub callback wants RSlice<MetricLabel> — but the hot path
247        // (the recorder callback itself) avoids any extra copies.
248        let ffi_labels: Vec<MetricLabel<'_>> = labels
249            .iter()
250            .map(|(k, v)| (RStr::from(*k), RStr::from(*v)))
251            .collect();
252        (inner.record_fn)(
253            inner.ctx,
254            RStr::from(name),
255            RSlice::from(ffi_labels.as_slice()),
256            value,
257            kind,
258        );
259    }
260}
261
262impl Default for MetricRecorder {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268impl std::fmt::Debug for MetricRecorder {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        // `try_read` so a Debug print never deadlocks on a writer
271        // (install is rare; treat the contended case as "unknown").
272        let installed = match self.inner.try_read() {
273            Ok(g) => Some(g.is_some()),
274            Err(_) => None,
275        };
276        f.debug_struct("MetricRecorder")
277            .field("installed", &installed)
278            .finish()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::ptr;
286    use std::sync::atomic::{AtomicU64, Ordering};
287
288    static EMITTED_COUNT: AtomicU64 = AtomicU64::new(0);
289
290    extern "C" fn test_recorder(
291        _ctx: *const c_void,
292        _name: RStr<'_>,
293        _labels: RSlice<'_, MetricLabel<'_>>,
294        _value: f64,
295        _kind: MetricKind,
296    ) {
297        EMITTED_COUNT.fetch_add(1, Ordering::Relaxed);
298    }
299
300    #[test]
301    fn emit_without_install_is_silent_noop() {
302        let r = MetricRecorder::new();
303        // Should not panic, should not invoke any recorder.
304        r.counter("test", &[], 1);
305        r.gauge("test", &[], 1.0);
306        r.histogram("test", &[], 1.0);
307        // (No external observer here — the actual test is "no panic".)
308    }
309
310    #[test]
311    fn install_then_emit_routes_to_recorder() {
312        let before = EMITTED_COUNT.load(Ordering::Relaxed);
313        let r = MetricRecorder::new();
314        r.install(test_recorder, ptr::null());
315        r.counter("dispatches_total", &[("kind", "ok")], 1);
316        r.gauge("in_flight", &[], 7.0);
317        r.histogram("latency_seconds", &[("path", "/x")], 0.012);
318        assert_eq!(EMITTED_COUNT.load(Ordering::Relaxed) - before, 3);
319    }
320
321    #[test]
322    fn install_replaces_previous_recorder() {
323        // Was previously asserting OnceLock-style "second install is
324        // no-op". That contract caused a use-after-free on plugin
325        // reload: the hub creates a fresh `Box<MetricCtx>` for the
326        // NEW plugin, but the static `MetricRecorder` in the plugin
327        // .so kept the OLD ctx pointer; when the hub later dropped
328        // the OLD Box (after the OLD plugin's last in-flight call
329        // retired), the NEXT metric emission dereferenced freed
330        // memory. The contract is now: install REPLACES.
331        static EMITTED_VIA_CTX: [AtomicU64; 2] = [AtomicU64::new(0), AtomicU64::new(0)];
332
333        extern "C" fn recorder_route_by_ctx(
334            ctx: *const c_void,
335            _name: RStr<'_>,
336            _labels: RSlice<'_, MetricLabel<'_>>,
337            _value: f64,
338            _kind: MetricKind,
339        ) {
340            let idx = ctx as usize;
341            if idx < EMITTED_VIA_CTX.len() {
342                EMITTED_VIA_CTX[idx].fetch_add(1, Ordering::Relaxed);
343            }
344        }
345
346        let r = MetricRecorder::new();
347        r.install(recorder_route_by_ctx, std::ptr::null::<c_void>());
348        r.counter("c1", &[], 1);
349        assert_eq!(EMITTED_VIA_CTX[0].load(Ordering::Relaxed), 1);
350        assert_eq!(EMITTED_VIA_CTX[1].load(Ordering::Relaxed), 0);
351
352        // Second install MUST replace — this is the reload case.
353        r.install(recorder_route_by_ctx, std::ptr::dangling::<c_void>());
354        r.counter("c2", &[], 1);
355        // The NEW ctx received the new emission, NOT the old ctx.
356        assert_eq!(EMITTED_VIA_CTX[0].load(Ordering::Relaxed), 1);
357        assert_eq!(EMITTED_VIA_CTX[1].load(Ordering::Relaxed), 1);
358    }
359
360    /// Concurrent emit while a racing install replaces the recorder.
361    /// Models the hub's "NEW plugin's `set_metric_recorder` thunk runs
362    /// while OLD plugin's `process()` emissions are in flight" race.
363    /// The read-lock in `emit()` must hold the OLD inner valid for
364    /// the duration of the in-flight callback; the install must not
365    /// be observed as a torn write.
366    #[test]
367    fn install_during_concurrent_emit_does_not_tear() {
368        use std::sync::Arc;
369        use std::thread;
370        use std::time::Duration;
371
372        static OK_CALLS: AtomicU64 = AtomicU64::new(0);
373
374        extern "C" fn ok_recorder(
375            _ctx: *const c_void,
376            _name: RStr<'_>,
377            _labels: RSlice<'_, MetricLabel<'_>>,
378            _value: f64,
379            _kind: MetricKind,
380        ) {
381            OK_CALLS.fetch_add(1, Ordering::Relaxed);
382        }
383
384        let r = Arc::new(MetricRecorder::new());
385        r.install(ok_recorder, ptr::null());
386
387        let emitter = {
388            let r = Arc::clone(&r);
389            thread::spawn(move || {
390                for _ in 0..10_000 {
391                    r.counter("dispatches_total", &[], 1);
392                }
393            })
394        };
395        let installer = {
396            let r = Arc::clone(&r);
397            thread::spawn(move || {
398                for i in 0..1_000 {
399                    r.install(ok_recorder, i as *const c_void);
400                    thread::sleep(Duration::from_micros(10));
401                }
402            })
403        };
404
405        emitter.join().unwrap();
406        installer.join().unwrap();
407        // All 10_000 emissions must have landed (no panic, no torn
408        // write that the read side rejected). The exact count is
409        // observable via OK_CALLS, but the load-bearing assertion is
410        // "no segfault / no panic during the race".
411        assert_eq!(OK_CALLS.load(Ordering::Relaxed), 10_000);
412    }
413}