From 1dd879386331c3c4834f8d0c5c3d7a800711499c Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Thu, 9 Jul 2026 16:54:53 +0100 Subject: [PATCH 1/4] feat(metrics-registry): add core registration API Introduce the registration layer on top of the protobuf data model: - `EncodeMetric`: trait for metrics that encode themselves into `MetricFamily` messages (best-effort; an empty `Vec` is valid). - `register`/`iter`: append-only global registry behind a `parking_lot::RwLock>>`. `iter()` clones an `Arc` snapshot so iteration never holds the lock. Service-name handling is deferred to encode time so metrics can be registered before the service name is known. - `IntoMetrics`: sealed conversion trait accepting a single boxed metric or a `Vec` of them. - `MetricsIter`/`MetricRegistration`: point-in-time snapshot iterator pairing each metric with its `RegistrationMetadata` via accessors, keeping the internal `Entry` storage type private. - `RegistrationMetadata`: `#[non_exhaustive]` builder for the `optional` and `unprefixed` flags. Add the `parking_lot` dependency and re-export the new public API from `lib.rs`. --- foundations-metrics-registry/Cargo.toml | 1 + .../src/encode_metric.rs | 10 ++ foundations-metrics-registry/src/iter.rs | 55 +++++++++ foundations-metrics-registry/src/lib.rs | 11 ++ foundations-metrics-registry/src/metadata.rs | 33 ++++++ foundations-metrics-registry/src/registry.rs | 108 ++++++++++++++++++ 6 files changed, 218 insertions(+) create mode 100644 foundations-metrics-registry/src/encode_metric.rs create mode 100644 foundations-metrics-registry/src/iter.rs create mode 100644 foundations-metrics-registry/src/metadata.rs create mode 100644 foundations-metrics-registry/src/registry.rs diff --git a/foundations-metrics-registry/Cargo.toml b/foundations-metrics-registry/Cargo.toml index 90e436c..195fc36 100644 --- a/foundations-metrics-registry/Cargo.toml +++ b/foundations-metrics-registry/Cargo.toml @@ -10,6 +10,7 @@ license = { workspace = true } workspace = true [dependencies] +parking_lot = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } diff --git a/foundations-metrics-registry/src/encode_metric.rs b/foundations-metrics-registry/src/encode_metric.rs new file mode 100644 index 0000000..bc59c54 --- /dev/null +++ b/foundations-metrics-registry/src/encode_metric.rs @@ -0,0 +1,10 @@ +use crate::proto::MetricFamily; + +/// A metric that can encode itself into the protobuf data model. +/// +/// Encoding is best-effort: implementations skip (and internally report) any +/// metric or series that fails, so an empty `Vec` is a valid result. +pub trait EncodeMetric: Send + Sync + 'static { + /// Encodes this metric into zero or more [`MetricFamily`] messages. + fn encode(&self) -> Vec; +} diff --git a/foundations-metrics-registry/src/iter.rs b/foundations-metrics-registry/src/iter.rs new file mode 100644 index 0000000..0267995 --- /dev/null +++ b/foundations-metrics-registry/src/iter.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use crate::EncodeMetric; +use crate::RegistrationMetadata; +use crate::registry::Entry; + +/// A registered metric together with its metadata. +/// +/// Yielded by [`MetricsIter`] +pub struct MetricRegistration { + entry: Arc, +} + +impl MetricRegistration { + /// The metadata supplied to [`register`](crate::register). + pub fn metadata(&self) -> &RegistrationMetadata { + &self.entry.metadata + } + /// The registered metric. + pub fn metric(&self) -> &dyn EncodeMetric { + self.entry.metric.as_ref() + } +} + +/// A point-in-time snapshot iterator over the registered metrics. +/// +/// Metrics registered after [`iter`](crate::iter()) was called are not observed, +/// and the registry lock is not held while iterating. +pub struct MetricsIter { + entries: std::vec::IntoIter>, +} + +impl MetricsIter { + pub(crate) fn new(entries: Vec>) -> Self { + Self { + entries: entries.into_iter(), + } + } +} + +impl Iterator for MetricsIter { + type Item = MetricRegistration; + + fn next(&mut self) -> Option { + let entry = self.entries.next()?; + + Some(MetricRegistration { entry }) + } + + fn size_hint(&self) -> (usize, Option) { + self.entries.size_hint() + } +} + +impl ExactSizeIterator for MetricsIter {} diff --git a/foundations-metrics-registry/src/lib.rs b/foundations-metrics-registry/src/lib.rs index 2ff4ad2..f82c86e 100644 --- a/foundations-metrics-registry/src/lib.rs +++ b/foundations-metrics-registry/src/lib.rs @@ -19,4 +19,15 @@ //! [`prometheus/client_model`]: https://github.com/prometheus/client_model #![warn(missing_docs)] +mod encode_metric; +mod iter; +mod metadata; +mod registry; + pub mod proto; + +pub use encode_metric::EncodeMetric; +pub use iter::{MetricRegistration, MetricsIter}; +pub use metadata::RegistrationMetadata; +pub use proto::MetricFamily; +pub use registry::{IntoMetrics, iter, register}; diff --git a/foundations-metrics-registry/src/metadata.rs b/foundations-metrics-registry/src/metadata.rs new file mode 100644 index 0000000..8b394ad --- /dev/null +++ b/foundations-metrics-registry/src/metadata.rs @@ -0,0 +1,33 @@ +/// Metadata attached to a metric at registration time. +/// +/// `#[non_exhaustive]` so fields can be added later on without breaking +/// [`register`](crate::register). Build it from [`default`](Self::default) plus +/// the setters, since downstream crates can't use a struct literal. +#[non_exhaustive] +#[derive(Clone, Default)] +pub struct RegistrationMetadata { + /// Whether the metric is exported only when optional metrics are requested. + pub optional: bool, + + /// Whether to suppress the service-name prefix for this metric + /// + /// The subsystem prefix stays; only the service-name prefix is skipped, and + /// only when the service name is applied as a prefix rather than a label. + pub unprefixed: bool, +} + +impl RegistrationMetadata { + /// Sets [`optional`](Self::optional) + #[must_use] + pub fn optional(mut self, optional: bool) -> Self { + self.optional = optional; + self + } + + /// Sets [`unprefixed`](Self::unprefixed) + #[must_use] + pub fn unprefixed(mut self, unprefixed: bool) -> Self { + self.unprefixed = unprefixed; + self + } +} diff --git a/foundations-metrics-registry/src/registry.rs b/foundations-metrics-registry/src/registry.rs new file mode 100644 index 0000000..0f19a75 --- /dev/null +++ b/foundations-metrics-registry/src/registry.rs @@ -0,0 +1,108 @@ +use parking_lot::RwLock; +use std::sync::{Arc, OnceLock}; + +use crate::EncodeMetric; +use crate::RegistrationMetadata; +use crate::iter::MetricsIter; + +/// A registered metric paired with its registration metadata +pub(crate) struct Entry { + pub(crate) metadata: RegistrationMetadata, + pub(crate) metric: Box, +} + +static REGISTRY: OnceLock>>> = OnceLock::new(); + +fn registry() -> &'static RwLock>> { + REGISTRY.get_or_init(|| RwLock::new(Vec::new())) +} + +/// Registers one or more metrics with the given metadata. +/// +/// Registration is append-only; there is no unregister. Service-name handling +/// deliberately happens at encode time, not here, so metrics can be registered +/// before the service name is known. +pub fn register(metrics: impl IntoMetrics, metadata: RegistrationMetadata) { + let mut guard = registry().write(); + for metric in metrics.into_metrics() { + let entry: Arc = Arc::new(Entry { + metadata: metadata.clone(), + metric, + }); + guard.push(entry); + } +} + +/// Returns a snapshot iterator over the registered metrics and their metadata. +pub fn iter() -> MetricsIter { + let entries: Vec> = registry().read().clone(); + MetricsIter::new(entries) +} + +mod private { + pub trait Sealed {} +} + +/// Converts a value into the metrics it contributes to the registry. +pub trait IntoMetrics: private::Sealed { + /// Consumes `self`, yielding the metrics to be registered. + fn into_metrics(self) -> Vec>; +} + +impl private::Sealed for Box {} +impl IntoMetrics for Box { + fn into_metrics(self) -> Vec> { + vec![self] + } +} + +impl private::Sealed for Vec> {} +impl IntoMetrics for Vec> { + fn into_metrics(self) -> Vec> { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::{Metric, MetricFamily, MetricType}; + + struct Named(&'static str); + + impl EncodeMetric for Named { + fn encode(&self) -> Vec { + vec![MetricFamily { + name: Some(self.0.to_owned()), + help: Some("help.".to_owned()), + r#type: Some(MetricType::Counter as i32), + metric: vec![Metric::default()], + unit: Some("seconds".to_owned()), + }] + } + } + + #[test] + fn register_and_iter_with_metadata() { + register( + Box::new(Named("required_metric")) as Box, + RegistrationMetadata::default(), + ); + register( + vec![Box::new(Named("optional_metric")) as Box], + RegistrationMetadata::default().optional(true), + ); + + let observed: Vec<(bool, Option)> = iter() + .map(|reg| { + ( + reg.metadata().optional, + reg.metric().encode()[0].name.clone(), + ) + }) + .collect(); + + assert!(observed.contains(&(false, Some("required_metric".to_owned())))); + assert!(observed.contains(&(true, Some("optional_metric".to_owned())))); + } +} From c54670aac08b31171b31d39b965fef24a9f52ef4 Mon Sep 17 00:00:00 2001 From: Ethan Olchik <60030956+ethanolchik@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:04:13 +0100 Subject: [PATCH 2/4] 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-registry/src/metadata.rs | 2 +- foundations-metrics-registry/src/registry.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/foundations-metrics-registry/src/metadata.rs b/foundations-metrics-registry/src/metadata.rs index 8b394ad..9ef5257 100644 --- a/foundations-metrics-registry/src/metadata.rs +++ b/foundations-metrics-registry/src/metadata.rs @@ -9,7 +9,7 @@ pub struct RegistrationMetadata { /// Whether the metric is exported only when optional metrics are requested. pub optional: bool, - /// Whether to suppress the service-name prefix for this metric + /// Whether to suppress the service-name prefix for this metric. /// /// The subsystem prefix stays; only the service-name prefix is skipped, and /// only when the service name is applied as a prefix rather than a label. diff --git a/foundations-metrics-registry/src/registry.rs b/foundations-metrics-registry/src/registry.rs index 0f19a75..7a32de7 100644 --- a/foundations-metrics-registry/src/registry.rs +++ b/foundations-metrics-registry/src/registry.rs @@ -44,6 +44,8 @@ mod private { } /// Converts a value into the metrics it contributes to the registry. +/// +/// This allows passing both `Box` and `Vec>` to [`register`]. pub trait IntoMetrics: private::Sealed { /// Consumes `self`, yielding the metrics to be registered. fn into_metrics(self) -> Vec>; From 7babfa5a63b62df81681e133a82366b22e6be39e Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Fri, 10 Jul 2026 17:16:44 +0100 Subject: [PATCH 3/4] refactor(metrics-registry): simplify snapshots with static metric references --- foundations-metrics-registry/src/iter.rs | 20 +++++++++----------- foundations-metrics-registry/src/lib.rs | 2 +- foundations-metrics-registry/src/registry.rs | 19 ++++++++++++------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/foundations-metrics-registry/src/iter.rs b/foundations-metrics-registry/src/iter.rs index 0267995..37161b5 100644 --- a/foundations-metrics-registry/src/iter.rs +++ b/foundations-metrics-registry/src/iter.rs @@ -1,24 +1,22 @@ -use std::sync::Arc; - use crate::EncodeMetric; use crate::RegistrationMetadata; use crate::registry::Entry; /// A registered metric together with its metadata. /// -/// Yielded by [`MetricsIter`] -pub struct MetricRegistration { - entry: Arc, +/// Yielded by [`MetricsIter`]. +pub struct RegisteredMetric { + entry: Entry, } -impl MetricRegistration { +impl RegisteredMetric { /// The metadata supplied to [`register`](crate::register). pub fn metadata(&self) -> &RegistrationMetadata { &self.entry.metadata } /// The registered metric. pub fn metric(&self) -> &dyn EncodeMetric { - self.entry.metric.as_ref() + self.entry.metric } } @@ -27,11 +25,11 @@ impl MetricRegistration { /// Metrics registered after [`iter`](crate::iter()) was called are not observed, /// and the registry lock is not held while iterating. pub struct MetricsIter { - entries: std::vec::IntoIter>, + entries: std::vec::IntoIter, } impl MetricsIter { - pub(crate) fn new(entries: Vec>) -> Self { + pub(crate) fn new(entries: Vec) -> Self { Self { entries: entries.into_iter(), } @@ -39,12 +37,12 @@ impl MetricsIter { } impl Iterator for MetricsIter { - type Item = MetricRegistration; + type Item = RegisteredMetric; fn next(&mut self) -> Option { let entry = self.entries.next()?; - Some(MetricRegistration { entry }) + Some(RegisteredMetric { entry }) } fn size_hint(&self) -> (usize, Option) { diff --git a/foundations-metrics-registry/src/lib.rs b/foundations-metrics-registry/src/lib.rs index f82c86e..15f297b 100644 --- a/foundations-metrics-registry/src/lib.rs +++ b/foundations-metrics-registry/src/lib.rs @@ -27,7 +27,7 @@ mod registry; pub mod proto; pub use encode_metric::EncodeMetric; -pub use iter::{MetricRegistration, MetricsIter}; +pub use iter::{MetricsIter, RegisteredMetric}; pub use metadata::RegistrationMetadata; pub use proto::MetricFamily; pub use registry::{IntoMetrics, iter, register}; diff --git a/foundations-metrics-registry/src/registry.rs b/foundations-metrics-registry/src/registry.rs index 7a32de7..4de5741 100644 --- a/foundations-metrics-registry/src/registry.rs +++ b/foundations-metrics-registry/src/registry.rs @@ -1,19 +1,21 @@ +use std::sync::OnceLock; + use parking_lot::RwLock; -use std::sync::{Arc, OnceLock}; use crate::EncodeMetric; use crate::RegistrationMetadata; use crate::iter::MetricsIter; /// A registered metric paired with its registration metadata +#[derive(Clone)] pub(crate) struct Entry { pub(crate) metadata: RegistrationMetadata, - pub(crate) metric: Box, + pub(crate) metric: &'static dyn EncodeMetric, } -static REGISTRY: OnceLock>>> = OnceLock::new(); +static REGISTRY: OnceLock>> = OnceLock::new(); -fn registry() -> &'static RwLock>> { +fn registry() -> &'static RwLock> { REGISTRY.get_or_init(|| RwLock::new(Vec::new())) } @@ -25,17 +27,20 @@ fn registry() -> &'static RwLock>> { pub fn register(metrics: impl IntoMetrics, metadata: RegistrationMetadata) { let mut guard = registry().write(); for metric in metrics.into_metrics() { - let entry: Arc = Arc::new(Entry { + // The registry is append-only, so registered metrics live for the + // process lifetime and can be shared without reference counting. + let metric: &'static dyn EncodeMetric = Box::leak(metric); + let entry = Entry { metadata: metadata.clone(), metric, - }); + }; guard.push(entry); } } /// Returns a snapshot iterator over the registered metrics and their metadata. pub fn iter() -> MetricsIter { - let entries: Vec> = registry().read().clone(); + let entries = registry().read().clone(); MetricsIter::new(entries) } From 5c1e72fb2f50dc3b95e4710138054d4984d6c303 Mon Sep 17 00:00:00 2001 From: Ethan Olchik Date: Fri, 10 Jul 2026 17:19:40 +0100 Subject: [PATCH 4/4] chore(metrics-registry): remove subsystem comment --- foundations-metrics-registry/src/metadata.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/foundations-metrics-registry/src/metadata.rs b/foundations-metrics-registry/src/metadata.rs index 9ef5257..6ebb8e2 100644 --- a/foundations-metrics-registry/src/metadata.rs +++ b/foundations-metrics-registry/src/metadata.rs @@ -10,9 +10,6 @@ pub struct RegistrationMetadata { pub optional: bool, /// Whether to suppress the service-name prefix for this metric. - /// - /// The subsystem prefix stays; only the service-name prefix is skipped, and - /// only when the service name is applied as a prefix rather than a label. pub unprefixed: bool, }