From 7142b4ead30784a918181aed121a687b18e44fdb Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Mon, 13 Jul 2026 14:30:12 +0100 Subject: [PATCH 1/9] feat(metrics): add Counter implementation. Add shared atomic Counter storage with u64 and f64 support. - Introduce EncodeMetricValue for storage encoding - Add NamedMetric for registered names and help text --- Cargo.toml | 4 +- foundations-metrics-registry/src/proto/mod.rs | 2 +- foundations-metrics/Cargo.toml | 14 + foundations-metrics/src/lib.rs | 14 + foundations-metrics/src/metrics/counter.rs | 239 ++++++++++++++++++ foundations-metrics/src/metrics/mod.rs | 23 ++ foundations-metrics/src/registered.rs | 76 ++++++ foundations-metrics/src/value.rs | 6 + 8 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 foundations-metrics/Cargo.toml create mode 100644 foundations-metrics/src/lib.rs create mode 100644 foundations-metrics/src/metrics/counter.rs create mode 100644 foundations-metrics/src/metrics/mod.rs create mode 100644 foundations-metrics/src/registered.rs create mode 100644 foundations-metrics/src/value.rs diff --git a/Cargo.toml b/Cargo.toml index 3ba5af0..455387a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,9 @@ members = [ "foundations", "foundations-macros", - "foundations-sentry", + "foundations-metrics", "foundations-metrics-registry", + "foundations-sentry", "examples", "tools/gen-syscall-enum", ] @@ -43,6 +44,7 @@ anyhow = "1.0.102" backtrace = "0.3.76" foundations = { version = "5.8.0", path = "./foundations", default-features = false } foundations-macros = { version = "=5.8.0", path = "./foundations-macros", default-features = false } +foundations-metrics-registry = { version = "0.1.0", path = "./foundations-metrics-registry" } bindgen = { version = "0.72.1", default-features = false } cc = "1.2.61" cf-rustracing = "1.4.0" diff --git a/foundations-metrics-registry/src/proto/mod.rs b/foundations-metrics-registry/src/proto/mod.rs index 0b124a7..085299c 100644 --- a/foundations-metrics-registry/src/proto/mod.rs +++ b/foundations-metrics-registry/src/proto/mod.rs @@ -9,7 +9,7 @@ #[allow(missing_docs, unreachable_pub, clippy::all)] mod model; -pub use model::{BucketSpan, Histogram, Metric, MetricFamily, MetricType}; +pub use model::{BucketSpan, Counter, Histogram, Metric, MetricFamily, MetricType}; #[cfg(test)] mod tests { diff --git a/foundations-metrics/Cargo.toml b/foundations-metrics/Cargo.toml new file mode 100644 index 0000000..d156b1e --- /dev/null +++ b/foundations-metrics/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "foundations-metrics" +version.workspace = true +repository.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +foundations-metrics-registry = { workspace = true } +prometheus-client = "0.25.0" + +[lints] +workspace = true diff --git a/foundations-metrics/src/lib.rs b/foundations-metrics/src/lib.rs new file mode 100644 index 0000000..fb48203 --- /dev/null +++ b/foundations-metrics/src/lib.rs @@ -0,0 +1,14 @@ +//! The evolving layer of the `foundations` metrics stack. +//! +//! This crate provides the concrete metric types ([`Counter`], [`Gauge`], ...) +//! and the logic that encodes them into the Prometheus protobuf data model. It +//! builds on the slow-moving `foundations-metrics-registry` crate, which owns the +//! shared process-global registry and the stable wire format. +#![warn(missing_docs)] + +pub mod metrics; +mod registered; +mod value; + +pub use metrics::Counter; +pub use registered::NamedMetric; diff --git a/foundations-metrics/src/metrics/counter.rs b/foundations-metrics/src/metrics/counter.rs new file mode 100644 index 0000000..7bbb534 --- /dev/null +++ b/foundations-metrics/src/metrics/counter.rs @@ -0,0 +1,239 @@ +use std::marker::PhantomData; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use foundations_metrics_registry::proto::{self, MetricFamily, MetricType}; + +use super::IntoF64; + +use crate::value::EncodeMetricValue; + +/// A monotonically increasing value, such as a request count or bytes served. +/// +/// A [`Counter`] may only ever be incremented; it never decreases (aside from a +/// reset to zero on process restart). It is a cheap handle over shared atomic +/// storage: [`Clone`] hands out another reference to the *same* series, so the +/// same counter can be incremented from many places and read back as a single +/// total. +/// +/// # Examples +/// +/// ``` +/// use foundations_metrics::Counter; +/// +/// let requests: Counter = Counter::default(); +/// requests.inc(); +/// requests.inc_by(4); +/// assert_eq!(requests.get(), 5); +/// +/// // Clones share storage. +/// let alias = requests.clone(); +/// alias.inc(); +/// assert_eq!(requests.get(), 6); +/// ``` +#[derive(Debug)] +pub struct Counter { + val: Arc, + marker: PhantomData, +} + +/// Atomic storage backing a [`Counter`]. +/// +/// Implemented for the numeric types a counter can hold. Foundations provides +/// implementations for `u64` and `f64` over [`AtomicU64`]; downstream code may +/// implement it for custom storage. +pub trait Atomic { + /// Increments the value by one, returning the previous value. + fn inc(&self) -> N; + + /// Increments the value by `v`, returning the previous value. + fn inc_by(&self, v: N) -> N; + + /// Loads the current value. + fn get(&self) -> N; +} + +impl Atomic for AtomicU64 { + #[inline] + fn inc(&self) -> u64 { + self.inc_by(1) + } + + #[inline] + fn inc_by(&self, v: u64) -> u64 { + self.fetch_add(v, Ordering::Relaxed) + } + + #[inline] + fn get(&self) -> u64 { + self.load(Ordering::Relaxed) + } +} + +impl Atomic for AtomicU64 { + #[inline] + fn inc(&self) -> f64 { + self.inc_by(1.0) + } + + #[inline] + fn inc_by(&self, v: f64) -> f64 { + let mut old_bits = self.load(Ordering::Relaxed); + + loop { + let old = f64::from_bits(old_bits); + let new_bits = (old + v).to_bits(); + + match self.compare_exchange_weak( + old_bits, + new_bits, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => return old, + Err(actual) => old_bits = actual, + } + } + } + + #[inline] + fn get(&self) -> f64 { + f64::from_bits(self.load(Ordering::Relaxed)) + } +} + +impl> Counter { + /// Increments the counter by one, returning the previous value. + #[inline] + pub fn inc(&self) -> N { + self.val.inc() + } + + /// Increments the counter by `v`, returning the previous value. + #[inline] + pub fn inc_by(&self, v: N) -> N { + self.val.inc_by(v) + } + + /// Returns the current value. + #[inline] + pub fn get(&self) -> N { + self.val.get() + } +} + +impl Clone for Counter { + fn clone(&self) -> Self { + Self { + val: Arc::clone(&self.val), + marker: PhantomData, + } + } +} + +impl Default for Counter { + fn default() -> Self { + Self { + val: Arc::new(A::default()), + marker: PhantomData, + } + } +} + +/// Builds the protobuf `MetricFamily` for a counter value. +/// +/// The name/help are left empty here; they are populated at registration and +/// encode time. +fn encode_counter(value: f64) -> Vec { + vec![MetricFamily { + name: Some(String::new()), + help: None, + r#type: Some(MetricType::Counter as i32), + metric: vec![proto::Metric { + counter: Some(proto::Counter { + value: Some(value), + ..Default::default() + }), + ..Default::default() + }], + unit: None, + }] +} + +impl EncodeMetricValue for Counter +where + N: IntoF64, + A: Atomic + Send + Sync + 'static, +{ + fn encode_metric_value(&self) -> Vec { + encode_counter(self.get().into_f64()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encoded_value(counter: Counter) -> f64 + where + N: IntoF64, + A: Atomic + Send + Sync + 'static, + { + let families = counter.encode_metric_value(); + let family = &families[0]; + + assert_eq!(family.r#type, Some(MetricType::Counter as i32)); + assert_eq!(family.metric.len(), 1); + + family.metric[0] + .counter + .as_ref() + .and_then(|counter| counter.value) + .expect("encoded counter has a value") + } + + #[test] + fn inc_inc_by_and_get() { + let counter = Counter::::default(); + assert_eq!(counter.get(), 0); + + assert_eq!(counter.inc(), 0); + assert_eq!(counter.get(), 1); + + assert_eq!(counter.inc_by(4), 1); + assert_eq!(counter.get(), 5); + } + + #[test] + fn f64_counter_inc_and_get() { + let counter = Counter::::default(); + assert_eq!(counter.get(), 0.0); + + assert_eq!(counter.inc(), 0.0); + assert_eq!(counter.inc_by(1.5), 1.0); + assert_eq!(counter.get(), 2.5); + } + + #[test] + fn clones_share_storage() { + let counter: Counter = Counter::default(); + let alias = counter.clone(); + + counter.inc(); + alias.inc(); + + assert_eq!(counter.get(), 2); + assert_eq!(alias.get(), 2); + } + + #[test] + fn encodes_counter_values() { + let unsigned = Counter::::default(); + unsigned.inc_by(7); + assert_eq!(encoded_value(unsigned), 7.0); + + let float = Counter::::default(); + float.inc_by(1.5); + assert_eq!(encoded_value(float), 1.5); + } +} diff --git a/foundations-metrics/src/metrics/mod.rs b/foundations-metrics/src/metrics/mod.rs new file mode 100644 index 0000000..0d04e29 --- /dev/null +++ b/foundations-metrics/src/metrics/mod.rs @@ -0,0 +1,23 @@ +//! The metrics module that contains scalar metrics (like Counter, Gauge, ...) and non-scalars, like Family. + +mod counter; + +pub use counter::{Atomic, Counter}; + +trait IntoF64: Send + Sync + 'static { + fn into_f64(self) -> f64; +} + +macro_rules! impl_into_f64 { + ($($ty:ty),+ $(,)?) => { + $( + impl IntoF64 for $ty { + fn into_f64(self) -> f64 { + self as f64 + } + } + )+ + }; +} + +impl_into_f64!(i32, u32, f32, i64, u64, f64); diff --git a/foundations-metrics/src/registered.rs b/foundations-metrics/src/registered.rs new file mode 100644 index 0000000..cbe6ab9 --- /dev/null +++ b/foundations-metrics/src/registered.rs @@ -0,0 +1,76 @@ +use foundations_metrics_registry::EncodeMetric; +use foundations_metrics_registry::MetricFamily; + +use crate::value::EncodeMetricValue; + +/// A metric paired with the name and help text it is exported under. +/// +/// Storage types (`Counter`, `Gauge`, ...) hold only their value and encode +/// themselves with relative names (a suffix such as `""`, `_min`, or `_max`). +/// `NamedMetric` supplies the registered base name and help text, bridging the +/// internal `EncodeMetricValue` storage trait to the public +/// [`EncodeMetric`](foundations_metrics_registry::EncodeMetric) registry trait. +pub struct NamedMetric { + name: &'static str, + help: &'static str, + metric: M, +} + +impl NamedMetric { + /// Wraps `metric` with the `name` and `help` it is exported under. + pub fn new(name: &'static str, help: &'static str, metric: M) -> Self { + Self { name, help, metric } + } +} + +impl EncodeMetric for NamedMetric +where + M: EncodeMetricValue, +{ + fn encode(&self) -> Vec { + let mut families = self.metric.encode_metric_value(); + + for family in &mut families { + let suffix = family.name.take().unwrap_or_default(); + family.name = Some(format!("{}{}", self.name, suffix)); + + if family.help.is_none() { + family.help = Some(self.help.to_owned()); + } + } + + families + } +} + +#[cfg(test)] +mod tests { + use foundations_metrics_registry::proto::MetricType; + + use super::*; + use crate::Counter; + + #[test] + fn rewrites_relative_name_and_fills_help() { + let counter = Counter::::default(); + let named = NamedMetric::new( + "http_requests_total", + "Number of requests.", + counter.clone(), + ); + + counter.inc_by(5); + + let families = named.encode(); + assert_eq!(families.len(), 1); + + let family = &families[0]; + assert_eq!(family.name.as_deref(), Some("http_requests_total")); + assert_eq!(family.help.as_deref(), Some("Number of requests.")); + assert_eq!(family.r#type, Some(MetricType::Counter as i32)); + assert_eq!( + family.metric[0].counter.as_ref().and_then(|c| c.value), + Some(5.0) + ); + } +} diff --git a/foundations-metrics/src/value.rs b/foundations-metrics/src/value.rs new file mode 100644 index 0000000..ff0d61c --- /dev/null +++ b/foundations-metrics/src/value.rs @@ -0,0 +1,6 @@ +use foundations_metrics_registry::MetricFamily; + +/// Internal trait for storage types +pub(crate) trait EncodeMetricValue: Send + Sync + 'static { + fn encode_metric_value(&self) -> Vec; +} From ab275efddaa8c8cf485f3924a023aabb8205aee3 Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Mon, 13 Jul 2026 16:24:16 +0100 Subject: [PATCH 2/9] feat(metrics): implement Gauge, RangeGauge and GaugeGuard --- foundations-metrics-registry/src/proto/mod.rs | 2 +- foundations-metrics/src/lib.rs | 2 +- foundations-metrics/src/metrics/gauge.rs | 429 ++++++++++++++++++ foundations-metrics/src/metrics/mod.rs | 2 + foundations-metrics/src/registered.rs | 37 +- foundations-metrics/src/value.rs | 8 +- 6 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 foundations-metrics/src/metrics/gauge.rs diff --git a/foundations-metrics-registry/src/proto/mod.rs b/foundations-metrics-registry/src/proto/mod.rs index 085299c..d250dd1 100644 --- a/foundations-metrics-registry/src/proto/mod.rs +++ b/foundations-metrics-registry/src/proto/mod.rs @@ -9,7 +9,7 @@ #[allow(missing_docs, unreachable_pub, clippy::all)] mod model; -pub use model::{BucketSpan, Counter, Histogram, Metric, MetricFamily, MetricType}; +pub use model::{BucketSpan, Counter, Gauge, Histogram, Metric, MetricFamily, MetricType}; #[cfg(test)] mod tests { diff --git a/foundations-metrics/src/lib.rs b/foundations-metrics/src/lib.rs index fb48203..2c3c79b 100644 --- a/foundations-metrics/src/lib.rs +++ b/foundations-metrics/src/lib.rs @@ -10,5 +10,5 @@ pub mod metrics; mod registered; mod value; -pub use metrics::Counter; +pub use metrics::{Counter, Gauge, GaugeGuard, RangeGauge}; pub use registered::NamedMetric; diff --git a/foundations-metrics/src/metrics/gauge.rs b/foundations-metrics/src/metrics/gauge.rs new file mode 100644 index 0000000..0e2be56 --- /dev/null +++ b/foundations-metrics/src/metrics/gauge.rs @@ -0,0 +1,429 @@ +//! Gauge metrics and their lifecycle helpers. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use foundations_metrics_registry::proto::{self, MetricFamily, MetricType}; +use prometheus_client::metrics::gauge::{Atomic as GaugeAtomic, Gauge as PrometheusGauge}; + +use crate::value::EncodeMetricValue; + +use super::IntoF64; + +/// A metric whose value may increase, decrease, or be set directly. +/// +/// This implementation delegates atomic storage and operations to +/// `prometheus-client`, while exposing only the explicit Foundations API. +/// Clones share the same underlying storage. +/// +/// The `u64`/`AtomicU64` defaults preserve the existing Foundations API. +#[derive(Debug)] +#[repr(transparent)] +pub struct Gauge(PrometheusGauge); + +impl Clone for Gauge { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Default for Gauge { + fn default() -> Self { + Self(PrometheusGauge::default()) + } +} + +impl> Gauge { + /// Increases the gauge by one, returning the previous value. + #[inline] + pub fn inc(&self) -> N { + self.0.inc() + } + + /// Increases the gauge by `v`, returning the previous value. + #[inline] + pub fn inc_by(&self, v: N) -> N { + self.0.inc_by(v) + } + + /// Decreases the gauge by one, returning the previous value. + #[inline] + pub fn dec(&self) -> N { + self.0.dec() + } + + /// Decreases the gauge by `v`, returning the previous value. + #[inline] + pub fn dec_by(&self, v: N) -> N { + self.0.dec_by(v) + } + + /// Sets the gauge to `v`, returning the previous value. + #[inline] + pub fn set(&self, v: N) -> N { + self.0.set(v) + } + + /// Returns the current value. + #[inline] + pub fn get(&self) -> N { + self.0.get() + } + + /// Returns a reference to the underlying atomic storage. + /// + /// This should only be used for advanced use-cases not directly supported by + /// the library. + #[inline] + pub fn inner(&self) -> &A { + self.0.inner() + } +} + +impl EncodeMetricValue for Gauge +where + N: IntoF64, + A: GaugeAtomic + Send + Sync + 'static, +{ + fn encode_metric_value(&self) -> Vec { + vec![MetricFamily { + name: Some(String::new()), + help: None, + r#type: Some(MetricType::Gauge as i32), + metric: vec![proto::Metric { + gauge: Some(proto::Gauge { + value: Some(self.get().into_f64()), + }), + ..Default::default() + }], + unit: None, + }] + } +} + +/// A gauge that also records the minimum and maximum values seen since the last +/// scrape. +/// +/// This gives visibility into the full range of a value within a scrape interval +/// with less overhead than a histogram. Reading the metric at encode time resets +/// the tracked minimum and maximum. It exports three series: the current value, +/// `_min`, and `_max`. +/// +/// # Examples +/// +/// ``` +/// use foundations_metrics::RangeGauge; +/// +/// let inflight = RangeGauge::default(); +/// for _ in 0..10 { +/// inflight.inc(); +/// } +/// for _ in 0..8 { +/// inflight.dec(); +/// } +/// assert_eq!(inflight.get(), 2); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct RangeGauge { + gauge: Gauge, + min: Arc, + max: Arc, + reset_cs: Arc>, +} + +impl RangeGauge { + /// Increases the gauge by one, returning the previous value. + pub fn inc(&self) -> u64 { + self.inc_by(1) + } + + /// Increases the gauge by `v`, returning the previous value. + pub fn inc_by(&self, v: u64) -> u64 { + let prev = self.gauge.inc_by(v); + self.update_max(prev + v); + prev + } + + /// Decreases the gauge by one, returning the previous value. + pub fn dec(&self) -> u64 { + self.dec_by(1) + } + + /// Decreases the gauge by `v`, returning the previous value. + pub fn dec_by(&self, v: u64) -> u64 { + let prev = self.gauge.dec_by(v); + self.update_min(prev - v); + prev + } + + /// Sets the gauge to `v`, returning the previous value. + pub fn set(&self, v: u64) -> u64 { + let prev = self.gauge.set(v); + self.update_max(v); + self.update_min(v); + prev + } + + /// Returns the current value of the gauge. + pub fn get(&self) -> u64 { + self.gauge.get() + } + + /// Exposes the inner atomic backing the current value. + /// + /// This should only be used for advanced use-cases not directly supported by + /// the library. + pub fn inner(&self) -> &AtomicU64 { + self.gauge.inner() + } + + fn update_max(&self, new_max: u64) { + self.max.fetch_max(new_max, Ordering::AcqRel); + } + + fn update_min(&self, new_min: u64) { + self.min.fetch_min(new_min, Ordering::AcqRel); + } + + /// Returns `(min, current, max)`, guaranteeing `min <= current <= max`, and + /// resets the tracked minimum and maximum to the current value. + fn get_values(&self) -> (u64, u64, u64) { + let _reset_guard = self.reset_cs.lock().unwrap(); + let current = self.get(); + + let min = std::cmp::min(current, self.min.swap(current, Ordering::AcqRel)); + let max = std::cmp::max(current, self.max.swap(current, Ordering::AcqRel)); + + let current_fixup = self.get(); + self.min.fetch_min(current_fixup, Ordering::AcqRel); + self.max.fetch_max(current_fixup, Ordering::AcqRel); + + (min, current, max) + } +} + +/// Builds a single-row gauge `MetricFamily` with a relative name `suffix`. +fn gauge_family(suffix: &str, value: u64) -> MetricFamily { + MetricFamily { + name: Some(suffix.to_owned()), + help: None, + r#type: Some(MetricType::Gauge as i32), + metric: vec![proto::Metric { + gauge: Some(proto::Gauge { + value: Some(value as f64), + }), + ..Default::default() + }], + unit: None, + } +} + +impl EncodeMetricValue for RangeGauge { + fn encode_metric_value(&self) -> Vec { + let (min, current, max) = self.get_values(); + + vec![ + gauge_family("", current), + gauge_family("_min", min), + gauge_family("_max", max), + ] + } +} + +/// Increments a gauge when created and decrements it when dropped. +/// +/// Useful for tracking the number of in-progress operations: hold the guard for +/// the duration of the work and the gauge reflects the live count. +/// +/// # Examples +/// +/// ``` +/// use foundations_metrics::{Gauge, GaugeGuard}; +/// +/// let connections: Gauge = Gauge::default(); +/// { +/// let _guard = GaugeGuard::new(connections.clone()); +/// assert_eq!(connections.get(), 1); +/// } +/// assert_eq!(connections.get(), 0); +/// ``` +pub struct GaugeGuard(G); + +impl GaugeGuard { + /// Creates a guard, incrementing the gauge now and decrementing it on drop. + pub fn new(gauge: G) -> Self { + gauge.inc(); + Self(gauge) + } +} + +impl Drop for GaugeGuard { + fn drop(&mut self) { + self.0.dec(); + } +} + +/// Helper trait for values supported by [`GaugeGuard`]. +pub trait GenericGauge { + /// Increments the gauge by one. + fn inc(&self); + + /// Decrements the gauge by one. + fn dec(&self); +} + +impl GenericGauge for Gauge { + fn inc(&self) { + Gauge::inc(self); + } + + fn dec(&self) { + Gauge::dec(self); + } +} + +impl GenericGauge for RangeGauge { + fn inc(&self) { + RangeGauge::inc(self); + } + + fn dec(&self) { + RangeGauge::dec(self); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicI32, AtomicI64, AtomicU32, AtomicU64}; + + use super::*; + + fn encoded_value(gauge: Gauge) -> f64 + where + N: IntoF64, + A: GaugeAtomic + Send + Sync + 'static, + { + let families = gauge.encode_metric_value(); + let family = &families[0]; + + assert_eq!(family.r#type, Some(MetricType::Gauge as i32)); + assert_eq!(family.metric.len(), 1); + + family.metric[0] + .gauge + .as_ref() + .and_then(|gauge| gauge.value) + .expect("encoded gauge has a value") + } + + #[test] + fn default_gauge_uses_u64_and_clones_share_storage() { + let gauge: Gauge = Gauge::default(); + let alias = gauge.clone(); + + assert!(std::ptr::eq(gauge.inner(), alias.inner())); + assert_eq!(gauge.set(4), 0); + assert_eq!(alias.inc(), 4); + assert_eq!(gauge.inc_by(3), 5); + assert_eq!(alias.dec(), 8); + assert_eq!(gauge.dec_by(2), 7); + assert_eq!(alias.get(), 5); + } + + #[test] + fn encodes_32_bit_gauge_values() { + let signed = Gauge::::default(); + signed.set(-3); + assert_eq!(encoded_value(signed), -3.0); + + let unsigned = Gauge::::default(); + unsigned.set(7); + assert_eq!(encoded_value(unsigned), 7.0); + + let float = Gauge::::default(); + float.set(1.5); + assert_eq!(encoded_value(float), 1.5); + } + + #[test] + fn encodes_64_bit_gauge_values() { + let signed = Gauge::::default(); + signed.set(-3); + assert_eq!(encoded_value(signed), -3.0); + + let unsigned = Gauge::::default(); + unsigned.set(7); + assert_eq!(encoded_value(unsigned), 7.0); + + let float = Gauge::::default(); + float.set(1.5); + assert_eq!(encoded_value(float), 1.5); + } + + /// Reads the encoded `(current, min, max)` triple; encoding resets min/max. + #[track_caller] + fn range_values(gauge: &RangeGauge) -> (u64, u64, u64) { + let families = gauge.encode_metric_value(); + assert_eq!(families.len(), 3); + assert_eq!(families[0].name.as_deref(), Some("")); + assert_eq!(families[1].name.as_deref(), Some("_min")); + assert_eq!(families[2].name.as_deref(), Some("_max")); + + let value = |family: &MetricFamily| { + family.metric[0] + .gauge + .as_ref() + .and_then(|gauge| gauge.value) + .expect("encoded range gauge has a value") as u64 + }; + + ( + value(&families[0]), + value(&families[1]), + value(&families[2]), + ) + } + + #[test] + fn range_gauge_tracks_and_resets_min_max() { + let gauge = RangeGauge::default(); + + assert_eq!(range_values(&gauge), (0, 0, 0)); + + gauge.inc(); + assert_eq!(range_values(&gauge), (1, 0, 1)); + assert_eq!(range_values(&gauge), (1, 1, 1)); + + gauge.dec(); + assert_eq!(range_values(&gauge), (0, 0, 1)); + assert_eq!(range_values(&gauge), (0, 0, 0)); + + gauge.inc_by(3); + gauge.dec_by(2); + assert_eq!(range_values(&gauge), (1, 0, 3)); + + gauge.inc_by(1); + gauge.dec_by(2); + assert_eq!(range_values(&gauge), (0, 0, 2)); + } + + #[test] + fn gauge_guard_inc_on_new_dec_on_drop() { + let gauge: Gauge = Gauge::default(); + { + let _guard = GaugeGuard::new(gauge.clone()); + assert_eq!(gauge.get(), 1); + } + assert_eq!(gauge.get(), 0); + } + + #[test] + fn gauge_guard_supports_range_gauge() { + let gauge = RangeGauge::default(); + { + let _guard = GaugeGuard::new(gauge.clone()); + assert_eq!(gauge.get(), 1); + } + assert_eq!(gauge.get(), 0); + } +} diff --git a/foundations-metrics/src/metrics/mod.rs b/foundations-metrics/src/metrics/mod.rs index 0d04e29..6026c1d 100644 --- a/foundations-metrics/src/metrics/mod.rs +++ b/foundations-metrics/src/metrics/mod.rs @@ -1,8 +1,10 @@ //! The metrics module that contains scalar metrics (like Counter, Gauge, ...) and non-scalars, like Family. mod counter; +mod gauge; pub use counter::{Atomic, Counter}; +pub use gauge::{Gauge, GaugeGuard, RangeGauge}; trait IntoF64: Send + Sync + 'static { fn into_f64(self) -> f64; diff --git a/foundations-metrics/src/registered.rs b/foundations-metrics/src/registered.rs index cbe6ab9..d143824 100644 --- a/foundations-metrics/src/registered.rs +++ b/foundations-metrics/src/registered.rs @@ -48,7 +48,7 @@ mod tests { use foundations_metrics_registry::proto::MetricType; use super::*; - use crate::Counter; + use crate::{Counter, RangeGauge}; #[test] fn rewrites_relative_name_and_fills_help() { @@ -73,4 +73,39 @@ mod tests { Some(5.0) ); } + + #[test] + fn prefixes_each_range_gauge_series() { + let gauge = RangeGauge::default(); + gauge.inc_by(3); + gauge.dec_by(2); + + let named = NamedMetric::new( + "inflight_requests", + "Number of requests awaiting a response.", + gauge, + ); + + let families = named.encode(); + let expected = [ + ("inflight_requests", 1.0), + ("inflight_requests_min", 0.0), + ("inflight_requests_max", 3.0), + ]; + + assert_eq!(families.len(), expected.len()); + + for (family, (name, value)) in families.iter().zip(expected) { + assert_eq!(family.name.as_deref(), Some(name)); + assert_eq!( + family.help.as_deref(), + Some("Number of requests awaiting a response.") + ); + assert_eq!(family.r#type, Some(MetricType::Gauge as i32)); + assert_eq!( + family.metric[0].gauge.as_ref().and_then(|g| g.value), + Some(value) + ); + } + } } diff --git a/foundations-metrics/src/value.rs b/foundations-metrics/src/value.rs index ff0d61c..26a4eec 100644 --- a/foundations-metrics/src/value.rs +++ b/foundations-metrics/src/value.rs @@ -1,6 +1,12 @@ use foundations_metrics_registry::MetricFamily; -/// Internal trait for storage types +/// Encodes metric storage independently of its registered identity. +/// +/// Each returned family must set `name` to a relative suffix. The primary +/// family uses `Some("")`; additional series use suffixes such as `Some("_min")` +/// or `Some("_max")`. [`NamedMetric`](crate::NamedMetric) prepends the registered +/// base name and supplies help text when it is absent. pub(crate) trait EncodeMetricValue: Send + Sync + 'static { + /// Encodes the current value into one or more relatively named families. fn encode_metric_value(&self) -> Vec; } From a94d495d398c8a577faee7676c7b432bfbfd86d5 Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Mon, 13 Jul 2026 16:35:13 +0100 Subject: [PATCH 3/9] fix(metrics): add Counter inner accessor Expose the underlying atomic storage to preserve compatibility with the legacy Counter API. --- foundations-metrics/src/metrics/counter.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/foundations-metrics/src/metrics/counter.rs b/foundations-metrics/src/metrics/counter.rs index 7bbb534..833f927 100644 --- a/foundations-metrics/src/metrics/counter.rs +++ b/foundations-metrics/src/metrics/counter.rs @@ -120,6 +120,12 @@ impl> Counter { pub fn get(&self) -> N { self.val.get() } + + /// Returns a reference to the underlying atomic storage. + #[inline] + pub fn inner(&self) -> &A { + self.val.as_ref() + } } impl Clone for Counter { From 938956c7bc35a8b06ae6b6f073ce1dc2fc06a0a3 Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Mon, 13 Jul 2026 16:37:26 +0100 Subject: [PATCH 4/9] chore(metrics): cleanup comments --- foundations-metrics/src/metrics/gauge.rs | 6 ------ foundations-metrics/src/value.rs | 10 +++++----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/foundations-metrics/src/metrics/gauge.rs b/foundations-metrics/src/metrics/gauge.rs index 0e2be56..e40037e 100644 --- a/foundations-metrics/src/metrics/gauge.rs +++ b/foundations-metrics/src/metrics/gauge.rs @@ -71,9 +71,6 @@ impl> Gauge { } /// Returns a reference to the underlying atomic storage. - /// - /// This should only be used for advanced use-cases not directly supported by - /// the library. #[inline] pub fn inner(&self) -> &A { self.0.inner() @@ -265,10 +262,7 @@ impl Drop for GaugeGuard { /// Helper trait for values supported by [`GaugeGuard`]. pub trait GenericGauge { - /// Increments the gauge by one. fn inc(&self); - - /// Decrements the gauge by one. fn dec(&self); } diff --git a/foundations-metrics/src/value.rs b/foundations-metrics/src/value.rs index 26a4eec..fb1bd5b 100644 --- a/foundations-metrics/src/value.rs +++ b/foundations-metrics/src/value.rs @@ -1,11 +1,11 @@ use foundations_metrics_registry::MetricFamily; -/// Encodes metric storage independently of its registered identity. +/// Encodes metric values before registration metadata is applied. /// -/// Each returned family must set `name` to a relative suffix. The primary -/// family uses `Some("")`; additional series use suffixes such as `Some("_min")` -/// or `Some("_max")`. [`NamedMetric`](crate::NamedMetric) prepends the registered -/// base name and supplies help text when it is absent. +/// Each returned [`MetricFamily`] must set `name` to a relative suffix. The +/// primary series uses `Some("")`; additional series use names such as +/// `Some("_min")` and `Some("_max")`. [`NamedMetric`](crate::NamedMetric) +/// prepends the registered metric name and fills in missing help text. pub(crate) trait EncodeMetricValue: Send + Sync + 'static { /// Encodes the current value into one or more relatively named families. fn encode_metric_value(&self) -> Vec; From a8773e11e86eba076e89ed45090c1fc37c02db20 Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Mon, 13 Jul 2026 18:22:09 +0100 Subject: [PATCH 5/9] refactor(metrics): own scalar metric atomic storage - Replace the prometheus-client gauge wrapper with Foundations-owned storage and atomic traits. - Share floating-point atomic updates between counters and gauges. --- foundations-metrics/src/lib.rs | 2 +- foundations-metrics/src/metrics/counter.rs | 29 +--- foundations-metrics/src/metrics/gauge.rs | 181 +++++++++++++++++---- foundations-metrics/src/metrics/mod.rs | 26 ++- 4 files changed, 177 insertions(+), 61 deletions(-) diff --git a/foundations-metrics/src/lib.rs b/foundations-metrics/src/lib.rs index 2c3c79b..618dc4e 100644 --- a/foundations-metrics/src/lib.rs +++ b/foundations-metrics/src/lib.rs @@ -10,5 +10,5 @@ pub mod metrics; mod registered; mod value; -pub use metrics::{Counter, Gauge, GaugeGuard, RangeGauge}; +pub use metrics::{Counter, CounterAtomic, Gauge, GaugeAtomic, GaugeGuard, RangeGauge}; pub use registered::NamedMetric; diff --git a/foundations-metrics/src/metrics/counter.rs b/foundations-metrics/src/metrics/counter.rs index 833f927..9545395 100644 --- a/foundations-metrics/src/metrics/counter.rs +++ b/foundations-metrics/src/metrics/counter.rs @@ -42,7 +42,7 @@ pub struct Counter { /// Implemented for the numeric types a counter can hold. Foundations provides /// implementations for `u64` and `f64` over [`AtomicU64`]; downstream code may /// implement it for custom storage. -pub trait Atomic { +pub trait CounterAtomic { /// Increments the value by one, returning the previous value. fn inc(&self) -> N; @@ -53,7 +53,7 @@ pub trait Atomic { fn get(&self) -> N; } -impl Atomic for AtomicU64 { +impl CounterAtomic for AtomicU64 { #[inline] fn inc(&self) -> u64 { self.inc_by(1) @@ -70,7 +70,7 @@ impl Atomic for AtomicU64 { } } -impl Atomic for AtomicU64 { +impl CounterAtomic for AtomicU64 { #[inline] fn inc(&self) -> f64 { self.inc_by(1.0) @@ -78,22 +78,7 @@ impl Atomic for AtomicU64 { #[inline] fn inc_by(&self, v: f64) -> f64 { - let mut old_bits = self.load(Ordering::Relaxed); - - loop { - let old = f64::from_bits(old_bits); - let new_bits = (old + v).to_bits(); - - match self.compare_exchange_weak( - old_bits, - new_bits, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => return old, - Err(actual) => old_bits = actual, - } - } + super::update_f64(self, |old| old + v) } #[inline] @@ -102,7 +87,7 @@ impl Atomic for AtomicU64 { } } -impl> Counter { +impl> Counter { /// Increments the counter by one, returning the previous value. #[inline] pub fn inc(&self) -> N { @@ -169,7 +154,7 @@ fn encode_counter(value: f64) -> Vec { impl EncodeMetricValue for Counter where N: IntoF64, - A: Atomic + Send + Sync + 'static, + A: CounterAtomic + Send + Sync + 'static, { fn encode_metric_value(&self) -> Vec { encode_counter(self.get().into_f64()) @@ -183,7 +168,7 @@ mod tests { fn encoded_value(counter: Counter) -> f64 where N: IntoF64, - A: Atomic + Send + Sync + 'static, + A: CounterAtomic + Send + Sync + 'static, { let families = counter.encode_metric_value(); let family = &families[0]; diff --git a/foundations-metrics/src/metrics/gauge.rs b/foundations-metrics/src/metrics/gauge.rs index e40037e..b22f264 100644 --- a/foundations-metrics/src/metrics/gauge.rs +++ b/foundations-metrics/src/metrics/gauge.rs @@ -1,10 +1,8 @@ -//! Gauge metrics and their lifecycle helpers. - -use std::sync::atomic::{AtomicU64, Ordering}; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use foundations_metrics_registry::proto::{self, MetricFamily, MetricType}; -use prometheus_client::metrics::gauge::{Atomic as GaugeAtomic, Gauge as PrometheusGauge}; use crate::value::EncodeMetricValue; @@ -12,24 +10,154 @@ use super::IntoF64; /// A metric whose value may increase, decrease, or be set directly. /// -/// This implementation delegates atomic storage and operations to -/// `prometheus-client`, while exposing only the explicit Foundations API. -/// Clones share the same underlying storage. +/// This implementation owns shared atomic storage and exposes only the explicit +/// Foundations API. Clones share the same underlying storage. /// /// The `u64`/`AtomicU64` defaults preserve the existing Foundations API. #[derive(Debug)] -#[repr(transparent)] -pub struct Gauge(PrometheusGauge); +pub struct Gauge { + val: Arc, + marker: PhantomData, +} impl Clone for Gauge { fn clone(&self) -> Self { - Self(self.0.clone()) + Self { + val: Arc::clone(&self.val), + marker: PhantomData, + } } } impl Default for Gauge { fn default() -> Self { - Self(PrometheusGauge::default()) + Self { + val: Arc::new(A::default()), + marker: PhantomData, + } + } +} + +/// Atomic storage backing a [`Gauge`]. +/// +/// Implemented for the numeric types a gauge can hold. Foundations provides +/// implementations over the standard library's 64-bit atomics (`i64`, `u64`, +/// and `f64`); downstream code may implement it for custom storage. +/// +/// Every method returns the value held *before* the operation was applied. +pub trait GaugeAtomic { + /// Increases the value by one, returning the previous value. + fn inc(&self) -> N; + + /// Increases the value by `v`, returning the previous value. + fn inc_by(&self, v: N) -> N; + + /// Decreases the value by one, returning the previous value. + fn dec(&self) -> N; + + /// Decreases the value by `v`, returning the previous value. + fn dec_by(&self, v: N) -> N; + + /// Sets the value to `v`, returning the previous value. + fn set(&self, v: N) -> N; + + /// Loads the current value. + fn get(&self) -> N; +} + +impl GaugeAtomic for AtomicI64 { + #[inline] + fn inc(&self) -> i64 { + self.inc_by(1) + } + + #[inline] + fn inc_by(&self, v: i64) -> i64 { + self.fetch_add(v, Ordering::Relaxed) + } + + #[inline] + fn dec(&self) -> i64 { + self.dec_by(1) + } + + #[inline] + fn dec_by(&self, v: i64) -> i64 { + self.fetch_sub(v, Ordering::Relaxed) + } + + #[inline] + fn set(&self, v: i64) -> i64 { + self.swap(v, Ordering::Relaxed) + } + + #[inline] + fn get(&self) -> i64 { + self.load(Ordering::Relaxed) + } +} + +impl GaugeAtomic for AtomicU64 { + #[inline] + fn inc(&self) -> u64 { + self.inc_by(1) + } + + #[inline] + fn inc_by(&self, v: u64) -> u64 { + self.fetch_add(v, Ordering::Relaxed) + } + + #[inline] + fn dec(&self) -> u64 { + self.dec_by(1) + } + + #[inline] + fn dec_by(&self, v: u64) -> u64 { + self.fetch_sub(v, Ordering::Relaxed) + } + + #[inline] + fn set(&self, v: u64) -> u64 { + self.swap(v, Ordering::Relaxed) + } + + #[inline] + fn get(&self) -> u64 { + self.load(Ordering::Relaxed) + } +} + +impl GaugeAtomic for AtomicU64 { + #[inline] + fn inc(&self) -> f64 { + self.inc_by(1.0) + } + + #[inline] + fn inc_by(&self, v: f64) -> f64 { + super::update_f64(self, |old| old + v) + } + + #[inline] + fn dec(&self) -> f64 { + self.dec_by(1.0) + } + + #[inline] + fn dec_by(&self, v: f64) -> f64 { + super::update_f64(self, |old| old - v) + } + + #[inline] + fn set(&self, v: f64) -> f64 { + f64::from_bits(self.swap(v.to_bits(), Ordering::Relaxed)) + } + + #[inline] + fn get(&self) -> f64 { + f64::from_bits(self.load(Ordering::Relaxed)) } } @@ -37,43 +165,43 @@ impl> Gauge { /// Increases the gauge by one, returning the previous value. #[inline] pub fn inc(&self) -> N { - self.0.inc() + self.val.inc() } /// Increases the gauge by `v`, returning the previous value. #[inline] pub fn inc_by(&self, v: N) -> N { - self.0.inc_by(v) + self.val.inc_by(v) } /// Decreases the gauge by one, returning the previous value. #[inline] pub fn dec(&self) -> N { - self.0.dec() + self.val.dec() } /// Decreases the gauge by `v`, returning the previous value. #[inline] pub fn dec_by(&self, v: N) -> N { - self.0.dec_by(v) + self.val.dec_by(v) } /// Sets the gauge to `v`, returning the previous value. #[inline] pub fn set(&self, v: N) -> N { - self.0.set(v) + self.val.set(v) } /// Returns the current value. #[inline] pub fn get(&self) -> N { - self.0.get() + self.val.get() } /// Returns a reference to the underlying atomic storage. #[inline] pub fn inner(&self) -> &A { - self.0.inner() + self.val.as_ref() } } @@ -288,7 +416,7 @@ impl GenericGauge for RangeGauge { #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicI32, AtomicI64, AtomicU32, AtomicU64}; + use std::sync::atomic::{AtomicI64, AtomicU64}; use super::*; @@ -324,21 +452,6 @@ mod tests { assert_eq!(alias.get(), 5); } - #[test] - fn encodes_32_bit_gauge_values() { - let signed = Gauge::::default(); - signed.set(-3); - assert_eq!(encoded_value(signed), -3.0); - - let unsigned = Gauge::::default(); - unsigned.set(7); - assert_eq!(encoded_value(unsigned), 7.0); - - let float = Gauge::::default(); - float.set(1.5); - assert_eq!(encoded_value(float), 1.5); - } - #[test] fn encodes_64_bit_gauge_values() { let signed = Gauge::::default(); diff --git a/foundations-metrics/src/metrics/mod.rs b/foundations-metrics/src/metrics/mod.rs index 6026c1d..a911005 100644 --- a/foundations-metrics/src/metrics/mod.rs +++ b/foundations-metrics/src/metrics/mod.rs @@ -1,10 +1,28 @@ -//! The metrics module that contains scalar metrics (like Counter, Gauge, ...) and non-scalars, like Family. +//! The metrics module that contains scalar metrics (like Counter, Gauge, ...) and non-scalars, +//! like Histogram, NativeHistogram, and also Family. + +use std::sync::atomic::{AtomicU64, Ordering}; mod counter; mod gauge; -pub use counter::{Atomic, Counter}; -pub use gauge::{Gauge, GaugeGuard, RangeGauge}; +pub use counter::{Counter, CounterAtomic}; +pub use gauge::{Gauge, GaugeAtomic, GaugeGuard, RangeGauge}; + +fn update_f64(atomic: &AtomicU64, f: impl Fn(f64) -> f64) -> f64 { + let mut old_bits = atomic.load(Ordering::Relaxed); + + loop { + let old = f64::from_bits(old_bits); + let new_bits = f(old).to_bits(); + + match atomic.compare_exchange_weak(old_bits, new_bits, Ordering::Relaxed, Ordering::Relaxed) + { + Ok(_) => return old, + Err(actual) => old_bits = actual, + } + } +} trait IntoF64: Send + Sync + 'static { fn into_f64(self) -> f64; @@ -22,4 +40,4 @@ macro_rules! impl_into_f64 { }; } -impl_into_f64!(i32, u32, f32, i64, u64, f64); +impl_into_f64!(i64, u64, f64); From fccfa5d07b31a1b62fbdb5a541eb32a211ed94a9 Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Tue, 14 Jul 2026 08:54:24 +0100 Subject: [PATCH 6/9] feat(metrics): re-export registry API --- foundations-metrics/src/lib.rs | 3 +++ foundations-metrics/src/metrics/counter.rs | 4 ++-- foundations-metrics/src/metrics/gauge.rs | 4 ++-- foundations-metrics/src/registered.rs | 7 ++----- foundations-metrics/src/value.rs | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/foundations-metrics/src/lib.rs b/foundations-metrics/src/lib.rs index 618dc4e..21344c0 100644 --- a/foundations-metrics/src/lib.rs +++ b/foundations-metrics/src/lib.rs @@ -10,5 +10,8 @@ pub mod metrics; mod registered; mod value; +pub use foundations_metrics_registry::{ + EncodeMetric, IntoMetrics, MetricFamily, RegistrationMetadata, register, +}; pub use metrics::{Counter, CounterAtomic, Gauge, GaugeAtomic, GaugeGuard, RangeGauge}; pub use registered::NamedMetric; diff --git a/foundations-metrics/src/metrics/counter.rs b/foundations-metrics/src/metrics/counter.rs index 9545395..31aaa84 100644 --- a/foundations-metrics/src/metrics/counter.rs +++ b/foundations-metrics/src/metrics/counter.rs @@ -2,11 +2,11 @@ use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use foundations_metrics_registry::proto::{self, MetricFamily, MetricType}; +use foundations_metrics_registry::proto::{self, MetricType}; use super::IntoF64; -use crate::value::EncodeMetricValue; +use crate::{MetricFamily, value::EncodeMetricValue}; /// A monotonically increasing value, such as a request count or bytes served. /// diff --git a/foundations-metrics/src/metrics/gauge.rs b/foundations-metrics/src/metrics/gauge.rs index b22f264..0b7dfbe 100644 --- a/foundations-metrics/src/metrics/gauge.rs +++ b/foundations-metrics/src/metrics/gauge.rs @@ -2,9 +2,9 @@ use std::marker::PhantomData; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use foundations_metrics_registry::proto::{self, MetricFamily, MetricType}; +use foundations_metrics_registry::proto::{self, MetricType}; -use crate::value::EncodeMetricValue; +use crate::{MetricFamily, value::EncodeMetricValue}; use super::IntoF64; diff --git a/foundations-metrics/src/registered.rs b/foundations-metrics/src/registered.rs index d143824..e033c17 100644 --- a/foundations-metrics/src/registered.rs +++ b/foundations-metrics/src/registered.rs @@ -1,7 +1,4 @@ -use foundations_metrics_registry::EncodeMetric; -use foundations_metrics_registry::MetricFamily; - -use crate::value::EncodeMetricValue; +use crate::{EncodeMetric, MetricFamily, value::EncodeMetricValue}; /// A metric paired with the name and help text it is exported under. /// @@ -9,7 +6,7 @@ use crate::value::EncodeMetricValue; /// themselves with relative names (a suffix such as `""`, `_min`, or `_max`). /// `NamedMetric` supplies the registered base name and help text, bridging the /// internal `EncodeMetricValue` storage trait to the public -/// [`EncodeMetric`](foundations_metrics_registry::EncodeMetric) registry trait. +/// [`EncodeMetric`] registry trait. pub struct NamedMetric { name: &'static str, help: &'static str, diff --git a/foundations-metrics/src/value.rs b/foundations-metrics/src/value.rs index fb1bd5b..ed98433 100644 --- a/foundations-metrics/src/value.rs +++ b/foundations-metrics/src/value.rs @@ -1,4 +1,4 @@ -use foundations_metrics_registry::MetricFamily; +use crate::MetricFamily; /// Encodes metric values before registration metadata is applied. /// From 95dc866bc5847abb49ec5ed363fd26baa855bffc Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Wed, 15 Jul 2026 09:52:26 +0100 Subject: [PATCH 7/9] docs(metrics): improve comments & examples in gauge.rs --- foundations-metrics/src/metrics/gauge.rs | 91 +++++++++++++++++++----- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/foundations-metrics/src/metrics/gauge.rs b/foundations-metrics/src/metrics/gauge.rs index 0b7dfbe..dc611ac 100644 --- a/foundations-metrics/src/metrics/gauge.rs +++ b/foundations-metrics/src/metrics/gauge.rs @@ -10,10 +10,28 @@ use super::IntoF64; /// A metric whose value may increase, decrease, or be set directly. /// -/// This implementation owns shared atomic storage and exposes only the explicit -/// Foundations API. Clones share the same underlying storage. +/// It is a cheap handle over shared atomic storage: [`Clone`] hands out another +/// reference to the *same* series, so the gauge can be updated from many places +/// and read back as a single current value. /// /// The `u64`/`AtomicU64` defaults preserve the existing Foundations API. +/// +/// # Examples +/// +/// ``` +/// use foundations_metrics::Gauge; +/// +/// let connections: Gauge = Gauge::default(); +/// connections.inc(); +/// connections.inc_by(4); +/// connections.dec_by(2); +/// assert_eq!(connections.get(), 3); +/// +/// // Clones share storage. +/// let alias = connections.clone(); +/// alias.set(10); +/// assert_eq!(connections.get(), 10); +/// ``` #[derive(Debug)] pub struct Gauge { val: Arc, @@ -236,17 +254,28 @@ where /// /// # Examples /// -/// ``` -/// use foundations_metrics::RangeGauge; +/// ```ignore +/// use foundations_metrics::{metrics, RangeGauge}; /// -/// let inflight = RangeGauge::default(); -/// for _ in 0..10 { -/// inflight.inc(); +/// #[metrics] +/// pub mod my_app_metrics { +/// /// Number of requests awaiting a response. +/// pub fn inflight_requests() -> RangeGauge; /// } -/// for _ in 0..8 { -/// inflight.dec(); +/// +/// fn usage() { +/// for _ in 0..10 { +/// my_app_metrics::inflight_requests().inc(); +/// } +/// for _ in 0..8 { +/// my_app_metrics::inflight_requests().dec(); +/// } +/// +/// // If scraped now, the metric exports these three series: +/// // inflight_requests 2 +/// // inflight_requests_min 0 +/// // inflight_requests_max 10 /// } -/// assert_eq!(inflight.get(), 2); /// ``` #[derive(Debug, Clone, Default)] pub struct RangeGauge { @@ -313,16 +342,30 @@ impl RangeGauge { /// Returns `(min, current, max)`, guaranteeing `min <= current <= max`, and /// resets the tracked minimum and maximum to the current value. fn get_values(&self) -> (u64, u64, u64) { + // Avoid data races by ensuring only one thread can perform the reset operation. let _reset_guard = self.reset_cs.lock().unwrap(); + + // First, get the current metric. let current = self.get(); + // Obtain min and max by swapping their contents with the current value. + // It is possible that current == min and another thread decremented current before we + // read its value, but has not yet decremented min. Enforce min <= current to account for + // that race. The same caveat applies to max. let min = std::cmp::min(current, self.min.swap(current, Ordering::AcqRel)); let max = std::cmp::max(current, self.max.swap(current, Ordering::AcqRel)); + // The current value may have changed between reading it and resetting min/max. Read it + // once more and ensure subsequent scrapes retain the invariant min <= current <= max. let current_fixup = self.get(); self.min.fetch_min(current_fixup, Ordering::AcqRel); self.max.fetch_max(current_fixup, Ordering::AcqRel); + // | min | c | max | + // T1: read current | 1 | 1 | 1 | + // T2: increment by 1 | 1 | 2 | 2 | + // T3: decrement by 2 | 0 | 0 | 2 | + (min, current, max) } } @@ -362,15 +405,26 @@ impl EncodeMetricValue for RangeGauge { /// /// # Examples /// -/// ``` -/// use foundations_metrics::{Gauge, GaugeGuard}; +/// ```ignore +/// use foundations_metrics::{metrics, Gauge, GaugeGuard}; /// -/// let connections: Gauge = Gauge::default(); -/// { -/// let _guard = GaugeGuard::new(connections.clone()); -/// assert_eq!(connections.get(), 1); +/// #[metrics] +/// pub mod my_app_metrics { +/// /// Number of currently connected clients. +/// pub fn client_connections_active() -> Gauge; +/// } +/// +/// fn usage() { +/// let client_metric = GaugeGuard::new(my_app_metrics::client_connections_active()); +/// // Do work where you want the metric to remain incremented. +/// // When it leaves scope, the metric will be decremented. +/// // Alternatively, move ownership to another scope to change the lifetime. +/// tokio::spawn(async move { +/// // Do work with arbitrary lifetime on another task. +/// // Manually drop to force `client_metric` ownership to this task. +/// drop(client_metric); +/// }); /// } -/// assert_eq!(connections.get(), 0); /// ``` pub struct GaugeGuard(G); @@ -390,7 +444,10 @@ impl Drop for GaugeGuard { /// Helper trait for values supported by [`GaugeGuard`]. pub trait GenericGauge { + /// Increases the wrapped gauge by one. fn inc(&self); + + /// Decreases the wrapped gauge by one. fn dec(&self); } From dadee9770f6e0161d264881d33dbfdf536d711be Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Thu, 16 Jul 2026 14:35:30 +0100 Subject: [PATCH 8/9] doc(metrics): apply doc suggestions from code review. --- foundations-metrics/src/metrics/gauge.rs | 2 -- foundations-metrics/src/metrics/mod.rs | 31 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/foundations-metrics/src/metrics/gauge.rs b/foundations-metrics/src/metrics/gauge.rs index dc611ac..533df4e 100644 --- a/foundations-metrics/src/metrics/gauge.rs +++ b/foundations-metrics/src/metrics/gauge.rs @@ -14,8 +14,6 @@ use super::IntoF64; /// reference to the *same* series, so the gauge can be updated from many places /// and read back as a single current value. /// -/// The `u64`/`AtomicU64` defaults preserve the existing Foundations API. -/// /// # Examples /// /// ``` diff --git a/foundations-metrics/src/metrics/mod.rs b/foundations-metrics/src/metrics/mod.rs index a911005..6302dba 100644 --- a/foundations-metrics/src/metrics/mod.rs +++ b/foundations-metrics/src/metrics/mod.rs @@ -1,5 +1,36 @@ //! The metrics module that contains scalar metrics (like Counter, Gauge, ...) and non-scalars, //! like Histogram, NativeHistogram, and also Family. +//! +//! # License +//! +//! Substantial parts of the code in this module were adapted from +//! [`prometheus-client`] subject to their licensing terms: +//! +//! ```text +//! MIT License +//! +//! Copyright (c) 2020 Max Inden +//! +//! Permission is hereby granted, free of charge, to any person obtaining a copy +//! of this software and associated documentation files (the "Software"), to deal +//! in the Software without restriction, including without limitation the rights +//! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +//! copies of the Software, and to permit persons to whom the Software is +//! furnished to do so, subject to the following conditions: +//! +//! The above copyright notice and this permission notice shall be included in all +//! copies or substantial portions of the Software. +//! +//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +//! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +//! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +//! OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +//! SOFTWARE. +//! ``` +//! +//! [`prometheus-client`]: https://github.com/prometheus/client_rust use std::sync::atomic::{AtomicU64, Ordering}; From 25e3f9dc257a8ff00e88e7ff3129cbf65233ed22 Mon Sep 17 00:00:00 2001 From: Ethan Olchik <60030956+ethanolchik@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:21:19 +0100 Subject: [PATCH 9/9] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leo Blöcher --- foundations-metrics/Cargo.toml | 2 +- foundations-metrics/src/registered.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/foundations-metrics/Cargo.toml b/foundations-metrics/Cargo.toml index d156b1e..bb63af0 100644 --- a/foundations-metrics/Cargo.toml +++ b/foundations-metrics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "foundations-metrics" -version.workspace = true +version = "0.1.0" repository.workspace = true edition.workspace = true authors.workspace = true diff --git a/foundations-metrics/src/registered.rs b/foundations-metrics/src/registered.rs index e033c17..473a5b8 100644 --- a/foundations-metrics/src/registered.rs +++ b/foundations-metrics/src/registered.rs @@ -28,8 +28,8 @@ where let mut families = self.metric.encode_metric_value(); for family in &mut families { - let suffix = family.name.take().unwrap_or_default(); - family.name = Some(format!("{}{}", self.name, suffix)); + let name = family.name.get_or_insert_default(); + name.insert_str(0, self.name); if family.help.is_none() { family.help = Some(self.help.to_owned());