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}