Skip to content

Commit

Permalink
feat: Client improvements (#6)
Browse files Browse the repository at this point in the history
# What ❔

Improves the client crate in various ways:

- Supports `Gauge`s for more types, e.g. `usize` / `isize`.
- Supports raw identifiers (e.g., `r#type`) as label names.
- Supports encoding C-style enums as label values and transforming their
variant names like in `serde`.

## Why ❔

Makes the crate easier to use (with the guiding example being the main
Era repo).
  • Loading branch information
slowli authored Sep 20, 2023
1 parent b9e373a commit 1ee530c
Show file tree
Hide file tree
Showing 10 changed files with 865 additions and 413 deletions.
276 changes: 258 additions & 18 deletions crates/vise-macros/src/labels.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,76 @@
//! Derivation of `EncodeLabelValue` and `EncodeLabelSet` traits.
use std::fmt;
use std::{collections::HashSet, fmt};

use proc_macro::TokenStream;
use quote::quote;
use syn::{Attribute, Data, DeriveInput, Field, Ident, LitStr, Path, PathArguments, Type};
use syn::{Attribute, Data, DeriveInput, Field, Fields, Ident, LitStr, Path, PathArguments, Type};

use crate::utils::{metrics_attribute, validate_name, ParseAttribute};

#[derive(Debug, Clone, Copy)]
#[allow(clippy::enum_variant_names)]
enum RenameRule {
LowerCase,
UpperCase,
CamelCase,
SnakeCase,
ScreamingSnakeCase,
KebabCase,
ScreamingKebabCase,
}

impl RenameRule {
fn parse(s: &str) -> Result<Self, &'static str> {
Ok(match s {
"lowercase" => Self::LowerCase,
"UPPERCASE" => Self::UpperCase,
"camelCase" => Self::CamelCase,
"snake_case" => Self::SnakeCase,
"SCREAMING_SNAKE_CASE" => Self::ScreamingSnakeCase,
"kebab-case" => Self::KebabCase,
"SCREAMING-KEBAB-CASE" => Self::ScreamingKebabCase,
_ => {
return Err(
"Invalid case specified; should be one of: lowercase, UPPERCASE, camelCase, \
snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE",
)
}
})
}

fn transform(self, ident: &str) -> String {
debug_assert!(ident.is_ascii()); // Should be checked previously
let (spacing_char, scream) = match self {
Self::LowerCase => return ident.to_ascii_lowercase(),
Self::UpperCase => return ident.to_ascii_uppercase(),
Self::CamelCase => return ident[..1].to_ascii_lowercase() + &ident[1..],
// ^ Since `ident` is an ASCII string, indexing is safe
Self::SnakeCase => ('_', false),
Self::ScreamingSnakeCase => ('_', true),
Self::KebabCase => ('-', false),
Self::ScreamingKebabCase => ('-', true),
};

let mut output = String::with_capacity(ident.len());
for (i, ch) in ident.char_indices() {
if i > 0 && ch.is_ascii_uppercase() {
output.push(spacing_char);
}
output.push(if scream {
ch.to_ascii_uppercase()
} else {
ch.to_ascii_lowercase()
});
}
output
}
}

#[derive(Default)]
struct EncodeLabelAttrs {
cr: Option<Path>,
rename_all: Option<RenameRule>,
format: Option<LitStr>,
label: Option<LitStr>,
}
Expand All @@ -20,8 +80,9 @@ impl fmt::Debug for EncodeLabelAttrs {
formatter
.debug_struct("EncodeLabelAttrs")
.field("cr", &self.cr.as_ref().map(|_| "_"))
.field("format", &self.format.as_ref().map(|_| "_"))
.field("label", &self.label.as_ref().map(|_| "_"))
.field("rename_all", &self.rename_all)
.field("format", &self.format.as_ref().map(LitStr::value))
.field("label", &self.label.as_ref().map(LitStr::value))
.finish()
}
}
Expand All @@ -33,6 +94,12 @@ impl ParseAttribute for EncodeLabelAttrs {
if meta.path.is_ident("crate") {
attrs.cr = Some(meta.value()?.parse()?);
Ok(())
} else if meta.path.is_ident("rename_all") {
let case_str: LitStr = meta.value()?.parse()?;
let case = RenameRule::parse(&case_str.value())
.map_err(|message| syn::Error::new(case_str.span(), message))?;
attrs.rename_all = Some(case);
Ok(())
} else if meta.path.is_ident("format") {
attrs.format = Some(meta.value()?.parse()?);
Ok(())
Expand All @@ -49,20 +116,115 @@ impl ParseAttribute for EncodeLabelAttrs {
}
}

#[derive(Debug)]
struct EnumVariant {
ident: Ident,
label_value: String,
}

impl EnumVariant {
fn encode(&self) -> proc_macro2::TokenStream {
let ident = &self.ident;
let label_value = &self.label_value;
quote!(Self::#ident => #label_value)
}
}

#[derive(Default)]
struct EnumVariantAttrs {
name: Option<LitStr>,
}

impl fmt::Debug for EnumVariantAttrs {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("EnumVariantAttrs")
.field("name", &self.name.as_ref().map(LitStr::value))
.finish()
}
}

impl ParseAttribute for EnumVariantAttrs {
fn parse(raw: &Attribute) -> syn::Result<Self> {
let mut attrs = Self::default();
raw.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
attrs.name = Some(meta.value()?.parse()?);
Ok(())
} else {
Err(meta.error("unsupported attribute"))
}
})?;
Ok(attrs)
}
}

