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}