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    pub tags: BTreeMap<String, String>,
268}
269
270fn default_prefix() -> String {
271    "objectstore".to_owned()
272}
273
274impl Default for MetricsConfig {
275    fn default() -> Self {
276        Self {
277            addr: None,
278            prefix: "objectstore".to_owned(),
279            buffer_size: None,
280            tags: BTreeMap::new(),
281        }
282    }
283}
284
285/// Initializes the global DogStatsD metrics exporter.
286///
287/// Returns `Ok(())` immediately when `config.addr` is `None` — in that case the
288/// global recorder is never installed and all `metrics` calls are no-ops.
289pub fn init(config: &MetricsConfig) -> Result<(), Error> {
290    let Some(ref addr) = config.addr else {
291        return Ok(());
292    };
293
294    objectstore_log::info!("reporting metrics to statsd at {addr}");
295
296    let global_labels: Vec<metrics::Label> = config
297        .tags
298        .iter()
299        .map(|(k, v)| metrics::Label::new(k.clone(), v.clone()))
300        .collect();
301
302    let mut builder = DogStatsDBuilder::default()
303        .with_remote_address(addr)?
304        .with_telemetry(true)
305        .with_aggregation_mode(AggregationMode::Aggressive)
306        .send_histograms_as_distributions(true)
307        .with_histogram_sampling(true)
308        .set_global_prefix(&config.prefix)
309        .with_global_labels(global_labels);
310
311    if let Some(buffer_size) = config.buffer_size {
312        builder = builder.with_maximum_payload_length(buffer_size)?;
313    }
314
315    builder.install()?;
316
317    Ok(())
318}
319
320pub use mock::with_capturing_test_client;
321
322// ---------------------------------------------------------------------------
323// Macros
324// ---------------------------------------------------------------------------
325
326/// Increments a counter metric.
327///
328/// # Syntax
329///
330/// ```rust
331/// use objectstore_metrics::count;
332///
333/// // Shorthand: increments by 1
334/// count!("server.start");
335/// count!("server.requests", route = "/v1/test", method = "GET");
336///
337/// // Explicit increment value
338/// count!("server.requests" += 5);
339/// count!("server.requests" += 5, route = "/v1/test");
340/// ```
341///
342/// Tag keys are identifiers; tag values must implement `Into<SharedString>`
343/// (use `.to_string()` for integers or non-string types).
344#[macro_export]
345macro_rules! count {
346    // Shorthand: increment by 1
347    ($name:literal $(, $tag:ident = $tv:expr)* $(,)?) => {
348        $crate::_macro_support::metrics::counter!(
349            $name $(, stringify!($tag) => $tv)*
350        )
351        .increment(1);
352    };
353    // Explicit increment value
354    ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
355        $crate::_macro_support::metrics::counter!(
356            $name $(, stringify!($tag) => $tv)*
357        )
358        .increment($value as u64);
359    };
360}
361
362/// Sets, increments, or decrements a gauge metric.
363///
364/// # Syntax
365///
366/// ```rust
367/// use objectstore_metrics::gauge;
368///
369/// gauge!("runtime.num_workers" = 4usize);
370/// gauge!("connections" += 1usize);
371/// gauge!("connections" -= 1usize);
372/// gauge!("runtime.num_workers" = 4usize, pool = "default");
373/// ```
374///
375/// Values are converted to `f64` via [`AsF64`]. Supported types
376/// include `f64`, `Duration`, integer primitives, `u64`, and `usize`.
377///
378/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
379#[macro_export]
380macro_rules! gauge {
381    // Set
382    ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
383        $crate::_macro_support::metrics::gauge!(
384            $name $(, stringify!($tag) => $tv)*
385        )
386        .set($crate::_macro_support::AsF64::as_f64($value));
387    };
388    // Increment
389    ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
390        $crate::_macro_support::metrics::gauge!(
391            $name $(, stringify!($tag) => $tv)*
392        )
393        .increment($crate::_macro_support::AsF64::as_f64($value));
394    };
395    // Decrement
396    ($name:literal -= $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
397        $crate::_macro_support::metrics::gauge!(
398            $name $(, stringify!($tag) => $tv)*
399        )
400        .decrement($crate::_macro_support::AsF64::as_f64($value));
401    };
402}
403
404/// Records a distribution (histogram) metric.
405///
406/// # Syntax
407///
408/// ```rust
409/// use std::time::Duration;
410/// use objectstore_metrics::record;
411///
412/// let elapsed = Duration::from_secs(1);
413/// record!("server.requests.duration" = elapsed);
414/// record!("server.requests.duration" = elapsed, route = "/v1/test");
415/// record!("put.size" = 1024u64, usecase = "default");
416/// ```
417///
418/// Values are converted to `f64` via [`AsF64`]. `Duration` is
419/// converted to fractional seconds automatically.
420///
421/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
422#[macro_export]
423macro_rules! record {
424    ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
425        $crate::_macro_support::metrics::histogram!(
426            $name $(, stringify!($tag) => $tv)*
427        )
428        .record($crate::_macro_support::AsF64::as_f64($value));
429    };
430}
431
432/// Starts a timer that records elapsed time in fractional seconds as a
433/// distribution metric.
434///
435/// Returns a [`TimerGuard`] that captures `Instant::now()` at creation.
436/// Call [`.record()`](TimerGuard::record) to record the metric with the
437/// tag `success:true`, or let it drop to record with `success:false`.
438///
439/// If you want to override this behavior and record the metric with
440/// `success:true` even on drop, call [`.success()`](TimerGuard::success)
441/// on the guard.
442///
443/// Tags can also be added after creation via [`.tag()`](TimerGuard::tag),
444/// which is useful when some tag values depend on the outcome of the
445/// timed operation.
446///
447/// # Syntax
448///
449/// ```rust
450/// use objectstore_metrics::timer;
451///
452/// let guard = timer!("server.requests.duration");
453/// let guard = timer!("server.requests.duration", route = "/v1/test");
454/// // ... do work ...
455/// guard.record(); // records elapsed time with success:true
456/// ```
457///
458/// ```rust
459/// use objectstore_metrics::timer;
460///
461/// let guard = timer!("server.requests.duration", route = "/v1/test");
462/// // ... determine backend ...
463/// let guard = guard.tag("backend", "gcs");
464/// guard.record();
465/// ```
466///
467/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
468#[macro_export]
469macro_rules! timer {
470    ($name:literal $(, $tag:ident = $tv:expr)* $(,)?) => {{
471        let labels = vec![
472            $($crate::_macro_support::metrics::Label::new(stringify!($tag), $tv),)*
473        ];
474        $crate::TimerGuard::new($name, module_path!(), labels)
475    }};
476}