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..34a36d9 100644 --- a/foundations-metrics/Cargo.toml +++ b/foundations-metrics/Cargo.toml @@ -9,6 +9,9 @@ license.workspace = true [dependencies] 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/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/labels/error.rs b/foundations-metrics/src/labels/error.rs new file mode 100644 index 0000000..b3237ce --- /dev/null +++ b/foundations-metrics/src/labels/error.rs @@ -0,0 +1,35 @@ +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 { + 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..ba7173b --- /dev/null +++ b/foundations-metrics/src/labels/mod.rs @@ -0,0 +1,22 @@ +//! 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; + +// 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 +/// 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..cf3954e --- /dev/null +++ b/foundations-metrics/src/labels/serializer.rs @@ -0,0 +1,507 @@ +use std::fmt; + +use foundations_metrics_registry::proto::LabelPair; +use serde::Serialize; +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 { + 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 + } +} + +// Adapted from prometools' `serde::top::StructSerializer` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). +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) + } +} + +// Adapted from prometools' `serde::value::ValueSerializer` +// (https://github.com/nox/prometools, licensed MIT OR Apache-2.0). +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 + } +} + +// 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 + .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() + ); + } +} diff --git a/foundations-metrics/src/lib.rs b/foundations-metrics/src/lib.rs index 21344c0..d0751e7 100644 --- a/foundations-metrics/src/lib.rs +++ b/foundations-metrics/src/lib.rs @@ -6,12 +6,18 @@ //! 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, }; -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..c7715ef --- /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::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` +// (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 +/// 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 { + 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 { + 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 + } +} + +#[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 {