#[derive(Debug)]
struct EncodeLabelValueImpl {
attrs: EncodeLabelAttrs,
name: Ident,
enum_variants: Option<Vec<EnumVariant>>,
}

impl EncodeLabelValueImpl {
fn new(raw: &DeriveInput) -> syn::Result<Self> {
let attrs: EncodeLabelAttrs = metrics_attribute(&raw.attrs)?;
if let Some(format) = &attrs.format {
if attrs.rename_all.is_some() {
let message = "`rename_all` and `format` attributes cannot be specified together";
return Err(syn::Error::new(format.span(), message));
}
}

let enum_variants = attrs
.rename_all
.map(|case| Self::extract_enum_variants(raw, case))
.transpose()?;

Ok(Self {
attrs: metrics_attribute(&raw.attrs)?,
attrs,
enum_variants,
name: raw.ident.clone(),
})
}

fn extract_enum_variants(raw: &DeriveInput, case: RenameRule) -> syn::Result<Vec<EnumVariant>> {
let Data::Enum(data) = &raw.data else {
let message = "`rename_all` attribute can only be placed on enums";
return Err(syn::Error::new_spanned(raw, message));
};

let mut unique_label_values = HashSet::with_capacity(data.variants.len());
let variants = data.variants.iter().map(|variant| {
if !matches!(variant.fields, Fields::Unit) {
let message = "To use `rename_all` attribute, all enum variants must be plain \
(have no fields)";
return Err(syn::Error::new_spanned(variant, message));
}
let ident_str = variant.ident.to_string();
if !ident_str.is_ascii() {
let message = "Variant name must consist of ASCII chars";
return Err(syn::Error::new(variant.ident.span(), message));
}
let attrs: EnumVariantAttrs = metrics_attribute(&variant.attrs)?;
let label_value = if let Some(name_override) = attrs.name {
name_override.value()
} else {
case.transform(&ident_str)
};
if !unique_label_values.insert(label_value.clone()) {
let message = format!("Label value `{label_value}` is redefined");
return Err(syn::Error::new_spanned(variant, message));
}

Ok(EnumVariant {
ident: variant.ident.clone(),
label_value,
})
});
variants.collect()
}

fn impl_value(&self) -> proc_macro2::TokenStream {
let cr = if let Some(cr) = &self.attrs.cr {
quote!(#cr)
Expand All @@ -72,12 +234,27 @@ impl EncodeLabelValueImpl {
let name = &self.name;
let encoding = quote!(#cr::_reexports::encoding);

let format_lit;
let format = if let Some(format) = &self.attrs.format {
format
let encode_impl = if let Some(enum_variants) = &self.enum_variants {
let variant_hands = enum_variants.iter().map(EnumVariant::encode);
quote! {
use core::fmt::Write as _;
core::write!(encoder, "{}", match self {
#(#variant_hands,)*
})
}
} else {
format_lit = LitStr::new("{}", name.span());
&format_lit
let format_lit;
let format = if let Some(format) = &self.attrs.format {
format
} else {
format_lit = LitStr::new("{}", name.span());
&format_lit
};

quote! {
use core::fmt::Write as _;
core::write!(encoder, #format, self)
}
};

quote! {
Expand All @@ -86,8 +263,7 @@ impl EncodeLabelValueImpl {
&self,
encoder: &mut #encoding::LabelValueEncoder<'_>,
) -> core::fmt::Result {
use core::fmt::Write as _;
core::write!(encoder, #format, self)
#encode_impl
}
}
}
Expand Down Expand Up @@ -136,14 +312,25 @@ impl LabelField {
let message = "Encoded fields must be named";
syn::Error::new_spanned(raw, message)
})?;
validate_name(&name.to_string())
.map_err(|message| syn::Error::new(name.span(), message))?;

Ok(Self {
let this = Self {
name,
is_option: Self::detect_is_option(&raw.ty),
attrs: metrics_attribute(&raw.attrs)?,
})
};
validate_name(&this.label_string())
.map_err(|message| syn::Error::new(this.name.span(), message))?;
Ok(this)
}

/// Strips the `r#` prefix from raw identifiers.
fn label_string(&self) -> String {
let label = self.name.to_string();
if let Some(stripped) = label.strip_prefix("r#") {
stripped.to_owned()
} else {
label
}
}

fn detect_is_option(ty: &Type) -> bool {
Expand All @@ -163,7 +350,7 @@ impl LabelField {

fn encode(&self, encoding: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
let name = &self.name;
let label = LitStr::new(&self.name.to_string(), name.span());
let label = LitStr::new(&self.label_string(), name.span());

// Skip `Option`al fields by default if they are `None`.
let default_skip: Path;
Expand Down Expand Up @@ -205,7 +392,7 @@ struct EncodeLabelSetImpl {

impl EncodeLabelSetImpl {
fn new(raw: &DeriveInput) -> syn::Result<Self> {
let EncodeLabelValueImpl { attrs, name } = EncodeLabelValueImpl::new(raw)?;
let EncodeLabelValueImpl { attrs, name, .. } = EncodeLabelValueImpl::new(raw)?;

let fields = if attrs.label.is_some() {
None
Expand Down Expand Up @@ -286,3 +473,56 @@ pub(crate) fn impl_encode_label_set(input: TokenStream) -> TokenStream {
};
trait_impl.impl_set().into()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn renaming_rules() {
let ident = "TestIdent";
let rules_and_expected_outcomes = [
(RenameRule::LowerCase, "testident"),
(RenameRule::UpperCase, "TESTIDENT"),
(RenameRule::CamelCase, "testIdent"),
(RenameRule::SnakeCase, "test_ident"),
(RenameRule::ScreamingSnakeCase, "TEST_IDENT"),
(RenameRule::KebabCase, "test-ident"),
(RenameRule::ScreamingKebabCase, "TEST-IDENT"),
];
for (rule, expected) in rules_and_expected_outcomes {
assert_eq!(rule.transform(ident), expected);
}
}

#[test]
fn encoding_label_set() {
let input: DeriveInput = syn::parse_quote! {
struct TestLabels {
r#type: &'static str,
#[metrics(skip = str::is_empty)]
kind: &'static str,
}
};
let label_set = EncodeLabelSetImpl::new(&input).unwrap();
let fields = label_set.fields.as_ref().unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].label_string(), "type");
assert_eq!(fields[1].label_string(), "kind");
assert!(fields[1].attrs.skip.is_some());
}

#[test]
fn label_value_redefinition_error() {
let input: DeriveInput = syn::parse_quote! {
#[metrics(rename_all = "snake_case")]
enum Label {
First,
#[metrics(name = "first")]
Second,
}
};
let err = EncodeLabelValueImpl::new(&input).unwrap_err().to_string();
assert!(err.contains("Label value `first` is redefined"), "{err}");
}
}
11 changes: 10 additions & 1 deletion crates/vise/src/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use prometheus_client::{

use std::{borrow::Cow, error, fmt, iter};

use crate::{registry::MetricsVisitor, Metrics};
use crate::{
registry::{CollectToRegistry, MetricsVisitor, Registry},
Metrics,
};

type CollectorFn<M> = Box<dyn Fn() -> M + Send + Sync>;
type CollectorItem<'a> = (Cow<'a, Descriptor>, MaybeOwned<'a, Box<dyn LocalMetric>>);
Expand Down Expand Up @@ -91,6 +94,12 @@ impl<M: Metrics> CollectorTrait for &'static Collector<M> {
}
}

impl<M: Metrics> CollectToRegistry for Collector<M> {
fn collect_to_registry(&'static self, registry: &mut Registry) {
registry.register_collector(self);
}
}

#[cfg(test)]
mod tests {
use once_cell::sync::Lazy;
Expand Down
Loading

0 comments on commit 1ee530c

Please sign in to comment.