Skip to main content

objectstore_metrics/
lib.rs

1//! Metrics macros and DogStatsD initialization for Objectstore.
2//!
3//! This crate provides three things:
4//!
5//! 1. [`count!`], [`gauge!`], [`record!`], and [`timer!`] macros with rustfmt-friendly
6//!    expression-based syntax.
7//! 2. [`MetricsConfig`] and [`init`] for wiring up a DogStatsD exporter.
8//! 3. [`with_capturing_test_client`] for asserting on emitted metrics in tests.
9//!
10//! # Usage
11//!
12//! ```rust
13//! use std::time::Duration;
14//! use objectstore_metrics::{count, gauge, record, timer};
15//!
16//! let stored_size: u64 = 1024;
17//! let elapsed = Duration::from_secs(1);
18//! let route = "api/v1";
19//!
20//! count!("server.start");
21//! gauge!("server.requests.in_flight" = 42usize);
22//! record!("server.requests.duration" = elapsed, route = route);
23//! ```
24//!
25//! # Tag syntax
26//!
27//! Tags use `ident = expr` syntax. Tag values must implement `Into<SharedString>`
28//! (i.e., `&str`, `String`, or similar). For integer or `Display` types, call
29//! `.to_string()`. Use `.as_str()` methods whenever available to avoid allocation.
30//!
31//! # `AsF64` trait
32//!
33//! [`AsF64`] converts gauge and histogram values to `f64`:
34//!
35//! - Standard numeric primitives (`f32`, `f64`, `i8`–`i32`, `u8`–`u32`) via `Into<f64>`.
36//! - `u64` and `usize` via an `as f64` cast; values above 2^53 lose precision, which is
37//!   acceptable for metric reporting.
38//! - [`Duration`](std::time::Duration) as fractional seconds via `.as_secs_f64()`.
39
40mod mock;
41
42use std::collections::BTreeMap;
43
44use metrics_exporter_dogstatsd::{AggregationMode, DogStatsDBuilder};
45use serde::{Deserialize, Serialize};
46
47/// Converts a value to `f64` for metric recording.
48///
49/// Implemented for `f64`, `f32`, [`Duration`](std::time::Duration),
50/// `i8`–`i32`, `u8`–`u32`, `u64`, and `usize`.
51///
52/// `Duration` is converted to fractional seconds via `.as_secs_f64()`.
53/// `u64` and `usize` use an `as f64` cast; values above 2^53 lose precision,
54/// which is acceptable for metric reporting.
55#[allow(clippy::wrong_self_convention)]
56pub trait AsF64 {
57    /// Converts this value to its `f64` representation.
58    fn as_f64(self) -> f64;
59}
60
61macro_rules! impl_as_f64 {
62    // Types where Into<f64> is available
63    (into: $($t:ty),* $(,)?) => {$(
64        impl AsF64 for $t {
65            fn as_f64(self) -> f64 { self.into() }
66        }
67    )*};
68    // Types where only `as f64` is available
69    (cast: $($t:ty),* $(,)?) => {$(
70        impl AsF64 for $t {
71            fn as_f64(self) -> f64 { self as f64 }
72        }
73    )*};
74}
75
76impl_as_f64!(into: f32, f64, i8, i16, i32, u8, u16, u32);
77impl_as_f64!(cast: u64, usize);
78
79impl AsF64 for std::time::Duration {
80    fn as_f64(self) -> f64 {
81        self.as_secs_f64()
82    }
83}
84
85/// A guard that measures elapsed time and records it as a distribution metric.
86///
87/// Created by the [`timer!`] macro. Records with `success:true` when
88/// [`record()`](TimerGuard::record) is called, or `success:false` when dropped
89/// without calling `record()`.
90/// Call [`success()`](TimerGuard::success) to override this behavior and record
91/// with `success:true` even on drop.
92///
93/// Tags can be added after creation via [`tag()`](TimerGuard::tag), or updated
94/// via [`set_tag()`](TimerGuard::set_tag).
95#[must_use = "timer! returns a guard that records the metric on guard.record() or on drop, bind it to a variable"]
96pub struct TimerGuard {
97    start: std::time::Instant,
98    name: &'static str,
99    module_path: &'static str,
100    labels: Vec<metrics::Label>,
101    record_failure_on_drop: bool,
102    recorded: bool,
103}
104
105impl TimerGuard {
106    #[doc(hidden)]
107    pub fn new(name: &'static str, module_path: &'static str, labels: Vec<metrics::Label>) -> Self {
108        Self {
109            start: std::time::Instant::now(),
110            name,
111            module_path,
112            labels,
113            record_failure_on_drop: true,
114            recorded: false,
115        }
116    }
117
118    /// Returns the time elapsed since the guard was created.
119    pub fn elapsed(&self) -> std::time::Duration {
120        self.start.elapsed()
121    }
122
123    /// Adds a tag to the metric.
124    pub fn tag(mut self, key: &'static str, value: impl Into<metrics::SharedString>) -> Self {
125        self.labels.push(metrics::Label::new(key, value));
126        self
127    }
128
129    /// Sets a tag, replacing any existing tags with the same key.
130    ///
131    /// The updated tag is included when the guard records, including on drop.
132    pub fn set_tag(&mut self, key: &'static str, value: impl Into<metrics::SharedString>) {
133        self.labels.retain(|label| label.key() != key);
134        self.labels.push(metrics::Label::new(key, value));
135    }
136
137    /// Changes the behavior of this guard to always record the metric
138    /// with `success:true`, even on drop.
139    pub fn success(mut self) -> Self {
140        self.record_failure_on_drop = false;
141        self
142    }
143
144    /// Consumes the guard, recording the elapsed time with `success:true`.
145    pub fn record(mut self) {
146        self.emit("true");
147    }
148
149    fn emit(&mut self, success: &'static str) {
150        self.recorded = true;
151        let mut labels = std::mem::take(&mut self.labels);
152        labels.push(metrics::Label::new("success", success));
153        let key = metrics::Key::from_parts(self.name, labels);
154        let metadata = metrics::Metadata::new(
155            self.module_path,
156            metrics::Level::INFO,
157            Some(self.module_path),
158        );
159        metrics::with_recorder(|rec| {
160            rec.register_histogram(&key, &metadata)
161                .record(AsF64::as_f64(self.start.elapsed()));
162        });
163    }
164}
165
166impl Drop for TimerGuard {
167    fn drop(&mut self) {
168        if !self.recorded {
169            let success = if self.record_failure_on_drop {
170                "false"
171            } else {
172                "true"
173            };
174            self.emit(success);
175        }
176    }
177}
178
179/// Re-exports used by macro expansion. Not part of the public API.
180#[doc(hidden)]
181pub mod _macro_support {
182    pub use crate::AsF64;
183    pub use metrics;
184}
185
186/// Error type for metrics initialization.
187#[derive(Debug, thiserror::Error)]
188pub enum Error {
189    /// Failed to build the DogStatsD exporter.
190    #[error("failed to initialize metrics exporter: {0}")]
191    Build(#[from] metrics_exporter_dogstatsd::BuildError),
192}
193
194/// Configuration for the DogStatsD metrics exporter.
195///
196/// When `addr` is `None`, metrics are no-ops (the global recorder is never installed).
197///
198/// # Environment Variables
199///
200/// - `OS__METRICS__ADDR` — StatsD address (e.g. `127.0.0.1:8125` or `unixgram:///tmp/statsd.sock`)
201/// - `OS__METRICS__PREFIX` — global metric name prefix
202/// - `OS__METRICS__BUFFER_SIZE` — maximum payload length in bytes
203/// - `OS__METRICS__TAGS__KEY=value` — per-key global tags
204#[derive(Clone, Debug, Deserialize, Serialize)]
205pub struct MetricsConfig {
206    /// Remote address to forward metrics to.
207    ///
208    /// When `None`, metrics are disabled (the global recorder is not installed and all
209    /// metric calls are no-ops).
210    ///
211    /// For UDP, the address must be in the format `<host>:<port>` (e.g. `127.0.0.1:8125`).
212    /// For Unix domain sockets, use the format `<scheme>://<path>`, where the scheme is
213    /// either `unix` (stream, `SOCK_STREAM`) or `unixgram` (datagram, `SOCK_DGRAM`).
214    ///
215    /// # Default
216    ///
217    /// `None` (metrics disabled)
218    ///
219    /// # Environment Variable
220    ///
221    /// `OS__METRICS__ADDR`
222    pub addr: Option<String>,
223
224    /// Global prefix prepended to every metric name.
225    ///
226    /// The prefix is prepended to every metric name, with a `.` separator added automatically.
227    ///
228    /// # Default
229    ///
230    /// `"objectstore"`
231    ///
232    /// # Environment Variable
233    ///
234    /// `OS__METRICS__PREFIX`
235    #[serde(default = "default_prefix")]
236    pub prefix: String,
237
238    /// Maximum payload length in bytes.
239    ///
240    /// Controls the maximum size per StatsD payload. Should match the Datadog Agent's
241    /// `dogstatsd_buffer_size` setting. If `None`, the exporter uses its default
242    /// (1432 bytes for UDP, 8192 bytes for Unix sockets).
243    ///
244    /// # Default
245    ///
246    /// `None` (exporter default)
247    ///
248    /// # Environment Variable
249    ///
250    /// `OS__METRICS__BUFFER_SIZE`
251    pub buffer_size: Option<usize>,
252
253    /// Global tags applied to all metrics.
254    ///
255    /// Key-value pairs attached to every emitted metric. Useful for identifying
256    /// environment, region, or other deployment-specific dimensions.
257    ///
258    /// # Default
259    ///
260    /// Empty (no tags)
261    ///
262    /// # Environment Variables
263    ///
264    /// Each tag is set individually:
265    /// - `OS__METRICS__TAGS__FOO=foo`
266    /// - `OS__METRICS__TAGS__BAR=bar`
267    ///
268    /// # YAML Example
269    ///
270    /// ```yaml
271    /// metrics:
272    ///   tags:
273    ///     foo: foo
274    ///     bar: bar
275    /// ```
276    #[serde(default)]
277    pub tags: BTreeMap<String, String>,
278}
279
280fn default_prefix() -> String {
281    "objectstore".to_owned()
282}
283
284impl Default for MetricsConfig {
285    fn default() -> Self {
286        Self {
287            addr: None,
288            prefix: "objectstore".to_owned(),
289            buffer_size: None,
290            tags: BTreeMap::new(),
291        }
292    }
293}
294
295/// Initializes the global DogStatsD metrics exporter.
296///
297/// Returns `Ok(())` immediately when `config.addr` is `None` — in that case the
298/// global recorder is never installed and all `metrics` calls are no-ops.
299pub fn init(config: &MetricsConfig) -> Result<(), Error> {
300    let Some(ref addr) = config.addr else {
301        return Ok(());
302    };
303
304    objectstore_log::info!("reporting metrics to statsd at {addr}");
305
306    let global_labels: Vec<metrics::Label> = config
307        .tags
308        .iter()
309        .map(|(k, v)| metrics::Label::new(k.clone(), v.clone()))
310        .collect();
311
312    let mut builder = DogStatsDBuilder::default()
313        .with_remote_address(addr)?
314        .with_telemetry(true)
315        .with_aggregation_mode(AggregationMode::Aggressive)
316        .send_histograms_as_distributions(true)
317        .with_histogram_sampling(true)
318        .set_global_prefix(&config.prefix)
319        .with_global_labels(global_labels);
320
321    if let Some(buffer_size) = config.buffer_size {
322        builder = builder.with_maximum_payload_length(buffer_size)?;
323    }
324
325    builder.install()?;
326
327    Ok(())
328}
329
330pub use mock::{with_capturing_test_client, with_capturing_test_client_async};
331
332// ---------------------------------------------------------------------------
333// Macros
334// ---------------------------------------------------------------------------
335
336/// Increments a counter metric.
337///
338/// # Syntax
339///
340/// ```rust
341/// use objectstore_metrics::count;
342///
343/// // Shorthand: increments by 1
344/// count!("server.start");
345/// count!("server.requests", route = "/v1/test", method = "GET");
346///
347/// // Explicit increment value
348/// count!("server.requests" += 5);
349/// count!("server.requests" += 5, route = "/v1/test");
350/// ```
351///
352/// Tag keys are identifiers; tag values must implement `Into<SharedString>`
353/// (use `.to_string()` for integers or non-string types).
354#[macro_export]
355macro_rules! count {
356    // Shorthand: increment by 1
357    ($name:literal $(, $tag:ident = $tv:expr)* $(,)?) => {
358        $crate::_macro_support::metrics::counter!(
359            $name $(, stringify!($tag) => $tv)*
360        )
361        .increment(1)
362    };
363    // Explicit increment value
364    ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
365        $crate::_macro_support::metrics::counter!(
366            $name $(, stringify!($tag) => $tv)*
367        )
368        .increment($value as u64)
369    };
370}
371
372/// Sets, increments, or decrements a gauge metric.
373///
374/// # Syntax
375///
376/// ```rust
377/// use objectstore_metrics::gauge;
378///
379/// gauge!("runtime.num_workers" = 4usize);
380/// gauge!("connections" += 1usize);
381/// gauge!("connections" -= 1usize);
382/// gauge!("runtime.num_workers" = 4usize, pool = "default");
383/// ```
384///
385/// Values are converted to `f64` via [`AsF64`]. Supported types
386/// include `f64`, `Duration`, integer primitives, `u64`, and `usize`.
387///
388/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
389#[macro_export]
390macro_rules! gauge {
391    // Set
392    ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
393        $crate::_macro_support::metrics::gauge!(
394            $name $(, stringify!($tag) => $tv)*
395        )
396        .set($crate::_macro_support::AsF64::as_f64($value));
397    };
398    // Increment
399    ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
400        $crate::_macro_support::metrics::gauge!(
401            $name $(, stringify!($tag) => $tv)*
402        )
403        .increment($crate::_macro_support::AsF64::as_f64($value));
404    };
405    // Decrement
406    ($name:literal -= $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
407        $crate::_macro_support::metrics::gauge!(
408            $name $(, stringify!($tag) => $tv)*
409        )
410        .decrement($crate::_macro_support::AsF64::as_f64($value));
411    };
412}
413
414/// Records a distribution (histogram) metric.
415///
416/// # Syntax
417///
418/// ```rust
419/// use std::time::Duration;
420/// use objectstore_metrics::record;
421///
422/// let elapsed = Duration::from_secs(1);
423/// record!("server.requests.duration" = elapsed);
424/// record!("server.requests.duration" = elapsed, route = "/v1/test");
425/// record!("put.size" = 1024u64, usecase = "default");
426/// ```
427///
428/// Values are converted to `f64` via [`AsF64`]. `Duration` is
429/// converted to fractional seconds automatically.
430///
431/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
432#[macro_export]
433macro_rules! record {
434    ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
435        $crate::_macro_support::metrics::histogram!(
436            $name $(, stringify!($tag) => $tv)*
437        )
438        .record($crate::_macro_support::AsF64::as_f64($value));
439    };
440}
441
442/// Starts a timer that records elapsed time in fractional seconds as a
443/// distribution metric.
444///
445/// Returns a [`TimerGuard`] that captures `Instant::now()` at creation.
446/// Call [`.record()`](TimerGuard::record) to record the metric with the
447/// tag `success:true`, or let it drop to record with `success:false`.
448///
449/// If you want to override this behavior and record the metric with
450/// `success:true` even on drop, call [`.success()`](TimerGuard::success)
451/// on the guard.
452///
453/// Tags can also be added after creation via [`.tag()`](TimerGuard::tag),
454/// which is useful when some tag values depend on the outcome of the
455/// timed operation.
456///
457/// # Syntax
458///
459/// ```rust
460/// use objectstore_metrics::timer;
461///
462/// let guard = timer!("server.requests.duration");
463/// let guard = timer!("server.requests.duration", route = "/v1/test");
464/// // ... do work ...
465/// guard.record(); // records elapsed time with success:true
466/// ```
467///
468/// ```rust
469/// use objectstore_metrics::timer;
470///
471/// let guard = timer!("server.requests.duration", route = "/v1/test");
472/// // ... determine backend ...
473/// let guard = guard.tag("backend", "gcs");
474/// guard.record();
475/// ```
476///
477/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
478#[macro_export]
479macro_rules! timer {
480    ($name:literal $(, $tag:ident = $tv:expr)* $(,)?) => {{
481        let labels = vec![
482            $($crate::_macro_support::metrics::Label::new(stringify!($tag), $tv),)*
483        ];
484        $crate::TimerGuard::new($name, module_path!(), labels)
485    }};
486}