diff --git a/CHANGELOG.md b/CHANGELOG.md index c798c59..1603406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Family::get_or_create_owned` can access a metric in a labeled family. This method avoids the risk of runtime deadlocks at the expense of creating an owned type. See [PR 244]. - + +- Supported derive macro `Registrant` to register a metric set with a + `Registry`. See [PR 270]. + [PR 244]: https://github.com/prometheus/client_rust/pull/244 [PR 257]: https://github.com/prometheus/client_rust/pull/257 +[PR 270]: https://github.com/prometheus/client_rust/pull/270 ### Changed diff --git a/Cargo.toml b/Cargo.toml index a0f5e2a..2860b2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,15 @@ default = [] protobuf = ["dep:prost", "dep:prost-types", "dep:prost-build"] [workspace] -members = ["derive-encode"] +members = ["derive-encode", "prometheus-client-derive"] + +[workspace.dependencies] +proc-macro2 = "1" +quote = "1" +syn = "2" + +# dev-dependencies +trybuild = "1" [dependencies] dtoa = "1.0" @@ -24,6 +32,7 @@ parking_lot = "0.12" prometheus-client-derive-encode = { version = "0.5.0", path = "derive-encode" } prost = { version = "0.12.0", optional = true } prost-types = { version = "0.12.0", optional = true } +prometheus-client-derive = { version = "0.24.0", path = "./prometheus-client-derive" } [dev-dependencies] async-std = { version = "1", features = ["attributes"] } diff --git a/derive-encode/Cargo.toml b/derive-encode/Cargo.toml index 064cce7..63d5e8a 100644 --- a/derive-encode/Cargo.toml +++ b/derive-encode/Cargo.toml @@ -12,13 +12,13 @@ documentation = "https://docs.rs/prometheus-client-derive-text-encode" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -proc-macro2 = "1" -quote = "1" -syn = "2" +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } [dev-dependencies] prometheus-client = { path = "../", features = ["protobuf"] } -trybuild = "1" +trybuild = { workspace = true } [lib] proc-macro = true diff --git a/prometheus-client-derive/Cargo.toml b/prometheus-client-derive/Cargo.toml new file mode 100644 index 0000000..f9b4852 --- /dev/null +++ b/prometheus-client-derive/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "prometheus-client-derive" +version = "0.24.0" +authors = ["Max Inden "] +edition = "2021" +description = "Macros to derive auxiliary traits for the prometheus-client library." +license = "Apache-2.0 OR MIT" +keywords = ["derive", "prometheus", "metrics", "instrumentation", "monitoring"] +repository = "https://github.com/prometheus/client_rust" +homepage = "https://github.com/prometheus/client_rust" +documentation = "https://docs.rs/prometheus-client" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } + +[dev-dependencies] +prometheus-client = { path = "../" } +trybuild = { workspace = true } diff --git a/prometheus-client-derive/src/lib.rs b/prometheus-client-derive/src/lib.rs new file mode 100644 index 0000000..78e4411 --- /dev/null +++ b/prometheus-client-derive/src/lib.rs @@ -0,0 +1,54 @@ +#![deny(dead_code)] +#![deny(missing_docs)] +#![deny(unused)] +#![forbid(unsafe_code)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! This crate provides a procedural macro to derive +//! auxiliary traits for the +//! [`prometheus_client`](https://docs.rs/prometheus-client/latest/prometheus_client/) +mod registrant; + +use proc_macro::TokenStream as TokenStream1; +use proc_macro2::TokenStream as TokenStream2; +use syn::Error; + +type Result = std::result::Result; + +#[proc_macro_derive(Registrant, attributes(registrant))] +/// Derives the `prometheus_client::registry::Registrant` trait implementation for a struct. +/// ```rust +/// use prometheus_client::metrics::counter::Counter; +/// use prometheus_client::metrics::gauge::Gauge; +/// use prometheus_client::registry::{Registry, Registrant as _}; +/// use prometheus_client_derive::Registrant; +/// +/// #[derive(Registrant)] +/// struct Server { +/// /// Number of HTTP requests received +/// /// from the client +/// requests: Counter, +/// /// Memory usage in bytes +/// /// of the server +/// #[registrant(unit = "bytes")] +/// memory_usage: Gauge, +/// } +/// +/// let mut registry = Registry::default(); +/// let server = Server { +/// requests: Counter::default(), +/// memory_usage: Gauge::default(), +/// }; +/// server.register(&mut registry); +/// ``` +/// +/// There are several field attributes: +/// - `#[registrant(rename = "...")]`: Renames the metric. +/// - `#[registrant(unit = "...")]`: Sets the unit of the metric. +/// - `#[registrant(skip)]`: Skips the field and does not register it. +pub fn registrant_derive(input: TokenStream1) -> TokenStream1 { + match registrant::registrant_impl(input.into()) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} diff --git a/prometheus-client-derive/src/registrant/attribute.rs b/prometheus-client-derive/src/registrant/attribute.rs new file mode 100644 index 0000000..98a0b32 --- /dev/null +++ b/prometheus-client-derive/src/registrant/attribute.rs @@ -0,0 +1,135 @@ +use proc_macro2::Span; +use quote::ToTokens; +use syn::spanned::Spanned; + +// do not derive debug since this needs "extra-traits" +// feature for crate `syn`, which slows compile time +// too much, and is not needed as this struct is not +// public. +#[derive(Default)] +pub struct Attribute { + pub help: Option, + pub unit: Option, + pub rename: Option, + pub skip: bool, +} + +impl Attribute { + fn with_help(mut self, doc: syn::LitStr) -> Self { + self.help = Some(doc); + self + } + + pub(super) fn merge(self, other: Self) -> syn::Result { + let mut merged = self; + + if let Some(help) = other.help { + // trim leading and trailing whitespace + // and add a space between the two doc strings + let mut acc = merged + .help + .unwrap_or_else(|| syn::LitStr::new("", help.span())) + .value() + .trim() + .to_string(); + acc.push(' '); + acc.push_str(help.value().trim()); + merged.help = Some(syn::LitStr::new(&acc, Span::call_site())); + } + if let Some(unit) = other.unit { + if merged.unit.is_some() { + return Err(syn::Error::new_spanned( + merged.unit, + "Duplicate `unit` attribute", + )); + } + + merged.unit = Some(unit); + } + if let Some(rename) = other.rename { + if merged.rename.is_some() { + return Err(syn::Error::new_spanned( + merged.rename, + "Duplicate `rename` attribute", + )); + } + + merged.rename = Some(rename); + } + if other.skip { + merged.skip = merged.skip || other.skip; + } + + Ok(merged) + } +} + +impl syn::parse::Parse for Attribute { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let meta = input.parse::()?; + let span = meta.span(); + + match meta { + syn::Meta::NameValue(meta) if meta.path.is_ident("doc") => { + if let syn::Expr::Lit(lit) = meta.value { + let lit_str = syn::parse2::(lit.lit.to_token_stream())?; + Ok(Attribute::default().with_help(lit_str)) + } else { + Err(syn::Error::new_spanned( + meta.value, + "Expected a string literal for doc attribute", + )) + } + } + syn::Meta::List(meta) if meta.path.is_ident("registrant") => { + let mut attr = Attribute::default(); + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("unit") { + let unit = meta.value()?.parse::()?; + + if attr.unit.is_some() { + return Err(syn::Error::new( + meta.path.span(), + "Duplicate `unit` attribute", + )); + } + + // unit should be lowercase + let unit = syn::LitStr::new( + unit.value().as_str().to_ascii_lowercase().as_str(), + unit.span(), + ); + attr.unit = Some(unit); + } else if meta.path.is_ident("rename") { + let rename = meta.value()?.parse::()?; + + if attr.rename.is_some() { + return Err(syn::Error::new( + meta.path.span(), + "Duplicate `rename` attribute", + )); + } + + attr.rename = Some(rename); + } else if meta.path.is_ident("skip") { + if attr.skip { + return Err(syn::Error::new( + meta.path.span(), + "Duplicate `skip` attribute", + )); + } + attr.skip = true; + } else { + panic!("Attributes other than `unit` and `rename` should not reach here"); + } + Ok(()) + })?; + Ok(attr) + } + _ => Err(syn::Error::new( + span, + r#"Unknown attribute, expected `#[doc(...)]` or `#[registrant([=value], ...)]`"#, + )), + } + } +} diff --git a/prometheus-client-derive/src/registrant/field.rs b/prometheus-client-derive/src/registrant/field.rs new file mode 100644 index 0000000..6ecfcd0 --- /dev/null +++ b/prometheus-client-derive/src/registrant/field.rs @@ -0,0 +1,65 @@ +use super::attribute::Attribute; +use crate::registrant::attribute; +use quote::ToTokens; + +// do not derive debug since this needs "extra-traits" +// feature for crate `syn`, which slows compile time +// too much, and is not needed as this struct is not +// public. +pub struct Field { + ident: syn::Ident, + name: syn::LitStr, + attr: Attribute, +} + +impl Field { + pub(super) fn ident(&self) -> &syn::Ident { + &self.ident + } + + pub(super) fn name(&self) -> &syn::LitStr { + match &self.attr.rename { + Some(rename) => rename, + None => &self.name, + } + } + + pub(super) fn help(&self) -> syn::LitStr { + self.attr + .help + .clone() + .unwrap_or_else(|| syn::LitStr::new("", self.ident.span())) + } + + pub(super) fn unit(&self) -> Option<&syn::LitStr> { + self.attr.unit.as_ref() + } + + pub(super) fn skip(&self) -> bool { + self.attr.skip + } +} + +impl TryFrom for Field { + type Error = syn::Error; + + fn try_from(field: syn::Field) -> Result { + let ident = field + .ident + .clone() + .expect("Fields::Named should have an identifier"); + let name = syn::LitStr::new(&ident.to_string(), ident.span()); + let attr = field + .attrs + .into_iter() + // ignore unknown attributes, which might be defined by another derive macros. + .filter(|attr| attr.path().is_ident("doc") || attr.path().is_ident("registrant")) + .try_fold(vec![], |mut acc, attr| { + acc.push(syn::parse2::(attr.meta.into_token_stream())?); + Ok::, syn::Error>(acc) + })? + .into_iter() + .try_fold(Attribute::default(), |acc, attr| acc.merge(attr))?; + Ok(Field { ident, name, attr }) + } +} diff --git a/prometheus-client-derive/src/registrant/mod.rs b/prometheus-client-derive/src/registrant/mod.rs new file mode 100644 index 0000000..4df46ad --- /dev/null +++ b/prometheus-client-derive/src/registrant/mod.rs @@ -0,0 +1,89 @@ +mod attribute; +mod field; + +use crate::{Error, Result, TokenStream2}; +use quote::quote; + +pub fn registrant_impl(input: TokenStream2) -> Result { + let ast = syn::parse2::(input)?; + let name = ast.ident; + let fields = match ast.data { + syn::Data::Struct(body) => match body.fields { + syn::Fields::Named(fields) => fields, + syn::Fields::Unnamed(fields) => { + return Err(Error::new_spanned( + fields, + "Can not derive Registrant for struct with unnamed fields.", + )); + } + syn::Fields::Unit => { + return Err(Error::new_spanned( + name, + "Can not derive Registrant for unit struct.", + )); + } + }, + syn::Data::Enum(_) => { + return Err(Error::new_spanned( + name, + "Can not derive Registrant for enum.", + )); + } + syn::Data::Union(_) => { + return Err(Error::new_spanned( + name, + "Can not derive Registrant for union.", + )); + } + }; + + let register_calls = fields + .named + .into_iter() + .try_fold(vec![], |mut acc, field| { + acc.push(field::Field::try_from(field)?); + Ok::, syn::Error>(acc) + })? + .into_iter() + .filter_map(|field| { + if field.skip() { + return None; + } + + let ident = field.ident(); + let name = field.name(); + let help = field.help(); + let body = match field.unit() { + Some(unit) => { + quote! { + registry.register_with_unit( + #name, + #help, + ::prometheus_client::registry::Unit::Other(#unit.to_string()), + self.#ident.clone(), + ); + } + } + None => { + quote! { + registry.register( + #name, + #help, + self.#ident.clone(), + ); + } + } + }; + + Some(body) + }); + + let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl(); + Ok(quote! { + impl #impl_generics ::prometheus_client::registry::Registrant for #name #ty_generics #where_clause { + fn register(&self, registry: &mut ::prometheus_client::registry::Registry) { + #(#register_calls)* + } + } + }) +} diff --git a/prometheus-client-derive/tests/build/01-parse.rs b/prometheus-client-derive/tests/build/01-parse.rs new file mode 100644 index 0000000..aec8176 --- /dev/null +++ b/prometheus-client-derive/tests/build/01-parse.rs @@ -0,0 +1,17 @@ +use prometheus_client::metrics::counter::Counter; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client_derive::Registrant; + +#[derive(Registrant)] +struct Server { + /// Number of HTTP requests received + /// from the client + #[registrant(rename = "http_requests")] + requests: Counter, + /// Memory usage in bytes + /// of the server + #[registrant(unit = "bytes")] + memory_usage: Gauge, +} + +fn main() {} diff --git a/prometheus-client-derive/tests/build/02-redefine-prelude-symbols.rs b/prometheus-client-derive/tests/build/02-redefine-prelude-symbols.rs new file mode 100644 index 0000000..3b953fc --- /dev/null +++ b/prometheus-client-derive/tests/build/02-redefine-prelude-symbols.rs @@ -0,0 +1,54 @@ +#![allow(unused_imports)] + +// Empty module has nothing and can be used to redefine symbols. +mod empty {} + +// redefine the prelude `::std` +use empty as std; + +// redefine the dependency `::prometheus_client` +use empty as prometheus_client; + +// redefine the dependency `::prometheus_client_derive` +use empty as prometheus_client_derive; + +// redefine the prelude `::core::result::Result`. +type Result = (); + +enum TResult { + Ok, + Err, +} + +// redefine the prelude `::core::result::Result::Ok/Err`. +use TResult::Ok; +use TResult::Err; + +type Option = (); + +enum TOption { + Some, + None, +} + +// redefine the prelude `::core::option::Option::Some/None`. +use TOption::Some; +use TOption::None; + +#[derive(::prometheus_client_derive::Registrant)] +struct Server { + #[registrant(rename = "requests")] + /// Number of HTTP requests received + /// from the client + reqs: ::prometheus_client::metrics::counter::Counter, + + #[registrant(unit = "bytes")] + /// Memory usage in bytes + /// of the server + mem_usage: ::prometheus_client::metrics::gauge::Gauge, + + #[registrant(skip)] + _phantom: (), +} + +fn main() {} diff --git a/prometheus-client-derive/tests/build/03-rename.rs b/prometheus-client-derive/tests/build/03-rename.rs new file mode 100644 index 0000000..a8794bf --- /dev/null +++ b/prometheus-client-derive/tests/build/03-rename.rs @@ -0,0 +1,13 @@ +use prometheus_client::metrics::counter::Counter; +use prometheus_client_derive::Registrant; + +#[derive(Registrant)] +struct Server { + #[registrant(rename = "http_requests")] + requests: Counter, + + #[registrant(rename = http_requests)] + invalid: Counter, +} + +fn main() {} diff --git a/prometheus-client-derive/tests/build/03-rename.stderr b/prometheus-client-derive/tests/build/03-rename.stderr new file mode 100644 index 0000000..028a537 --- /dev/null +++ b/prometheus-client-derive/tests/build/03-rename.stderr @@ -0,0 +1,5 @@ +error: expected string literal + --> tests/build/03-rename.rs:9:27 + | +9 | #[registrant(rename = http_requests)] + | ^^^^^^^^^^^^^ diff --git a/prometheus-client-derive/tests/build/04-unit.rs b/prometheus-client-derive/tests/build/04-unit.rs new file mode 100644 index 0000000..358e71e --- /dev/null +++ b/prometheus-client-derive/tests/build/04-unit.rs @@ -0,0 +1,13 @@ +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client_derive::Registrant; + +#[derive(Registrant)] +struct Server { + #[registrant(unit = "bytes")] + mem_usage: Gauge, + + #[registrant(unit = bytes)] + invalid: Gauge, +} + +fn main() {} diff --git a/prometheus-client-derive/tests/build/04-unit.stderr b/prometheus-client-derive/tests/build/04-unit.stderr new file mode 100644 index 0000000..60d65c5 --- /dev/null +++ b/prometheus-client-derive/tests/build/04-unit.stderr @@ -0,0 +1,5 @@ +error: expected string literal + --> tests/build/04-unit.rs:9:25 + | +9 | #[registrant(unit = bytes)] + | ^^^^^ diff --git a/prometheus-client-derive/tests/build/05-help.rs b/prometheus-client-derive/tests/build/05-help.rs new file mode 100644 index 0000000..66dd838 --- /dev/null +++ b/prometheus-client-derive/tests/build/05-help.rs @@ -0,0 +1,15 @@ +use prometheus_client::metrics::counter::Counter; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client_derive::Registrant; + +#[derive(Registrant)] +struct Server { + /// One line help + requests: Counter, + + /// Muti-line help + /// with a lot of text + mem_usage: Gauge, +} + +fn main() {} diff --git a/prometheus-client-derive/tests/build/06-attributes.rs b/prometheus-client-derive/tests/build/06-attributes.rs new file mode 100644 index 0000000..83a1446 --- /dev/null +++ b/prometheus-client-derive/tests/build/06-attributes.rs @@ -0,0 +1,16 @@ +#![allow(unused_imports)] +use prometheus_client::metrics::counter::Counter; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client_derive::Registrant; + +#[derive(Registrant)] +struct Server { + #[registrant(rename = "memory_usage", unit = "bytes")] // mutiple attributes in single parenthesis + mem_usage: Gauge, + + #[registrant(rename = "tcp_retransmitted")] + #[registrant(unit = "segments")] + tcp_retrans: Gauge, +} + +fn main() {} diff --git a/prometheus-client-derive/tests/lib.rs b/prometheus-client-derive/tests/lib.rs new file mode 100644 index 0000000..00f7da9 --- /dev/null +++ b/prometheus-client-derive/tests/lib.rs @@ -0,0 +1,62 @@ +use prometheus_client::{ + encoding::text::encode, + metrics::counter::Counter, + metrics::gauge::Gauge, + registry::{Registrant as _, Registry}, +}; +use prometheus_client_derive::Registrant; + +#[test] +fn build() { + let t = trybuild::TestCases::new(); + t.pass("tests/build/01-parse.rs"); + t.pass("tests/build/02-redefine-prelude-symbols.rs"); + t.compile_fail("tests/build/03-rename.rs"); + t.compile_fail("tests/build/04-unit.rs"); + t.pass("tests/build/05-help.rs"); + t.pass("tests/build/06-attributes.rs"); +} + +#[test] +fn sanity() { + #[derive(Registrant)] + struct HttpServer { + /// Number of HTTP requests received + /// from the client + #[registrant(rename = "http_requests")] + requests: Counter, + + /// Memory usage in bytes + /// of the server + #[registrant(unit = "bytes")] + memory_usage: Gauge, + + #[registrant(skip)] + #[allow(dead_code)] + skip: (), + } + + let mut registry = Registry::default(); + let http_server = HttpServer { + requests: Counter::default(), + memory_usage: Gauge::default(), + skip: (), + }; + http_server.register(&mut registry); + + let mut buf = String::new(); + encode(&mut buf, ®istry).unwrap(); + + let expected = [ + "# HELP http_requests Number of HTTP requests received from the client.", + "# TYPE http_requests counter", + "http_requests_total 0", + "# HELP memory_usage_bytes Memory usage in bytes of the server.", + "# TYPE memory_usage_bytes gauge", + "# UNIT memory_usage_bytes bytes", + "memory_usage_bytes 0", + "# EOF\n", + ] + .join("\n"); + assert_eq!(buf, expected); +} diff --git a/src/lib.rs b/src/lib.rs index cfff623..1335e4c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,3 +83,6 @@ pub mod collector; pub mod encoding; pub mod metrics; pub mod registry; + +#[doc(inline)] +pub use prometheus_client_derive::Registrant; diff --git a/src/registry.rs b/src/registry.rs index c7dec3b..c1359da 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -389,3 +389,15 @@ pub trait Metric: crate::encoding::EncodeMetric + Send + Sync + std::fmt::Debug impl Metric for T where T: crate::encoding::EncodeMetric + Send + Sync + std::fmt::Debug + 'static {} + +/// Something that can be registered +pub trait Registrant { + // An alternative signature would be: + // ``` + // fn register(registry: &mut Registry) -> Self; + // ``` + // But this is not dyn compatible. + + /// Register into the given registry. + fn register(&self, registry: &mut Registry); +}