objectstore_metrics/
lib.rs

1//! Metrics macros and DogStatsD initialization for Objectstore.
2//!
3//! This crate provides three things:
4//!
5//! 1. [`counter!`], [`gauge!`], and [`distribution!`] macros that preserve
6//!    a concise call-site syntax (`"name": value, "tag" => tag_value`).
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::{counter, distribution, gauge};
15//!
16//! let count = 42_u64;
17//! let elapsed = Duration::from_secs(1);
18//! let route = "api/v1";
19//!
20//! counter!("server.start": 1);
21//! gauge!("server.requests.in_flight": count);
22//! distribution!("server.requests.duration"@s: elapsed, "route" => route);
23//! ```
24//!
25//! # Unit annotations
26//!
27//! - `@s` converts a [`Duration`](std::time::Duration) to seconds via `.as_secs_f64()`.
28//! - `@b` converts the value via `as f64` (identity for byte counts).
29//! - No annotation also converts via `as f64`.
30
31mod mock;
32
33use std::collections::BTreeMap;
34
35use metrics_exporter_dogstatsd::{AggregationMode, DogStatsDBuilder};
36use serde::{Deserialize, Serialize};
37
38/// Re-exports used by macro expansion. Not part of the public API.
39#[doc(hidden)]
40pub mod _macro_support {
41    pub use metrics;
42}
43
44/// Error type for metrics initialization.
45#[derive(Debug, thiserror::Error)]
46pub enum Error {
47    /// Failed to build the DogStatsD exporter.
48    #[error("failed to initialize metrics exporter: {0}")]
49    Build(#[from] metrics_exporter_dogstatsd::BuildError),
50}
51
52/// Configuration for the DogStatsD metrics exporter.
53///
54/// When `addr` is `None`, metrics are no-ops (the global recorder is never installed).
55///
56/// # Environment Variables
57///
58/// - `OS__METRICS__ADDR` — StatsD address (e.g. `127.0.0.1:8125` or `unixgram:///tmp/statsd.sock`)
59/// - `OS__METRICS__PREFIX` — global metric name prefix
60/// - `OS__METRICS__BUFFER_SIZE` — maximum payload length in bytes
61/// - `OS__METRICS__TAGS__KEY=value` — per-key global tags
62#[derive(Clone, Debug, Deserialize, Serialize)]
63pub struct MetricsConfig {
64    /// Remote address to forward metrics to.
65    ///
66    /// When `None`, metrics are disabled (the global recorder is not installed and all
67    /// metric calls are no-ops).
68    ///
69    /// For UDP, the address must be in the format `<host>:<port>` (e.g. `127.0.0.1:8125`).
70    /// For Unix domain sockets, use the format `<scheme>://<path>`, where the scheme is
71    /// either `unix` (stream, `SOCK_STREAM`) or `unixgram` (datagram, `SOCK_DGRAM`).
72    ///
73    /// # Default
74    ///
75    /// `None` (metrics disabled)
76    ///
77    /// # Environment Variable
78    ///
79    /// `OS__METRICS__ADDR`
80    pub addr: Option<String>,
81
82    /// Global prefix prepended to every metric name.
83    ///
84    /// The prefix is prepended to every metric name, with a `.` separator added automatically.
85    ///
86    /// # Default
87    ///
88    /// `"objectstore"`
89    ///
90    /// # Environment Variable
91    ///
92    /// `OS__METRICS__PREFIX`
93    #[serde(default = "default_prefix")]
94    pub prefix: String,
95
96    /// Maximum payload length in bytes.
97    ///
98    /// Controls the maximum size per StatsD payload. Should match the Datadog Agent's
99    /// `dogstatsd_buffer_size` setting. If `None`, the exporter uses its default
100    /// (1432 bytes for UDP, 8192 bytes for Unix sockets).
101    ///
102    /// # Default
103    ///
104    /// `None` (exporter default)
105    ///
106    /// # Environment Variable
107    ///
108    /// `OS__METRICS__BUFFER_SIZE`
109    pub buffer_size: Option<usize>,
110
111    /// Global tags applied to all metrics.
112    ///
113    /// Key-value pairs attached to every emitted metric. Useful for identifying
114    /// environment, region, or other deployment-specific dimensions.
115    ///
116    /// # Default
117    ///
118    /// Empty (no tags)
119    ///
120    /// # Environment Variables
121    ///
122    /// Each tag is set individually:
123    /// - `OS__METRICS__TAGS__FOO=foo`
124    /// - `OS__METRICS__TAGS__BAR=bar`
125    ///
126    /// # YAML Example
127    ///
128    /// ```yaml
129    /// metrics:
130    ///   tags:
131    ///     foo: foo
132    ///     bar: bar
133    /// ```
134    pub tags: BTreeMap<String, String>,
135}
136
137fn default_prefix() -> String {
138    "objectstore".to_owned()
139}
140
141impl Default for MetricsConfig {
142    fn default() -> Self {
143        Self {
144            addr: None,
145            prefix: "objectstore".to_owned(),
146            buffer_size: None,
147            tags: BTreeMap::new(),
148        }
149    }
150}
151
152/// Initializes the global DogStatsD metrics exporter.
153///
154/// Returns `Ok(())` immediately when `config.addr` is `None` — in that case the
155/// global recorder is never installed and all `metrics` calls are no-ops.
156pub fn init(config: &MetricsConfig) -> Result<(), Error> {
157    let Some(ref addr) = config.addr else {
158        return Ok(());
159    };
160
161    tracing::info!("reporting metrics to statsd at {addr}");
162
163    let global_labels: Vec<metrics::Label> = config
164        .tags
165        .iter()
166        .map(|(k, v)| metrics::Label::new(k.clone(), v.clone()))
167        .collect();
168
169    let mut builder = DogStatsDBuilder::default()
170        .with_remote_address(addr)?
171        .with_telemetry(true)
172        .with_aggregation_mode(AggregationMode::Aggressive)
173        .send_histograms_as_distributions(true)
174        .with_histogram_sampling(true)
175        .set_global_prefix(&config.prefix)
176        .with_global_labels(global_labels);
177
178    if let Some(buffer_size) = config.buffer_size {
179        builder = builder.with_maximum_payload_length(buffer_size)?;
180    }
181
182    builder.install()?;
183
184    Ok(())
185}
186
187pub use mock::with_capturing_test_client;
188
189// ---------------------------------------------------------------------------
190// Macros
191// ---------------------------------------------------------------------------
192
193/// Emits a counter metric.
194///
195/// # Syntax
196///
197/// ```rust
198/// use objectstore_metrics::counter;
199///
200/// counter!("name": 1u64);
201/// counter!("name": 1u64, "tag" => "value");
202/// counter!("name": 1u64, "tag1" => "val1", "tag2" => "val2");
203/// ```
204#[macro_export]
205macro_rules! counter {
206    ($name:literal : $value:expr $(, $tag:literal => $tv:expr)* $(,)?) => {
207        $crate::_macro_support::metrics::counter!(
208            $name
209            $(, $tag => $crate::__label_value!($tv))*
210        )
211        .increment($value as u64);
212    };
213}
214
215/// Emits a gauge metric.
216///
217/// # Syntax
218///
219/// ```rust
220/// use objectstore_metrics::gauge;
221///
222/// gauge!("name": 1.0_f64);
223/// gauge!("name"@b: 1024_u64);
224/// gauge!("name": 1.0_f64, "tag" => "value");
225/// ```
226///
227/// The `@b` unit annotation converts via `as f64` (identity for byte counts).
228#[macro_export]
229macro_rules! gauge {
230    ($name:literal @b : $value:expr $(, $tag:literal => $tv:expr)* $(,)?) => {
231        $crate::_macro_support::metrics::gauge!(
232            $name
233            $(, $tag => $crate::__label_value!($tv))*
234        )
235        .set($value as f64);
236    };
237    ($name:literal : $value:expr $(, $tag:literal => $tv:expr)* $(,)?) => {
238        $crate::_macro_support::metrics::gauge!(
239            $name
240            $(, $tag => $crate::__label_value!($tv))*
241        )
242        .set($value as f64);
243    };
244}
245
246/// Emits a distribution (histogram) metric.
247///
248/// # Syntax
249///
250/// ```rust
251/// use std::time::Duration;
252/// use objectstore_metrics::distribution;
253///
254/// distribution!("name": 1.0_f64);
255/// distribution!("name"@s: Duration::from_secs(1));
256/// distribution!("name"@b: 1024_u64);
257/// distribution!("name"@s: Duration::from_secs(1), "tag" => "value");
258/// ```
259///
260/// - `@s` converts a [`Duration`](std::time::Duration) to seconds via `.as_secs_f64()`.
261/// - `@b` converts the value via `as f64` (identity for byte counts).
262/// - No annotation converts via `as f64`.
263#[macro_export]
264macro_rules! distribution {
265    ($name:literal @s : $value:expr $(, $tag:literal => $tv:expr)* $(,)?) => {
266        $crate::_macro_support::metrics::histogram!(
267            $name
268            $(, $tag => $crate::__label_value!($tv))*
269        )
270        .record($value.as_secs_f64());
271    };
272    ($name:literal @b : $value:expr $(, $tag:literal => $tv:expr)* $(,)?) => {
273        $crate::_macro_support::metrics::histogram!(
274            $name
275            $(, $tag => $crate::__label_value!($tv))*
276        )
277        .record($value as f64);
278    };
279    ($name:literal : $value:expr $(, $tag:literal => $tv:expr)* $(,)?) => {
280        $crate::_macro_support::metrics::histogram!(
281            $name
282            $(, $tag => $crate::__label_value!($tv))*
283        )
284        .record($value as f64);
285    };
286}
287
288/// Converts a tag value expression to a string suitable for a [`metrics::Label`].
289///
290/// This handles `&str`, `String`, integer types, and anything with a `Display` impl
291/// by converting through `format!`. It relies on specialization-free dispatching:
292/// `&str` and `String` pass through, everything else uses `format!`.
293#[doc(hidden)]
294#[macro_export]
295macro_rules! __label_value {
296    ($e:expr) => {{
297        // Use a trait-based dispatch that works for &str, String, and Display types.
298        // The metrics crate accepts Into<SharedString> which covers &str, String, etc.
299        $crate::_macro_support::metrics::SharedString::from(format!("{}", $e))
300    }};
301}