Skip to content

feat(prometheus-client-derive): initial implemenation #270

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Copy link
Contributor Author

@ADD-SP ADD-SP Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we gate this dependency using a feature flag?

If so, we should also gate the prometheus-client-derive-encode, which should be considered as a breaking change even if the feature flag is default on.

Since the latest release is 0.23.1, the current version is 0.24.0, we can do breaking changes, but this based on the policy of the project. So the project maintainer may able to make a better call.


[dev-dependencies]
async-std = { version = "1", features = ["attributes"] }
Expand Down
8 changes: 4 additions & 4 deletions derive-encode/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions prometheus-client-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "prometheus-client-derive"
version = "0.24.0"
authors = ["Max Inden <[email protected]>"]
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 }
54 changes: 54 additions & 0 deletions prometheus-client-derive/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, Error>;

#[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(),
}
}
135 changes: 135 additions & 0 deletions prometheus-client-derive/src/registrant/attribute.rs
Original file line number Diff line number Diff line change
@@ -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<syn::LitStr>,
Copy link
Contributor Author

@ADD-SP ADD-SP Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we make the help as a required field? Missing help message might make no sense.

pub unit: Option<syn::LitStr>,
pub rename: Option<syn::LitStr>,
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<Self> {
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<Self> {
let meta = input.parse::<syn::Meta>()?;
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::<syn::LitStr>(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::<syn::LitStr>()?;

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(),
);
Comment on lines +97 to +101
Copy link
Contributor Author

@ADD-SP ADD-SP Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two options:

  1. Silently covert it to lowercase .
  2. Emit compilation error.

Unit is a part of the metric name in Prometheus output, but Prometheus allow uppercase in name which is not the best practice.

What do you think?

attr.unit = Some(unit);
} else if meta.path.is_ident("rename") {
let rename = meta.value()?.parse::<syn::LitStr>()?;
Copy link
Contributor Author

@ADD-SP ADD-SP Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enforce "Names SHOULD be in snake_case".

From README.md

Shall we emit compilation error if the metric name is not snake_case?


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(<key>[=value], ...)]`"#,
)),
}
}
}
65 changes: 65 additions & 0 deletions prometheus-client-derive/src/registrant/field.rs
Original file line number Diff line number Diff line change
@@ -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<syn::Field> for Field {
type Error = syn::Error;

fn try_from(field: syn::Field) -> Result<Self, Self::Error> {
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::<Attribute>(attr.meta.into_token_stream())?);
Ok::<Vec<attribute::Attribute>, syn::Error>(acc)
})?
.into_iter()
.try_fold(Attribute::default(), |acc, attr| acc.merge(attr))?;
Ok(Field { ident, name, attr })
}
}
Loading
Loading