Skip to main content

reth_node_metrics/
hooks.rs

1use metrics_process::Collector;
2use parking_lot::Mutex;
3use reth_tasks::TaskExecutor;
4use std::{
5    fmt,
6    panic::{catch_unwind, AssertUnwindSafe},
7    sync::Arc,
8    time::{Duration, Instant},
9};
10
11/// The simple alias for function types that are `'static`, `Send`, and `Sync`.
12pub trait Hook: Fn() + Send + Sync + 'static {}
13impl<T: 'static + Fn() + Send + Sync> Hook for T {}
14
15/// A builder-like type to create a new [`Hooks`] instance.
16pub struct HooksBuilder {
17    hooks: Vec<Box<dyn Hook<Output = ()>>>,
18    background_hooks: Vec<Box<dyn Hook<Output = ()>>>,
19    background_interval: Duration,
20}
21
22impl HooksBuilder {
23    /// Default interval at which background hooks are refreshed.
24    pub const DEFAULT_BACKGROUND_INTERVAL: Duration = Duration::from_secs(5 * 60);
25
26    /// Registers a [`Hook`] that runs while metrics are collected.
27    ///
28    /// Only suitable for cheap collection; anything that can take longer than a scrape timeout
29    /// belongs in [`with_background_hook`](Self::with_background_hook).
30    pub fn with_hook(self, hook: impl Hook) -> Self {
31        self.with_boxed_hook(Box::new(hook))
32    }
33
34    /// Registers a [`Hook`] by calling the provided closure.
35    pub fn install_hook<F, H>(self, f: F) -> Self
36    where
37        F: FnOnce() -> H,
38        H: Hook,
39    {
40        self.with_hook(f())
41    }
42
43    /// Registers a [`Hook`].
44    #[inline]
45    pub fn with_boxed_hook(mut self, hook: Box<dyn Hook<Output = ()>>) -> Self {
46        self.hooks.push(hook);
47        self
48    }
49
50    /// Registers a [`Hook`] whose collection is refreshed out of band: metrics collection kicks it
51    /// off, at most once per [`background_interval`](Self::with_background_interval) and never
52    /// while a previous refresh is still running, but never waits for it.
53    ///
54    /// Collection that walks a backing store (e.g. every static file jar) scales with the dataset
55    /// and can take seconds, which would stall a scrape for its entire duration. Such hooks only
56    /// set gauges, and the interval already means most scrapes render values collected by an
57    /// earlier one, so serving the previous values while the refresh runs costs at most one scrape
58    /// worth of freshness.
59    ///
60    /// Background hooks are collected sequentially in registration order. A hook that panics is
61    /// logged and does not prevent the hooks registered after it from being collected.
62    pub fn with_background_hook(mut self, hook: impl Hook) -> Self {
63        self.background_hooks.push(Box::new(hook));
64        self
65    }
66
67    /// Sets the minimum interval between the starts of two background collections.
68    pub const fn with_background_interval(mut self, interval: Duration) -> Self {
69        self.background_interval = interval;
70        self
71    }
72
73    /// Builds the [`Hooks`] collection from the registered hooks.
74    pub fn build(self) -> Hooks {
75        Hooks {
76            inner: Arc::new(self.hooks),
77            background: Arc::new(BackgroundHooks {
78                hooks: self.background_hooks,
79                interval: self.background_interval,
80                state: Mutex::default(),
81            }),
82        }
83    }
84}
85
86impl Default for HooksBuilder {
87    fn default() -> Self {
88        Self {
89            hooks: vec![
90                Box::new(|| Collector::default().collect()),
91                Box::new(collect_memory_stats),
92                Box::new(collect_io_stats),
93            ],
94            background_hooks: Vec::new(),
95            background_interval: Self::DEFAULT_BACKGROUND_INTERVAL,
96        }
97    }
98}
99
100impl std::fmt::Debug for HooksBuilder {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("HooksBuilder")
103            .field("hooks", &format_args!("Vec<Box<dyn Hook>>, len: {}", self.hooks.len()))
104            .field("background_hooks", &self.background_hooks.len())
105            .field("background_interval", &self.background_interval)
106            .finish()
107    }
108}
109
110/// Helper type for managing hooks
111#[derive(Clone)]
112pub struct Hooks {
113    inner: Arc<Vec<Box<dyn Hook<Output = ()>>>>,
114    background: Arc<BackgroundHooks>,
115}
116
117impl Hooks {
118    /// Creates a new [`HooksBuilder`] instance.
119    #[inline]
120    pub fn builder() -> HooksBuilder {
121        HooksBuilder::default()
122    }
123
124    pub(crate) fn iter(&self) -> impl Iterator<Item = &Box<dyn Hook<Output = ()>>> {
125        self.inner.iter()
126    }
127
128    /// Refreshes the background hooks unless a refresh is already in flight or the previous one
129    /// started less than [`with_background_interval`](HooksBuilder::with_background_interval) ago.
130    ///
131    /// Returns without waiting for the collection to finish: the caller renders the values of the
132    /// previous refresh.
133    pub(crate) fn refresh_background(&self, executor: &TaskExecutor) {
134        if !self.background.claim() {
135            return
136        }
137
138        let background = self.background.clone();
139        executor.spawn_blocking(move || background.collect());
140    }
141}
142
143impl fmt::Debug for Hooks {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        let hooks_len = self.inner.len();
146        f.debug_struct("Hooks")
147            .field("inner", &format_args!("Arc<Vec<Box<dyn Hook>>>, len: {hooks_len}"))
148            .field("background", &self.background)
149            .finish()
150    }
151}
152
153/// The [`Hook`]s that are collected out of band, see
154/// [`with_background_hook`](HooksBuilder::with_background_hook).
155struct BackgroundHooks {
156    hooks: Vec<Box<dyn Hook<Output = ()>>>,
157    interval: Duration,
158    state: Mutex<BackgroundHooksState>,
159}
160
161impl BackgroundHooks {
162    /// Marks a collection as started if none is in flight and the previous one started at least
163    /// `interval` ago, so that a concurrent caller can not start a second one.
164    ///
165    /// The interval is claimed upfront, while the in-flight flag is released once
166    /// [`collect`](Self::collect) returns or unwinds, so a panicking hook delays the next
167    /// collection by one interval instead of blocking it forever.
168    fn claim(&self) -> bool {
169        if self.hooks.is_empty() {
170            return false
171        }
172
173        let mut state = self.state.lock();
174        if state.in_flight ||
175            state.last_collected.is_some_and(|last| last.elapsed() < self.interval)
176        {
177            return false
178        }
179        state.last_collected = Some(Instant::now());
180        state.in_flight = true;
181
182        true
183    }
184
185    /// Collects a claimed refresh and releases the in-flight flag afterwards.
186    ///
187    /// Each hook is collected on its own so that one that panics is logged instead of skipping the
188    /// hooks registered after it.
189    fn collect(&self) {
190        let _in_flight = InFlightGuard(&self.state);
191        for (idx, hook) in self.hooks.iter().enumerate() {
192            if catch_unwind(AssertUnwindSafe(hook)).is_err() {
193                tracing::error!(hook = idx, "Background metrics hook panicked");
194            }
195        }
196    }
197}
198
199impl fmt::Debug for BackgroundHooks {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        f.debug_struct("BackgroundHooks")
202            .field("hooks", &format_args!("Vec<Box<dyn Hook>>, len: {}", self.hooks.len()))
203            .field("interval", &self.interval)
204            .finish()
205    }
206}
207
208/// Refresh state of [`BackgroundHooks`], kept under one lock so that the interval and the
209/// in-flight condition are checked and claimed atomically.
210#[derive(Default)]
211struct BackgroundHooksState {
212    /// When the last collection was started.
213    last_collected: Option<Instant>,
214    /// Whether a collection is currently running.
215    in_flight: bool,
216}
217
218/// Releases the in-flight flag when the collection finishes, also by unwinding.
219struct InFlightGuard<'a>(&'a Mutex<BackgroundHooksState>);
220
221impl Drop for InFlightGuard<'_> {
222    fn drop(&mut self) {
223        self.0.lock().in_flight = false;
224    }
225}
226
227#[cfg(all(feature = "jemalloc", unix))]
228fn collect_memory_stats() {
229    use metrics::gauge;
230    use tikv_jemalloc_ctl::{epoch, stats};
231    use tracing::error;
232
233    if epoch::advance().map_err(|error| error!(%error, "Failed to advance jemalloc epoch")).is_err()
234    {
235        return
236    }
237
238    if let Ok(value) = stats::active::read()
239        .map_err(|error| error!(%error, "Failed to read jemalloc.stats.active"))
240    {
241        gauge!("jemalloc.active").set(value as f64);
242    }
243
244    if let Ok(value) = stats::allocated::read()
245        .map_err(|error| error!(%error, "Failed to read jemalloc.stats.allocated"))
246    {
247        gauge!("jemalloc.allocated").set(value as f64);
248    }
249
250    if let Ok(value) = stats::mapped::read()
251        .map_err(|error| error!(%error, "Failed to read jemalloc.stats.mapped"))
252    {
253        gauge!("jemalloc.mapped").set(value as f64);
254    }
255
256    if let Ok(value) = stats::metadata::read()
257        .map_err(|error| error!(%error, "Failed to read jemalloc.stats.metadata"))
258    {
259        gauge!("jemalloc.metadata").set(value as f64);
260    }
261
262    if let Ok(value) = stats::resident::read()
263        .map_err(|error| error!(%error, "Failed to read jemalloc.stats.resident"))
264    {
265        gauge!("jemalloc.resident").set(value as f64);
266    }
267
268    if let Ok(value) = stats::retained::read()
269        .map_err(|error| error!(%error, "Failed to read jemalloc.stats.retained"))
270    {
271        gauge!("jemalloc.retained").set(value as f64);
272    }
273}
274
275#[cfg(not(all(feature = "jemalloc", unix)))]
276const fn collect_memory_stats() {}
277
278#[cfg(target_os = "linux")]
279fn collect_io_stats() {
280    use metrics::counter;
281    use tracing::error;
282
283    let Ok(process) = procfs::process::Process::myself()
284        .map_err(|error| error!(%error, "Failed to get currently running process"))
285    else {
286        return
287    };
288
289    let Ok(io) = process.io().map_err(
290        |error| error!(%error, "Failed to get IO stats for the currently running process"),
291    ) else {
292        return
293    };
294
295    counter!("io.rchar").absolute(io.rchar);
296    counter!("io.wchar").absolute(io.wchar);
297    counter!("io.syscr").absolute(io.syscr);
298    counter!("io.syscw").absolute(io.syscw);
299    counter!("io.read_bytes").absolute(io.read_bytes);
300    counter!("io.write_bytes").absolute(io.write_bytes);
301    counter!("io.cancelled_write_bytes").absolute(io.cancelled_write_bytes);
302}
303
304#[cfg(not(target_os = "linux"))]
305const fn collect_io_stats() {}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use reth_tasks::Runtime;
311    use std::sync::atomic::{AtomicUsize, Ordering};
312
313    /// Keeps kicking off refreshes until `done` holds, so that a refresh that was skipped because
314    /// the previous one was still in flight is retried.
315    fn refresh_until(hooks: &Hooks, runtime: &Runtime, done: impl Fn() -> bool) {
316        let deadline = Instant::now() + Duration::from_secs(10);
317        while !done() {
318            assert!(Instant::now() < deadline, "background collection did not make progress");
319            hooks.refresh_background(runtime);
320            std::thread::sleep(Duration::from_millis(5));
321        }
322    }
323
324    #[test]
325    fn background_collections_do_not_overlap() {
326        let runtime = Runtime::test();
327        let active = Arc::new(AtomicUsize::new(0));
328        let max_active = Arc::new(AtomicUsize::new(0));
329        let collections = Arc::new(AtomicUsize::new(0));
330        let hooks = Hooks::builder()
331            .with_background_interval(Duration::from_millis(20))
332            .with_background_hook({
333                let active = active.clone();
334                let max_active = max_active.clone();
335                let collections = collections.clone();
336                move || {
337                    let now_active = active.fetch_add(1, Ordering::SeqCst) + 1;
338                    max_active.fetch_max(now_active, Ordering::SeqCst);
339                    // outlive the interval, so that refreshes are attempted while this runs
340                    std::thread::sleep(Duration::from_millis(100));
341                    collections.fetch_add(1, Ordering::SeqCst);
342                    active.fetch_sub(1, Ordering::SeqCst);
343                }
344            })
345            .build();
346
347        let started = Instant::now();
348        while started.elapsed() < Duration::from_millis(350) {
349            hooks.refresh_background(&runtime);
350            std::thread::sleep(Duration::from_millis(5));
351        }
352        while active.load(Ordering::SeqCst) > 0 {
353            std::thread::sleep(Duration::from_millis(5));
354        }
355
356        assert_eq!(max_active.load(Ordering::SeqCst), 1, "collections overlapped");
357        assert!(
358            collections.load(Ordering::SeqCst) >= 2,
359            "collection did not resume once the previous one finished"
360        );
361    }
362
363    #[test]
364    fn panicking_background_hook_does_not_block_collection() {
365        let runtime = Runtime::test();
366        let collections = Arc::new(AtomicUsize::new(0));
367        let hooks = Hooks::builder()
368            .with_background_interval(Duration::ZERO)
369            .with_background_hook(|| panic!("hook panicked"))
370            .with_background_hook({
371                let collections = collections.clone();
372                move || {
373                    collections.fetch_add(1, Ordering::SeqCst);
374                }
375            })
376            .build();
377
378        // the hook registered after the panicking one is still collected
379        refresh_until(&hooks, &runtime, || collections.load(Ordering::SeqCst) >= 1);
380        // and the panic does not leave the collection marked as in flight
381        refresh_until(&hooks, &runtime, || collections.load(Ordering::SeqCst) >= 2);
382    }
383}