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