From c533058ef44750766179636194329fd688951ecf Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Tue, 14 Jul 2026 16:46:57 +0100 Subject: [PATCH 1/4] feat(metrics): add protobuf label serialisation Add a serde serialiser that converts metric label structs into Prometheus protobuf LabelPair values. Preserve the legacy backend's scalar formatting behaviour, including ryu-based float formatting, serde renames, enum variants, optional values, and display adapters. Validate label names and reject unsupported compound values. Store label values without OpenMetrics escaping so the protobuf model remains format-independent and text escaping can be applied later by the text encoder. --- foundations-metrics-registry/src/proto/mod.rs | 4 +- foundations-metrics/Cargo.toml | 2 + foundations-metrics/src/labels/error.rs | 33 ++ foundations-metrics/src/labels/mod.rs | 20 + foundations-metrics/src/labels/serializer.rs | 499 ++++++++++++++++++ 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 foundations-metrics/src/labels/error.rs create mode 100644 foundations-metrics/src/labels/mod.rs create mode 100644 foundations-metrics/src/labels/serializer.rs diff --git a/foundations-metrics-registry/src/proto/mod.rs b/foundations-metrics-registry/src/proto/mod.rs index d250dd1..6a56004 100644 --- a/foundations-metrics-registry/src/proto/mod.rs +++ b/foundations-metrics-registry/src/proto/mod.rs @@ -9,7 +9,9 @@ #[allow(missing_docs, unreachable_pub, clippy::all)] mod model; -pub use model::{BucketSpan, Counter, Gauge, Histogram, Metric, MetricFamily, MetricType}; +pub use model::{ + BucketSpan, Counter, Gauge, Histogram, LabelPair, Metric, MetricFamily, MetricType, +}; #[cfg(test)] mod tests { diff --git a/foundations-metrics/Cargo.toml b/foundations-metrics/Cargo.toml index bb63af0..5a0effc 100644 --- a/foundations-metrics/Cargo.toml +++ b/foundations-metrics/Cargo.toml @@ -9,6 +9,8 @@ license.workspace = true [dependencies] foundations-metrics-registry = { workspace = true } prometheus-client = "0.25.0" +serde = { workspace = true, features = ["derive"] } +ryu = "1.0.23" [lints] workspace = true diff --git a/foundations-metrics/src/labels/error.rs b/foundations-metrics/src/labels/error.rs new file mode 100644 index 0000000..38fd824 --- /dev/null +++ b/foundations-metrics/src/labels/error.rs @@ -0,0 +1,33 @@ +use std::error::Error; +use std::fmt; + +/// An error produced while serializing a metric label set. +#[derive(Debug)] +pub struct LabelError { + message: String, +} + +impl LabelError { + pub(crate) fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for LabelError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for LabelError {} + +impl serde::ser::Error for LabelError { + fn custom(message: T) -> Self + where + T: fmt::Display, + { + Self::new(message.to_string()) + } +} diff --git a/foundations-metrics/src/labels/mod.rs b/foundations-metrics/src/labels/mod.rs new file mode 100644 index 0000000..2e407d1 --- /dev/null +++ b/foundations-metrics/src/labels/mod.rs @@ -0,0 +1,20 @@ +//! Serialization of metric label sets into the protobuf data model. + +mod error; +mod serializer; + +pub use error::LabelError; + +use foundations_metrics_registry::proto::LabelPair; +use serde::Serialize; + +/// Serializes a label set into protobuf label pairs. +/// +/// Label values are stored as raw strings. OpenMetrics escaping is deliberately +/// deferred until text encoding. +pub fn to_label_pairs(labels: &S) -> Result, LabelError> +where + S: Serialize + ?Sized, +{ + labels.serialize(serializer::LabelSetSerializer) +} diff --git a/foundations-metrics/src/labels/serializer.rs b/foundations-metrics/src/labels/serializer.rs new file mode 100644 index 0000000..764119b --- /dev/null +++ b/foundations-metrics/src/labels/serializer.rs @@ -0,0 +1,499 @@ +use std::fmt; + +use foundations_metrics_registry::proto::LabelPair; +use serde::Serialize; +use serde::ser::{Impossible, SerializeStruct, Serializer}; + +use super::LabelError; + +pub(super) struct LabelSetSerializer; + +impl Serializer for LabelSetSerializer { + type Ok = Vec; + type Error = LabelError; + type SerializeSeq = Impossible; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = Impossible; + type SerializeMap = Impossible; + type SerializeStruct = LabelPairSerializer; + type SerializeStructVariant = Impossible; + + fn serialize_unit(self) -> Result { + Ok(Vec::new()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Ok(Vec::new()) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result + where + T: Serialize + ?Sized, + { + value.serialize(self) + } + + fn serialize_none(self) -> Result { + Ok(Vec::new()) + } + + fn serialize_some(self, value: &T) -> Result + where + T: Serialize + ?Sized, + { + value.serialize(self) + } + + fn serialize_struct( + self, + _name: &'static str, + len: usize, + ) -> Result { + Ok(LabelPairSerializer { + labels: Vec::with_capacity(len), + }) + } + + fn serialize_bool(self, _value: bool) -> Result { + Err(invalid_label_set()) + } + + fn serialize_i8(self, _value: i8) -> Result { + Err(invalid_label_set()) + } + + fn serialize_i16(self, _value: i16) -> Result { + Err(invalid_label_set()) + } + + fn serialize_i32(self, _value: i32) -> Result { + Err(invalid_label_set()) + } + + fn serialize_i64(self, _value: i64) -> Result { + Err(invalid_label_set()) + } + + fn serialize_i128(self, _value: i128) -> Result { + Err(invalid_label_set()) + } + + fn serialize_u8(self, _value: u8) -> Result { + Err(invalid_label_set()) + } + + fn serialize_u16(self, _value: u16) -> Result { + Err(invalid_label_set()) + } + + fn serialize_u32(self, _value: u32) -> Result { + Err(invalid_label_set()) + } + + fn serialize_u64(self, _value: u64) -> Result { + Err(invalid_label_set()) + } + + fn serialize_u128(self, _value: u128) -> Result { + Err(invalid_label_set()) + } + + fn serialize_f32(self, _value: f32) -> Result { + Err(invalid_label_set()) + } + + fn serialize_f64(self, _value: f64) -> Result { + Err(invalid_label_set()) + } + + fn serialize_char(self, _value: char) -> Result { + Err(invalid_label_set()) + } + + fn serialize_str(self, _value: &str) -> Result { + Err(invalid_label_set()) + } + + fn serialize_bytes(self, _value: &[u8]) -> Result { + Err(invalid_label_set()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result { + Err(invalid_label_set()) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result + where + T: Serialize + ?Sized, + { + Err(invalid_label_set()) + } + + fn serialize_seq(self, _len: Option) -> Result { + Err(invalid_label_set()) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Err(invalid_label_set()) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(invalid_label_set()) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(invalid_label_set()) + } + + fn serialize_map(self, _len: Option) -> Result { + Err(invalid_label_set()) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(invalid_label_set()) + } + + fn is_human_readable(&self) -> bool { + true + } +} + +pub(super) struct LabelPairSerializer { + labels: Vec, +} + +impl SerializeStruct for LabelPairSerializer { + type Ok = Vec; + type Error = LabelError; + + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Self::Error> + where + T: Serialize + ?Sized, + { + validate_label_name(key)?; + self.labels.push(LabelPair { + name: Some(key.to_owned()), + value: Some(value.serialize(LabelValueSerializer)?), + }); + Ok(()) + } + + fn end(self) -> Result { + Ok(self.labels) + } +} + +struct LabelValueSerializer; + +macro_rules! serialize_integer { + ($($method:ident($ty:ty)),+ $(,)?) => { + $( + fn $method(self, value: $ty) -> Result { + Ok(value.to_string()) + } + )+ + }; +} + +impl Serializer for LabelValueSerializer { + type Ok = String; + type Error = LabelError; + type SerializeSeq = Impossible; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = Impossible; + type SerializeMap = Impossible; + type SerializeStruct = Impossible; + type SerializeStructVariant = Impossible; + + fn serialize_bool(self, value: bool) -> Result { + Ok(value.to_string()) + } + + serialize_integer!( + serialize_i8(i8), + serialize_i16(i16), + serialize_i32(i32), + serialize_i64(i64), + serialize_i128(i128), + serialize_u8(u8), + serialize_u16(u16), + serialize_u32(u32), + serialize_u64(u64), + serialize_u128(u128), + ); + + fn serialize_f32(self, value: f32) -> Result { + Ok(ryu::Buffer::new().format(value).to_owned()) + } + + fn serialize_f64(self, value: f64) -> Result { + Ok(ryu::Buffer::new().format(value).to_owned()) + } + + fn serialize_char(self, value: char) -> Result { + Ok(value.to_string()) + } + + fn serialize_str(self, value: &str) -> Result { + Ok(value.to_owned()) + } + + fn serialize_unit(self) -> Result { + Ok(String::new()) + } + + fn serialize_unit_struct(self, name: &'static str) -> Result { + Ok(name.to_owned()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result { + Ok(variant.to_owned()) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result + where + T: Serialize + ?Sized, + { + value.serialize(self) + } + + fn serialize_none(self) -> Result { + Ok(String::new()) + } + + fn serialize_some(self, value: &T) -> Result + where + T: Serialize + ?Sized, + { + value.serialize(self) + } + + fn collect_str(self, value: &T) -> Result + where + T: fmt::Display + ?Sized, + { + Ok(value.to_string()) + } + + fn serialize_bytes(self, _value: &[u8]) -> Result { + Err(invalid_label_value("byte array")) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result + where + T: Serialize + ?Sized, + { + Err(invalid_label_value("newtype variant")) + } + + fn serialize_seq(self, _len: Option) -> Result { + Err(invalid_label_value("sequence")) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Err(invalid_label_value("tuple")) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(invalid_label_value("tuple struct")) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(invalid_label_value("tuple variant")) + } + + fn serialize_map(self, _len: Option) -> Result { + Err(invalid_label_value("map")) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(invalid_label_value("struct")) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(invalid_label_value("struct variant")) + } + + fn is_human_readable(&self) -> bool { + true + } +} + +fn validate_label_name(name: &str) -> Result<(), LabelError> { + let mut chars = name.chars(); + let valid = chars + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || matches!(character, '_' | ':')) + && chars + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | ':')); + + valid.then_some(()).ok_or_else(|| { + LabelError::new(format!( + "invalid metric label name {name:?}: expected [a-zA-Z_:][a-zA-Z0-9_:]*" + )) + }) +} + +fn invalid_label_set() -> LabelError { + LabelError::new("metric labels must serialize as a struct or unit") +} + +fn invalid_label_value(kind: &str) -> LabelError { + LabelError::new(format!("unsupported {kind} metric label value")) +} + +#[cfg(test)] +mod tests { + use serde::Serialize; + + use crate::to_label_pairs; + + #[derive(Serialize)] + #[serde(rename_all = "lowercase")] + enum Method { + Get, + } + + #[derive(Serialize)] + struct Labels<'a> { + method: Method, + status: u16, + sampled: bool, + ratio: f64, + missing: Option<&'a str>, + raw: &'a str, + } + + #[test] + fn serializes_supported_values_without_text_escaping() { + let pairs = to_label_pairs(&Labels { + method: Method::Get, + status: 200, + sampled: true, + ratio: 1.0, + missing: None, + raw: "quote=\" slash=\\ newline=\n", + }) + .unwrap(); + + let values: Vec<_> = pairs + .iter() + .map(|pair| { + ( + pair.name.as_deref().unwrap(), + pair.value.as_deref().unwrap(), + ) + }) + .collect(); + assert_eq!( + values, + [ + ("method", "get"), + ("status", "200"), + ("sampled", "true"), + ("ratio", "1.0"), + ("missing", ""), + ("raw", "quote=\" slash=\\ newline=\n"), + ] + ); + } + + #[test] + fn unit_is_an_empty_label_set() { + assert!(to_label_pairs(&()).unwrap().is_empty()); + } + + #[test] + fn rejects_invalid_label_names() { + #[derive(Serialize)] + struct Invalid { + #[serde(rename = "not-valid")] + value: &'static str, + } + + assert!(to_label_pairs(&Invalid { value: "x" }).is_err()); + } + + #[test] + fn rejects_compound_label_values() { + #[derive(Serialize)] + struct Invalid { + values: Vec, + } + + assert!( + to_label_pairs(&Invalid { + values: vec![1, 2, 3], + }) + .is_err() + ); + } +} From bc1ea088102cf1e0cc7b1555a3e08b85949215ee Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Wed, 15 Jul 2026 11:23:34 +0100 Subject: [PATCH 2/4] feat(metrics): add metric family support Add shared labeled metric storage with custom constructors and lifecycle operations. Group encoded metrics by suffix and skip invalid label sets or inconsistent metadata without failing collection. --- foundations-metrics/Cargo.toml | 1 + foundations-metrics/src/lib.rs | 6 +- foundations-metrics/src/metrics/family.rs | 466 ++++++++++++++++++++++ foundations-metrics/src/metrics/mod.rs | 2 + 4 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 foundations-metrics/src/metrics/family.rs diff --git a/foundations-metrics/Cargo.toml b/foundations-metrics/Cargo.toml index 5a0effc..34a36d9 100644 --- a/foundations-metrics/Cargo.toml +++ b/foundations-metrics/Cargo.toml @@ -11,6 +11,7 @@ foundations-metrics-registry = { workspace = true } prometheus-client = "0.25.0" serde = { workspace = true, features = ["derive"] } ryu = "1.0.23" +parking_lot = { workspace = true } [lints] workspace = true diff --git a/foundations-metrics/src/lib.rs b/foundations-metrics/src/lib.rs index 21344c0..30c541c 100644 --- a/foundations-metrics/src/lib.rs +++ b/foundations-metrics/src/lib.rs @@ -6,6 +6,7 @@ //! shared process-global registry and the stable wire format. #![warn(missing_docs)] +pub mod labels; pub mod metrics; mod registered; mod value; @@ -13,5 +14,8 @@ mod value; pub use foundations_metrics_registry::{ EncodeMetric, IntoMetrics, MetricFamily, RegistrationMetadata, register, }; -pub use metrics::{Counter, CounterAtomic, Gauge, GaugeAtomic, GaugeGuard, RangeGauge}; +pub use labels::{LabelError, to_label_pairs}; +pub use metrics::{ + Counter, CounterAtomic, Family, Gauge, GaugeAtomic, GaugeGuard, MetricConstructor, RangeGauge, +}; pub use registered::NamedMetric; diff --git a/foundations-metrics/src/metrics/family.rs b/foundations-metrics/src/metrics/family.rs new file mode 100644 index 0000000..88cb6f4 --- /dev/null +++ b/foundations-metrics/src/metrics/family.rs @@ -0,0 +1,466 @@ +use std::collections::HashMap; +use std::fmt; +use std::hash::Hash; +use std::io::{self, Write}; +use std::sync::Arc; + +use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use serde::Serialize; + +use crate::{MetricFamily, labels::to_label_pairs, value::EncodeMetricValue}; + +/// A set of metrics differentiated by their label values. +/// +/// Clones share the same metrics. Calling [`Family::get_or_create`] creates a +/// metric for a label set on first use and returns the existing metric on later +/// calls. +/// +/// # Examples +/// +/// ``` +/// use foundations_metrics::{Counter, Family}; +/// use serde::Serialize; +/// +/// #[derive(Clone, Eq, Hash, PartialEq, Serialize)] +/// struct Labels { +/// method: &'static str, +/// status: u16, +/// } +/// +/// let requests = Family::::default(); +/// let success = Labels { +/// method: "GET", +/// status: 200, +/// }; +/// +/// requests.get_or_create(&success).inc(); +/// requests.get_or_create(&success).inc_by(2); +/// assert_eq!(requests.get_or_create(&success).get(), 3); +/// +/// // Drop any returned guards before removing metrics from the family. +/// assert!(requests.remove(&success)); +/// assert_eq!(requests.get_or_create(&success).get(), 0); +/// ``` +pub struct Family M> { + metrics: Arc>>, + constructor: C, +} + +/// Constructs metrics for a [`Family`]. +/// +/// Custom constructors are useful for metrics that need configuration when +/// created, such as histograms with a specific set of buckets. +pub trait MetricConstructor { + /// Creates a new metric. + fn new_metric(&self) -> M; +} + +impl MetricConstructor for F +where + F: Fn() -> M, +{ + fn new_metric(&self) -> M { + self() + } +} + +impl Family { + /// Creates an empty family that uses `constructor` for new label sets. + /// + /// ``` + /// use foundations_metrics::{Counter, Family}; + /// + /// let counters = Family::<&'static str, Counter, _>::new_with_constructor(|| { + /// let counter = Counter::default(); + /// counter.inc_by(10); + /// counter + /// }); + /// + /// assert_eq!(counters.get_or_create(&"priority").get(), 10); + /// ``` + pub fn new_with_constructor(constructor: C) -> Self { + Self { + metrics: Arc::new(RwLock::new(HashMap::new())), + constructor, + } + } +} + +impl Default for Family +where + M: Default, +{ + fn default() -> Self { + Self::new_with_constructor(M::default) + } +} + +impl Family +where + S: Clone + Eq + Hash, + C: MetricConstructor, +{ + /// Returns the metric for `label_set`, creating it when absent. + /// + /// The returned guard keeps the family read-locked. Holding it while + /// accessing another label set in the same family can deadlock if that + /// second label set needs to be created. + pub fn get_or_create(&self, label_set: &S) -> MappedRwLockReadGuard<'_, M> { + if let Ok(metric) = + RwLockReadGuard::try_map(self.metrics.read(), |metrics| metrics.get(label_set)) + { + return metric; + } + + let mut metrics = self.metrics.write(); + metrics + .entry(label_set.clone()) + .or_insert_with(|| self.constructor.new_metric()); + + let metrics = RwLockWriteGuard::downgrade(metrics); + RwLockReadGuard::map(metrics, |metrics| { + metrics + .get(label_set) + .expect("metric exists after it was inserted") + }) + } + + /// Removes a label set, returning whether it was present. + pub fn remove(&self, label_set: &S) -> bool { + self.metrics.write().remove(label_set).is_some() + } + + /// Removes every label set from this family. + pub fn clear(&self) { + self.metrics.write().clear(); + } +} + +impl Clone for Family +where + C: Clone, +{ + fn clone(&self) -> Self { + Self { + metrics: Arc::clone(&self.metrics), + constructor: self.constructor.clone(), + } + } +} + +impl fmt::Debug for Family +where + S: fmt::Debug, + M: fmt::Debug, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Family") + .field("metrics", &self.metrics) + .finish_non_exhaustive() + } +} + +impl EncodeMetricValue for Family +where + S: Serialize + Send + Sync + 'static, + M: EncodeMetricValue, + C: Send + Sync + 'static, +{ + fn encode_metric_value(&self) -> Vec { + let metrics = self.metrics.read(); + let mut grouped: Vec = Vec::new(); + let mut first_label_error = None; + let mut label_error_count = 0; + let mut first_metadata_error = None; + let mut metadata_error_count = 0; + + for (label_set, metric) in metrics.iter() { + let labels = match to_label_pairs(label_set) { + Ok(labels) => labels, + Err(error) => { + label_error_count += 1; + first_label_error.get_or_insert(error); + continue; + } + }; + + for mut family in metric.encode_metric_value() { + if family.metric.is_empty() { + continue; + } + + for row in &mut family.metric { + row.label.splice(0..0, labels.iter().cloned()); + } + + if let Some(existing) = grouped + .iter_mut() + .find(|existing| existing.name == family.name) + { + if existing.help != family.help + || existing.r#type != family.r#type + || existing.unit != family.unit + { + metadata_error_count += 1; + first_metadata_error.get_or_insert_with(|| family.name.clone()); + continue; + } + + existing.metric.append(&mut family.metric); + } else { + grouped.push(family); + } + } + } + + drop(metrics); + + if let Some(error) = first_label_error { + let _ = writeln!( + io::stderr().lock(), + "non-fatal error while collecting metrics: skipped {label_error_count} label set(s); first serialization error: {error}" + ); + } + + if let Some(name) = first_metadata_error { + let _ = writeln!( + io::stderr().lock(), + "non-fatal error while collecting metrics: skipped {metadata_error_count} metric group(s) with inconsistent metadata; first relative name: {name:?}" + ); + } + + grouped + } +} + +#[cfg(test)] +mod tests { + use foundations_metrics_registry::proto::MetricType; + use serde::Serialize; + + use super::*; + use crate::{Counter, RangeGauge}; + + #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)] + struct Labels { + method: &'static str, + status: u16, + } + + #[test] + fn creates_reuses_removes_and_clears_metrics() { + let family = Family::::default(); + let get = Labels { + method: "GET", + status: 200, + }; + let post = Labels { + method: "POST", + status: 201, + }; + + family.get_or_create(&get).inc(); + family.get_or_create(&get).inc_by(2); + family.get_or_create(&post).inc_by(4); + + assert_eq!(family.get_or_create(&get).get(), 3); + assert_eq!(family.get_or_create(&post).get(), 4); + assert!(family.remove(&post)); + assert!(!family.remove(&post)); + assert_eq!(family.get_or_create(&post).get(), 0); + + family.clear(); + assert_eq!(family.get_or_create(&get).get(), 0); + } + + #[test] + fn clones_share_metrics() { + let family = Family::::default(); + let clone = family.clone(); + let labels = Labels { + method: "GET", + status: 200, + }; + + family.get_or_create(&labels).inc(); + clone.get_or_create(&labels).inc(); + + assert_eq!(family.get_or_create(&labels).get(), 2); + } + + #[test] + fn custom_constructor_creates_metrics() { + let family = Family::::new_with_constructor(|| { + let counter = Counter::default(); + counter.inc_by(10); + counter + }); + let labels = Labels { + method: "GET", + status: 200, + }; + + assert_eq!(family.get_or_create(&labels).get(), 10); + } + + #[test] + fn encodes_one_row_per_label_set() { + let family = Family::::default(); + family + .get_or_create(&Labels { + method: "GET", + status: 200, + }) + .inc_by(3); + family + .get_or_create(&Labels { + method: "POST", + status: 201, + }) + .inc_by(5); + + let families = family.encode_metric_value(); + assert_eq!(families.len(), 1); + assert_eq!(families[0].name.as_deref(), Some("")); + assert_eq!(families[0].r#type, Some(MetricType::Counter as i32)); + assert_eq!(families[0].metric.len(), 2); + + for row in &families[0].metric { + let method = row + .label + .iter() + .find(|label| label.name.as_deref() == Some("method")) + .and_then(|label| label.value.as_deref()) + .expect("row has a method label"); + let value = row + .counter + .as_ref() + .and_then(|counter| counter.value) + .expect("row has a counter value"); + + match method { + "GET" => assert_eq!(value, 3.0), + "POST" => assert_eq!(value, 5.0), + method => panic!("unexpected method {method}"), + } + } + } + + #[test] + fn groups_each_range_gauge_suffix() { + let family = Family::::default(); + let first = Labels { + method: "GET", + status: 200, + }; + let second = Labels { + method: "POST", + status: 201, + }; + + family.get_or_create(&first).inc_by(3); + family.get_or_create(&first).dec_by(2); + family.get_or_create(&second).inc_by(5); + + let families = family.encode_metric_value(); + assert_eq!(families.len(), 3); + + for (family, suffix) in families.iter().zip(["", "_min", "_max"]) { + assert_eq!(family.name.as_deref(), Some(suffix)); + assert_eq!(family.r#type, Some(MetricType::Gauge as i32)); + assert_eq!(family.metric.len(), 2); + assert!(family.metric.iter().all(|row| row.label.len() == 2)); + } + } + + #[test] + fn skips_only_label_sets_that_fail_to_serialize() { + #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)] + enum FallibleValue { + Valid, + Invalid(Vec), + } + + #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)] + struct FallibleLabels { + value: FallibleValue, + } + + let family = Family::::default(); + family + .get_or_create(&FallibleLabels { + value: FallibleValue::Valid, + }) + .inc_by(3); + family + .get_or_create(&FallibleLabels { + value: FallibleValue::Invalid(vec![1, 2, 3]), + }) + .inc_by(5); + + let families = family.encode_metric_value(); + assert_eq!(families.len(), 1); + assert_eq!(families[0].metric.len(), 1); + assert_eq!( + families[0].metric[0].label[0].value.as_deref(), + Some("Valid") + ); + assert_eq!( + families[0].metric[0] + .counter + .as_ref() + .and_then(|counter| counter.value), + Some(3.0) + ); + } + + #[test] + fn skips_metric_groups_with_inconsistent_metadata() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct MetadataMetric { + unit: &'static str, + } + + impl EncodeMetricValue for MetadataMetric { + fn encode_metric_value(&self) -> Vec { + let counter: Counter = Counter::default(); + let mut families = counter.encode_metric_value(); + families[0].unit = Some(self.unit.to_owned()); + families + } + } + + let metric_count = Arc::new(AtomicUsize::new(0)); + let family = Family::::new_with_constructor({ + let metric_count = Arc::clone(&metric_count); + move || MetadataMetric { + unit: if metric_count.fetch_add(1, Ordering::Relaxed) == 0 { + "seconds" + } else { + "bytes" + }, + } + }); + + drop(family.get_or_create(&Labels { + method: "GET", + status: 200, + })); + drop(family.get_or_create(&Labels { + method: "POST", + status: 201, + })); + + let families = family.encode_metric_value(); + assert_eq!(families.len(), 1); + assert_eq!(families[0].metric.len(), 1); + } + + #[test] + fn empty_family_encodes_nothing() { + let family = Family::::default(); + assert!(family.encode_metric_value().is_empty()); + } +} diff --git a/foundations-metrics/src/metrics/mod.rs b/foundations-metrics/src/metrics/mod.rs index 6302dba..f4a0d2c 100644 --- a/foundations-metrics/src/metrics/mod.rs +++ b/foundations-metrics/src/metrics/mod.rs @@ -35,9 +35,11 @@ use std::sync::atomic::{AtomicU64, Ordering}; mod counter; +mod family; mod gauge; pub use counter::{Counter, CounterAtomic}; +pub use family::{Family, MetricConstructor}; pub use gauge::{Gauge, GaugeAtomic, GaugeGuard, RangeGauge}; fn update_f64(atomic: &AtomicU64, f: impl Fn(f64) -> f64) -> f64 { From 47b61e2a520d918506551e44fd107ebb47145e49 Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Thu, 16 Jul 2026 17:48:28 +0100 Subject: [PATCH 3/4] docs(metrics): note prometools-adapted code --- foundations-metrics/src/labels/error.rs | 2 ++ foundations-metrics/src/labels/mod.rs | 2 ++ foundations-metrics/src/labels/serializer.rs | 8 ++++++++ foundations-metrics/src/metrics/family.rs | 2 ++ 4 files changed, 14 insertions(+) diff --git a/foundations-metrics/src/labels/error.rs b/foundations-metrics/src/labels/error.rs index 38fd824..b3237ce 100644 --- a/foundations-metrics/src/labels/error.rs +++ b/foundations-metrics/src/labels/error.rs @@ -1,6 +1,8 @@ use std::error::Error; use std::fmt; +// Adapted from prometools' `serde::error::Error` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). /// An error produced while serializing a metric label set. #[derive(Debug)] pub struct LabelError { diff --git a/foundations-metrics/src/labels/mod.rs b/foundations-metrics/src/labels/mod.rs index 2e407d1..ba7173b 100644 --- a/foundations-metrics/src/labels/mod.rs +++ b/foundations-metrics/src/labels/mod.rs @@ -8,6 +8,8 @@ pub use error::LabelError; use foundations_metrics_registry::proto::LabelPair; use serde::Serialize; +// Adapted from prometools' `serde` label serializer +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). /// Serializes a label set into protobuf label pairs. /// /// Label values are stored as raw strings. OpenMetrics escaping is deliberately diff --git a/foundations-metrics/src/labels/serializer.rs b/foundations-metrics/src/labels/serializer.rs index 764119b..cf3954e 100644 --- a/foundations-metrics/src/labels/serializer.rs +++ b/foundations-metrics/src/labels/serializer.rs @@ -6,6 +6,8 @@ use serde::ser::{Impossible, SerializeStruct, Serializer}; use super::LabelError; +// Adapted from prometools' `serde::top::TopSerializer` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). pub(super) struct LabelSetSerializer; impl Serializer for LabelSetSerializer { @@ -190,6 +192,8 @@ impl Serializer for LabelSetSerializer { } } +// Adapted from prometools' `serde::top::StructSerializer` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). pub(super) struct LabelPairSerializer { labels: Vec, } @@ -215,6 +219,8 @@ impl SerializeStruct for LabelPairSerializer { } } +// Adapted from prometools' `serde::value::ValueSerializer` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). struct LabelValueSerializer; macro_rules! serialize_integer { @@ -387,6 +393,8 @@ impl Serializer for LabelValueSerializer { } } +// Adapted from prometools' `serde::top::check_key` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). fn validate_label_name(name: &str) -> Result<(), LabelError> { let mut chars = name.chars(); let valid = chars diff --git a/foundations-metrics/src/metrics/family.rs b/foundations-metrics/src/metrics/family.rs index 88cb6f4..b3ff0f4 100644 --- a/foundations-metrics/src/metrics/family.rs +++ b/foundations-metrics/src/metrics/family.rs @@ -9,6 +9,8 @@ use serde::Serialize; use crate::{MetricFamily, labels::to_label_pairs, value::EncodeMetricValue}; +// Adapted from prometools' `serde::Family` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). /// A set of metrics differentiated by their label values. /// /// Clones share the same metrics. Calling [`Family::get_or_create`] creates a From a4f39f860dd4607d59ad13eafa6b0caa5b526ded Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Thu, 16 Jul 2026 18:31:41 +0100 Subject: [PATCH 4/4] feat(metrics): route collection diagnostics through an installable hook --- foundations-metrics/src/diagnostics.rs | 61 +++++++++++++++++++++++ foundations-metrics/src/lib.rs | 2 + foundations-metrics/src/metrics/family.rs | 12 ++--- 3 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 foundations-metrics/src/diagnostics.rs diff --git a/foundations-metrics/src/diagnostics.rs b/foundations-metrics/src/diagnostics.rs new file mode 100644 index 0000000..b5fd41d --- /dev/null +++ b/foundations-metrics/src/diagnostics.rs @@ -0,0 +1,61 @@ +//! Reporting of non-fatal diagnostics produced while collecting metrics. + +use std::error::Error; +use std::fmt; +use std::io::{self, Write}; +use std::sync::OnceLock; + +type CollectErrorHook = Box Fn(fmt::Arguments<'a>) + Send + Sync>; + +static COLLECT_ERROR_HOOK: OnceLock = OnceLock::new(); + +/// Error returned by [`set_collect_error_hook`] when a hook is already installed. +#[derive(Debug)] +pub struct CollectErrorHookAlreadySet(()); + +impl fmt::Display for CollectErrorHookAlreadySet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a metric collection error hook is already installed") + } +} + +impl Error for CollectErrorHookAlreadySet {} + +/// Routes non-fatal metric-collection diagnostics through a custom hook. +/// +/// Collecting metrics never fails: label sets or metric groups that cannot be +/// encoded are skipped and a non-fatal diagnostic is emitted instead. By default +/// these diagnostics are written to standard error. +/// +/// `foundations-metrics` is the low-level layer of the metrics stack and +/// deliberately depends on no logging framework, keeping logging decoupled from +/// metrics and avoiding a dependency cycle with higher-level crates. Instead of +/// calling into a logger directly, it exposes this seam: install a hook to route +/// collection diagnostics into your logging pipeline (typically at warning +/// level). The hook applies process-wide and can only be set once. +/// +/// ```no_run +/// foundations_metrics::set_collect_error_hook(|args| { +/// // Forward to your logging framework, e.g. `log`, `tracing`, or `slog`. +/// eprintln!("{args}"); +/// }) +/// .expect("collect error hook installed once during start-up"); +/// ``` +pub fn set_collect_error_hook( + hook: impl for<'a> Fn(fmt::Arguments<'a>) + Send + Sync + 'static, +) -> Result<(), CollectErrorHookAlreadySet> { + COLLECT_ERROR_HOOK + .set(Box::new(hook)) + .map_err(|_| CollectErrorHookAlreadySet(())) +} + +/// Emits a non-fatal collection diagnostic through the installed hook, falling +/// back to standard error when no hook is set. +pub(crate) fn report_collect_error(args: fmt::Arguments<'_>) { + match COLLECT_ERROR_HOOK.get() { + Some(hook) => hook(args), + None => { + let _ = writeln!(io::stderr().lock(), "{args}"); + } + } +} diff --git a/foundations-metrics/src/lib.rs b/foundations-metrics/src/lib.rs index 30c541c..d0751e7 100644 --- a/foundations-metrics/src/lib.rs +++ b/foundations-metrics/src/lib.rs @@ -6,11 +6,13 @@ //! shared process-global registry and the stable wire format. #![warn(missing_docs)] +mod diagnostics; pub mod labels; pub mod metrics; mod registered; mod value; +pub use diagnostics::{CollectErrorHookAlreadySet, set_collect_error_hook}; pub use foundations_metrics_registry::{ EncodeMetric, IntoMetrics, MetricFamily, RegistrationMetadata, register, }; diff --git a/foundations-metrics/src/metrics/family.rs b/foundations-metrics/src/metrics/family.rs index b3ff0f4..c7715ef 100644 --- a/foundations-metrics/src/metrics/family.rs +++ b/foundations-metrics/src/metrics/family.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; use std::fmt; use std::hash::Hash; -use std::io::{self, Write}; use std::sync::Arc; use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; use serde::Serialize; +use crate::diagnostics::report_collect_error; use crate::{MetricFamily, labels::to_label_pairs, value::EncodeMetricValue}; // Adapted from prometools' `serde::Family` @@ -219,17 +219,15 @@ where drop(metrics); if let Some(error) = first_label_error { - let _ = writeln!( - io::stderr().lock(), + report_collect_error(format_args!( "non-fatal error while collecting metrics: skipped {label_error_count} label set(s); first serialization error: {error}" - ); + )); } if let Some(name) = first_metadata_error { - let _ = writeln!( - io::stderr().lock(), + report_collect_error(format_args!( "non-fatal error while collecting metrics: skipped {metadata_error_count} metric group(s) with inconsistent metadata; first relative name: {name:?}" - ); + )); } grouped