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), or updated
94/// via [`set_tag()`](TimerGuard::set_tag).
95#[must_use = "timer! returns a guard that records the metric on guard.record() or on drop, bind it to a variable"]
96pub struct TimerGuard {
97 start: std::time::Instant,
98 name: &'static str,
99 module_path: &'static str,
100 labels: Vec<metrics::Label>,
101 record_failure_on_drop: bool,
102 recorded: bool,
103}
104
105impl TimerGuard {
106 #[doc(hidden)]
107 pub fn new(name: &'static str, module_path: &'static str, labels: Vec<metrics::Label>) -> Self {
108 Self {
109 start: std::time::Instant::now(),
110 name,
111 module_path,
112 labels,
113 record_failure_on_drop: true,
114 recorded: false,
115 }
116 }
117
118 /// Returns the time elapsed since the guard was created.
119 pub fn elapsed(&self) -> std::time::Duration {
120 self.start.elapsed()
121 }
122
123 /// Adds a tag to the metric.
124 pub fn tag(mut self, key: &'static str, value: impl Into<metrics::SharedString>) -> Self {
125 self.labels.push(metrics::Label::new(key, value));
126 self
127 }
128
129 /// Sets a tag, replacing any existing tags with the same key.
130 ///
131 /// The updated tag is included when the guard records, including on drop.
132 pub fn set_tag(&mut self, key: &'static str, value: impl Into<metrics::SharedString>) {
133 self.labels.retain(|label| label.key() != key);
134 self.labels.push(metrics::Label::new(key, value));
135 }
136
137 /// Changes the behavior of this guard to always record the metric
138 /// with `success:true`, even on drop.
139 pub fn success(mut self) -> Self {
140 self.record_failure_on_drop = false;
141 self
142 }
143
144 /// Consumes the guard, recording the elapsed time with `success:true`.
145 pub fn record(mut self) {
146 self.emit("true");
147 }
148
149 fn emit(&mut self, success: &'static str) {
150 self.recorded = true;
151 let mut labels = std::mem::take(&mut self.labels);
152 labels.push(metrics::Label::new("success", success));
153 let key = metrics::Key::from_parts(self.name, labels);
154 let metadata = metrics::Metadata::new(
155 self.module_path,
156 metrics::Level::INFO,
157 Some(self.module_path),
158 );
159 metrics::with_recorder(|rec| {
160 rec.register_histogram(&key, &metadata)
161 .record(AsF64::as_f64(self.start.elapsed()));
162 });
163 }
164}
165
166impl Drop for TimerGuard {
167 fn drop(&mut self) {
168 if !self.recorded {
169 let success = if self.record_failure_on_drop {
170 "false"
171 } else {
172 "true"
173 };
174 self.emit(success);
175 }
176 }
177}
178
179/// Re-exports used by macro expansion. Not part of the public API.
180#[doc(hidden)]
181pub mod _macro_support {
182 pub use crate::AsF64;
183 pub use metrics;
184}
185
186/// Error type for metrics initialization.
187#[derive(Debug, thiserror::Error)]
188pub enum Error {
189 /// Failed to build the DogStatsD exporter.
190 #[error("failed to initialize metrics exporter: {0}")]
191 Build(#[from] metrics_exporter_dogstatsd::BuildError),
192}
193
194/// Configuration for the DogStatsD metrics exporter.
195///
196/// When `addr` is `None`, metrics are no-ops (the global recorder is never installed).
197///
198/// # Environment Variables
199///
200/// - `OS__METRICS__ADDR` — StatsD address (e.g. `127.0.0.1:8125` or `unixgram:///tmp/statsd.sock`)
201/// - `OS__METRICS__PREFIX` — global metric name prefix
202/// - `OS__METRICS__BUFFER_SIZE` — maximum payload length in bytes
203/// - `OS__METRICS__TAGS__KEY=value` — per-key global tags
204#[derive(Clone, Debug, Deserialize, Serialize)]
205pub struct MetricsConfig {
206 /// Remote address to forward metrics to.
207 ///
208 /// When `None`, metrics are disabled (the global recorder is not installed and all
209 /// metric calls are no-ops).
210 ///
211 /// For UDP, the address must be in the format `<host>:<port>` (e.g. `127.0.0.1:8125`).
212 /// For Unix domain sockets, use the format `<scheme>://<path>`, where the scheme is
213 /// either `unix` (stream, `SOCK_STREAM`) or `unixgram` (datagram, `SOCK_DGRAM`).
214 ///
215 /// # Default
216 ///
217 /// `None` (metrics disabled)
218 ///
219 /// # Environment Variable
220 ///
221 /// `OS__METRICS__ADDR`
222 pub addr: Option<String>,
223
224 /// Global prefix prepended to every metric name.
225 ///
226 /// The prefix is prepended to every metric name, with a `.` separator added automatically.
227 ///
228 /// # Default
229 ///
230 /// `"objectstore"`
231 ///
232 /// # Environment Variable
233 ///
234 /// `OS__METRICS__PREFIX`
235 #[serde(default = "default_prefix")]
236 pub prefix: String,
237
238 /// Maximum payload length in bytes.
239 ///
240 /// Controls the maximum size per StatsD payload. Should match the Datadog Agent's
241 /// `dogstatsd_buffer_size` setting. If `None`, the exporter uses its default
242 /// (1432 bytes for UDP, 8192 bytes for Unix sockets).
243 ///
244 /// # Default
245 ///
246 /// `None` (exporter default)
247 ///
248 /// # Environment Variable
249 ///
250 /// `OS__METRICS__BUFFER_SIZE`
251 pub buffer_size: Option<usize>,
252
253 /// Global tags applied to all metrics.
254 ///
255 /// Key-value pairs attached to every emitted metric. Useful for identifying
256 /// environment, region, or other deployment-specific dimensions.
257 ///
258 /// # Default
259 ///
260 /// Empty (no tags)
261 ///
262 /// # Environment Variables
263 ///
264 /// Each tag is set individually:
265 /// - `OS__METRICS__TAGS__FOO=foo`
266 /// - `OS__METRICS__TAGS__BAR=bar`
267 ///
268 /// # YAML Example
269 ///
270 /// ```yaml
271 /// metrics:
272 /// tags:
273 /// foo: foo
274 /// bar: bar
275 /// ```
276 #[serde(default)]
277 pub tags: BTreeMap<String, String>,
278}
279
280fn default_prefix() -> String {
281 "objectstore".to_owned()
282}
283
284impl Default for MetricsConfig {
285 fn default() -> Self {
286 Self {
287 addr: None,
288 prefix: "objectstore".to_owned(),
289 buffer_size: None,
290 tags: BTreeMap::new(),
291 }
292 }
293}
294
295/// Initializes the global DogStatsD metrics exporter.
296///
297/// Returns `Ok(())` immediately when `config.addr` is `None` — in that case the
298/// global recorder is never installed and all `metrics` calls are no-ops.
299pub fn init(config: &MetricsConfig) -> Result<(), Error> {
300 let Some(ref addr) = config.addr else {
301 return Ok(());
302 };
303
304 objectstore_log::info!("reporting metrics to statsd at {addr}");
305
306 let global_labels: Vec<metrics::Label> = config
307 .tags
308 .iter()
309 .map(|(k, v)| metrics::Label::new(k.clone(), v.clone()))
310 .collect();
311
312 let mut builder = DogStatsDBuilder::default()
313 .with_remote_address(addr)?
314 .with_telemetry(true)
315 .with_aggregation_mode(AggregationMode::Aggressive)
316 .send_histograms_as_distributions(true)
317 .with_histogram_sampling(true)
318 .set_global_prefix(&config.prefix)
319 .with_global_labels(global_labels);
320
321 if let Some(buffer_size) = config.buffer_size {
322 builder = builder.with_maximum_payload_length(buffer_size)?;
323 }
324
325 builder.install()?;
326
327 Ok(())
328}
329
330pub use mock::{with_capturing_test_client, with_capturing_test_client_async};
331
332// ---------------------------------------------------------------------------
333// Macros
334// ---------------------------------------------------------------------------
335
336/// Increments a counter metric.
337///
338/// # Syntax
339///
340/// ```rust
341/// use objectstore_metrics::count;
342///
343/// // Shorthand: increments by 1
344/// count!("server.start");
345/// count!("server.requests", route = "/v1/test", method = "GET");
346///
347/// // Explicit increment value
348/// count!("server.requests" += 5);
349/// count!("server.requests" += 5, route = "/v1/test");
350/// ```
351///
352/// Tag keys are identifiers; tag values must implement `Into<SharedString>`
353/// (use `.to_string()` for integers or non-string types).
354#[macro_export]
355macro_rules! count {
356 // Shorthand: increment by 1
357 ($name:literal $(, $tag:ident = $tv:expr)* $(,)?) => {
358 $crate::_macro_support::metrics::counter!(
359 $name $(, stringify!($tag) => $tv)*
360 )
361 .increment(1)
362 };
363 // Explicit increment value
364 ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
365 $crate::_macro_support::metrics::counter!(
366 $name $(, stringify!($tag) => $tv)*
367 )
368 .increment($value as u64)
369 };
370}
371
372/// Sets, increments, or decrements a gauge metric.
373///
374/// # Syntax
375///
376/// ```rust
377/// use objectstore_metrics::gauge;
378///
379/// gauge!("runtime.num_workers" = 4usize);
380/// gauge!("connections" += 1usize);
381/// gauge!("connections" -= 1usize);
382/// gauge!("runtime.num_workers" = 4usize, pool = "default");
383/// ```
384///
385/// Values are converted to `f64` via [`AsF64`]. Supported types
386/// include `f64`, `Duration`, integer primitives, `u64`, and `usize`.
387///
388/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
389#[macro_export]
390macro_rules! gauge {
391 // Set
392 ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
393 $crate::_macro_support::metrics::gauge!(
394 $name $(, stringify!($tag) => $tv)*
395 )
396 .set($crate::_macro_support::AsF64::as_f64($value));
397 };
398 // Increment
399 ($name:literal += $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
400 $crate::_macro_support::metrics::gauge!(
401 $name $(, stringify!($tag) => $tv)*
402 )
403 .increment($crate::_macro_support::AsF64::as_f64($value));
404 };
405 // Decrement
406 ($name:literal -= $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
407 $crate::_macro_support::metrics::gauge!(
408 $name $(, stringify!($tag) => $tv)*
409 )
410 .decrement($crate::_macro_support::AsF64::as_f64($value));
411 };
412}
413
414/// Records a distribution (histogram) metric.
415///
416/// # Syntax
417///
418/// ```rust
419/// use std::time::Duration;
420/// use objectstore_metrics::record;
421///
422/// let elapsed = Duration::from_secs(1);
423/// record!("server.requests.duration" = elapsed);
424/// record!("server.requests.duration" = elapsed, route = "/v1/test");
425/// record!("put.size" = 1024u64, usecase = "default");
426/// ```
427///
428/// Values are converted to `f64` via [`AsF64`]. `Duration` is
429/// converted to fractional seconds automatically.
430///
431/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
432#[macro_export]
433macro_rules! record {
434 ($name:literal = $value:expr $(, $tag:ident = $tv:expr)* $(,)?) => {
435 $crate::_macro_support::metrics::histogram!(
436 $name $(, stringify!($tag) => $tv)*
437 )
438 .record($crate::_macro_support::AsF64::as_f64($value));
439 };
440}
441
442/// Starts a timer that records elapsed time in fractional seconds as a
443/// distribution metric.
444///
445/// Returns a [`TimerGuard`] that captures `Instant::now()` at creation.
446/// Call [`.record()`](TimerGuard::record) to record the metric with the
447/// tag `success:true`, or let it drop to record with `success:false`.
448///
449/// If you want to override this behavior and record the metric with
450/// `success:true` even on drop, call [`.success()`](TimerGuard::success)
451/// on the guard.
452///
453/// Tags can also be added after creation via [`.tag()`](TimerGuard::tag),
454/// which is useful when some tag values depend on the outcome of the
455/// timed operation.
456///
457/// # Syntax
458///
459/// ```rust
460/// use objectstore_metrics::timer;
461///
462/// let guard = timer!("server.requests.duration");
463/// let guard = timer!("server.requests.duration", route = "/v1/test");
464/// // ... do work ...
465/// guard.record(); // records elapsed time with success:true
466/// ```
467///
468/// ```rust
469/// use objectstore_metrics::timer;
470///
471/// let guard = timer!("server.requests.duration", route = "/v1/test");
472/// // ... determine backend ...
473/// let guard = guard.tag("backend", "gcs");
474/// guard.record();
475/// ```
476///
477/// Tag keys are identifiers; tag values must implement `Into<SharedString>`.
478#[macro_export]
479macro_rules! timer {
480 ($name:literal $(, $tag:ident = $tv:expr)* $(,)?) => {{
481 let labels = vec![
482 $($crate::_macro_support::metrics::Label::new(stringify!($tag), $tv),)*
483 ];
484 $crate::TimerGuard::new($name, module_path!(), labels)
485 }};
486}