objectstore_metrics/
lib.rs

1//! Metrics macros and DogStatsD initialization for Objectstore.
2//!
3//! This crate provides three things:
4//!
5//! 1. [`count!`], [`gauge!`], and [`record!`] 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};
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/// Re-exports used by macro expansion. Not part of the public API.
86#[doc(hidden)]
87pub mod _macro_support {
88    pub use crate::AsF64;
89    pub use metrics;
90}
91
92/// Error type for metrics initialization.
93#[derive(Debug, thiserror::Error)]
94pub enum Error {
95    /// Failed to build the DogStatsD exporter.
96    #[error("failed to initialize metrics exporter: {0}")]
97    Build(#[from] metrics_exporter_dogstatsd::BuildError),
98}
99
100/// Configuration for the DogStatsD metrics exporter.
101///
102/// When `addr` is `None`, metrics are no-ops (the global recorder is never installed).
103///
104/// # Environment Variables
105///
106/// - `OS__METRICS__ADDR` — StatsD address (e.g. `127.0.0.1:8125` or `unixgram:///tmp/statsd.sock`)
107/// - `OS__METRICS__PREFIX` — global metric name prefix
108/// - `OS__METRICS__BUFFER_SIZE` — maximum payload length in bytes
109/// - `OS__METRICS__TAGS__KEY=value` — per-key global tags
110#[derive(Clone, Debug, Deserialize, Serialize)]
111pub struct MetricsConfig {
112    /// Remote address to forward metrics to.
113    ///
114    /// When `None`, metrics are disabled (the global recorder is not installed and all
115    /// metric calls are no-ops).
116    ///
117    /// For UDP, the address must be in the format `<host>:<port>` (e.g. `127.0.0.1:8125`).
118    /// For Unix domain sockets, use the format `<scheme>://<path>`, where the scheme is
119    /// either `unix` (stream, `SOCK_STREAM`) or `unixgram` (datagram, `SOCK_DGRAM`).
120    ///
121    /// # Default
122    ///
123    /// `None` (metrics disabled)
124    ///
125    /// # Environment Variable
126    ///
127    /// `OS__METRICS__ADDR`
128    pub addr: Option<String>,
129
130    /// Global prefix prepended to every metric name.
131    ///
132    /// The prefix is prepended to every metric name, with a `.` separator added automatically.
133    ///
134    /// # Default
135    ///
136    /// `"objectstore"`
137    ///
138    /// # Environment Variable
139    ///
140    /// `OS__METRICS__PREFIX`
141    #[serde(default = "default_prefix")]
142    pub prefix: String,
143
144    /// Maximum payload length in bytes.
145    ///
146    /// Controls the maximum size per StatsD payload. Should match the Datadog Agent's
147    /// `dogstatsd_buffer_size` setting. If `None`, the exporter uses its default
148    /// (1432 bytes for UDP, 8192 bytes for Unix sockets).
149    ///
150    /// # Default
151    ///
152    /// `None` (exporter default)
153    ///
154    /// # Environment Variable
155    ///
156    /// `OS__METRICS__BUFFER_SIZE`
157    pub buffer_size: Option<usize>,
158
159    /// Global tags applied to all metrics.
160    ///
161    /// Key-value pairs attached to every emitted metric. Useful for identifying
162    /// environment, region, or other deployment-specific dimensions.
163    ///
164    /// # Default
165    ///
166    /// Empty (no tags)
167    ///
168    /// # Environment Variables
169    ///
170    /// Each tag is set individually:
171    /// - `OS__METRICS__TAGS__FOO=foo`
172    /// - `OS__METRICS__TAGS__BAR=bar`
173    ///
174    /// # YAML Example
175    ///
176    /// ```yaml
177    /// metrics:
178    ///   tags:
179    ///     foo: foo
180    ///     bar: bar
181    /// ```
182    pub tags: BTreeMap<String, String>,
183}
184
185fn default_prefix() -> String {
186    "objectstore".to_owned()
187}
188
189impl Default for MetricsConfig {
190    fn default() -> Self {
191        Self {
192            addr: None,
193            prefix: "objectstore".to_owned(),
194            buffer_size: None,
195            tags: BTreeMap::new(),
196        }
197    }
198}
199
200/// Initializes the global DogStatsD metrics exporter.
201///
202/// Returns `Ok(())` immediately when `config.addr` is `None` — in that case the
203/// global recorder is never installed and all `metrics` calls are no-ops.
204pub fn init(config: &MetricsConfig) -> Result<(), Error> {
205    let Some(ref addr) = config.addr else {
206        return Ok(());
207    };
208
209    objectstore_log::info!("reporting metrics to statsd at {addr}");
210
211    let global_labels: Vec<metrics::Label> = config
212        .tags
213        .iter()
214        .map(|(k, v)| metrics::Label::new(k.clone(), v.clone()))
215        .collect();
216
217    let mut builder = DogStatsDBuilder::default()
218        .with_remote_address(addr)?
219        .with_telemetry(true)
220        .with_aggregation_mode(AggregationMode::Aggressive)
221        .send_histograms_as_distributions(true)
222        .with_histogram_sampling(true)
223        .set_global_prefix(&config.prefix)
224        .with_global_labels(global_labels);
225
226    if let Some(buffer_size) = config.buffer_size {
227        builder = builder.with_maximum_payload_length(buffer_size)?;
228    }
229
230    builder.install()?;
231
232    Ok(())
233}
234
235pub use mock::with_capturing_test_client;
236
237// ---------------------------------------------------------------------------
238// Macros
239// ---------------------------------------------------------------------------
240
241/// Increments a counter metric.
242///
243/// # Syntax
244///
245/// ```rust
246/// use objectstore_metrics::count;
247///
248/// // Shorthand: increments by 1
249/// count!("server.start");
250/// count!("server.requests", route = "/v1/test", method = "GET");
251///
252/// // Explicit increment value
253/// count!("server.requests" += 5);
254/// count!("server.requests" += 5, route = "/v1/test");
255/// ```
256///
257/// Tag keys are identifiers; tag values must implement `Into<SharedString>`
258/// (use `.to_string()` for integers or non-string types).
259#[macro_export]
260macro_rules! count {
261    // Shorthand: increment by 1
262    ($name:literal $(, $tag:ident = $tv:expr)* $(,)?) => {
263        $crate::_macro_support::metrics::counter!(
264            $name $(, stringify!($tag) => $tv)*
265        )
266        .increment(1);
267    };
268    // Explicit increment value
269    ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
270        $crate::_macro_support::metrics::counter!(
271            $name $(, stringify!($tag) => $tv)*
272        )
273        .increment($value as u64);
274    };
275}
276
277/// Sets, increments, or decrements a gauge metric.
278///
279/// # Syntax
280///
281/// ```rust
282/// use objectstore_metrics::gauge;
283///
284/// gauge!("runtime.num_workers" = 4usize);
285/// gauge!("connections" += 1usize);
286/// gauge!("connections" -= 1usize);
287/// gauge!("runtime.num_workers" = 4usize, pool = "default");
288/// ```
289///
290/// Values are converted to `f64` via [`AsF64`]. Supported types
291/// include `f64`, `Duration`, integer primitives, `u64`, and `usize`.
292///
293/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
294#[macro_export]
295macro_rules! gauge {
296    // Set
297    ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
298        $crate::_macro_support::metrics::gauge!(
299            $name $(, stringify!($tag) => $tv)*
300        )
301        .set($crate::_macro_support::AsF64::as_f64($value));
302    };
303    // Increment
304    ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
305        $crate::_macro_support::metrics::gauge!(
306            $name $(, stringify!($tag) => $tv)*
307        )
308        .increment($crate::_macro_support::AsF64::as_f64($value));
309    };
310    // Decrement
311    ($name:literal -= $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
312        $crate::_macro_support::metrics::gauge!(
313            $name $(, stringify!($tag) => $tv)*
314        )
315        .decrement($crate::_macro_support::AsF64::as_f64($value));
316    };
317}
318
319/// Records a distribution (histogram) metric.
320///
321/// # Syntax
322///
323/// ```rust
324/// use std::time::Duration;
325/// use objectstore_metrics::record;
326///
327/// let elapsed = Duration::from_secs(1);
328/// record!("server.requests.duration" = elapsed);
329/// record!("server.requests.duration" = elapsed, route = "/v1/test");
330/// record!("put.size" = 1024u64, usecase = "default");
331/// ```
332///
333/// Values are converted to `f64` via [`AsF64`]. `Duration` is
334/// converted to fractional seconds automatically.
335///
336/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
337#[macro_export]
338macro_rules! record {
339    ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
340        $crate::_macro_support::metrics::histogram!(
341            $name $(, stringify!($tag) => $tv)*
342        )
343        .record($crate::_macro_support::AsF64::as_f64($value));
344    };
345}