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}