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}