saluki_metrics_macros/
lib.rs

1//! Procedural macros for `saluki-metrics`.
2//!
3//! This crate provides the [`static_metrics`] attribute macro. It is an implementation detail of `saluki-metrics` and
4//! should be used through the re-export there (`saluki_metrics::static_metrics`) rather than depended on directly.
5
6#![deny(missing_docs)]
7
8use proc_macro::TokenStream;
9use proc_macro2::{Span, TokenStream as TokenStream2};
10use quote::{format_ident, quote};
11use syn::{
12    parse::{Parse, ParseStream, Parser},
13    parse_quote, Fields, Ident, ItemStruct, LitStr, Token, Type,
14};
15
16/// Defines a container struct of statically defined metrics.
17///
18/// Applied to a struct whose fields are metric handles, this generates the registration, accessors, and metadata for
19/// the whole group. It is re-exported (and intended to be used) as `saluki_metrics::static_metrics`.
20///
21/// Because it can rewrite field storage (see "Mapped metrics" below), it must appear **before** any `#[derive(...)]`
22/// on the struct.
23///
24/// # Fields
25///
26/// Every field must be typed as `Counter`, `Gauge`, or `Histogram` (from the `metrics` crate, re-exported by
27/// `saluki-metrics`). Each field becomes one registered metric whose name is `"<prefix>_<field_name>"`.
28///
29/// # Arguments
30///
31/// - `prefix = <ident>` (**required**): a bare identifier prefixed to every metric name, for example `prefix = cache`.
32/// - `labels(a, b, ...)` (optional): the names of labels applied to every metric in the group. The label *values*
33///   are supplied to the generated `new()` and may be of any type implementing `saluki_metrics::Stringable`.
34///
35/// # Field attributes
36///
37/// - `#[metric(level = info | debug | trace)]` (optional, default `info`): the metric's verbosity level.
38/// - `#[metric(mapped(...))]` (optional): see below.
39///
40/// # Generated code
41///
42/// For a struct `Foo`, this generates `Foo::new(<label>, ...) -> Foo` (generic over each label value's type), a
43/// `Foo::<field>(&self) -> &<Handle>` accessor per metric, a `Foo::<field>_name() -> &'static str` helper, and a
44/// `Debug` implementation that prints only the struct name. `Clone` is not generated; derive it directly where needed.
45///
46/// # Mapped metrics
47///
48/// A field can be *mapped* by one or more labels whose values are supplied at emission time rather than at
49/// construction:
50///
51/// ```ignore
52/// #[static_metrics(prefix = component, labels(component_id))]
53/// #[derive(Clone)]
54/// struct Metrics {
55///     events_sent_total: Counter,
56///     // one label, sourced from any `Stringable`:
57///     #[metric(mapped(reason))]
58///     events_discarded_total: Counter,
59///     // or pinned to a concrete type for misuse resistance (and, potentially, cheaper conversion):
60///     #[metric(mapped(reason: DiscardReason))]
61///     other_total: Counter,
62/// }
63/// ```
64///
65/// A mapped field's storage is rewritten to hold a concurrent map of the dynamic label values to lazily registered
66/// handles (the source keeps writing `Counter`/`Gauge`/`Histogram`). Its accessor accepts each label value either by
67/// value or by reference and returns an owned handle. A typed label still only accepts its concrete type (or a
68/// reference to it), so lightweight `Copy` values can be passed directly without borrowing:
69///
70/// ```ignore
71/// // bare label -> generic `Stringable` parameter; typed label -> `Borrow<T>`, so `T` or `&T` are both accepted
72/// metrics.events_discarded_total("queue_full").increment(1);
73/// metrics.other_total(DiscardReason::QueueFull).increment(1);
74/// metrics.other_total(&DiscardReason::QueueFull).increment(1);
75/// ```
76///
77/// Each mapped handle is registered with the fixed struct labels plus the mapped labels. One or many labels may be
78/// given, comma-separated, mixing bare and typed forms.
79#[proc_macro_attribute]
80pub fn static_metrics(attr: TokenStream, item: TokenStream) -> TokenStream {
81    expand(attr.into(), item.into())
82        .unwrap_or_else(syn::Error::into_compile_error)
83        .into()
84}
85
86/// The metric kind, inferred from a field's declared type.
87#[derive(Clone, Copy)]
88enum MetricKind {
89    Counter,
90    Gauge,
91    Histogram,
92}
93
94impl MetricKind {
95    /// Returns the `metrics` registration macro name for this kind (`counter`/`gauge`/`histogram`).
96    fn macro_ident(self) -> Ident {
97        match self {
98            MetricKind::Counter => format_ident!("counter"),
99            MetricKind::Gauge => format_ident!("gauge"),
100            MetricKind::Histogram => format_ident!("histogram"),
101        }
102    }
103}
104
105/// The verbosity level of a metric.
106#[derive(Clone, Copy)]
107enum Level {
108    Info,
109    Debug,
110    Trace,
111}
112
113impl Level {
114    /// Returns the fully qualified `metrics::Level` variant for this level.
115    fn to_tokens(self) -> TokenStream2 {
116        match self {
117            Level::Info => quote! { ::saluki_metrics::reexport::metrics::Level::INFO },
118            Level::Debug => quote! { ::saluki_metrics::reexport::metrics::Level::DEBUG },
119            Level::Trace => quote! { ::saluki_metrics::reexport::metrics::Level::TRACE },
120        }
121    }
122}
123
124/// A single mapped label: a bare name (`reason`), optionally pinned to a concrete type (`reason: DiscardReason`).
125struct MappedLabel {
126    name: Ident,
127    ty: Option<Type>,
128}
129
130impl Parse for MappedLabel {
131    fn parse(input: ParseStream) -> syn::Result<Self> {
132        let name = input.parse()?;
133        let ty = if input.peek(Token![:]) {
134            input.parse::<Token![:]>()?;
135            Some(input.parse()?)
136        } else {
137            None
138        };
139        Ok(Self { name, ty })
140    }
141}
142
143/// A single metric parsed from a struct field.
144struct MetricField {
145    ident: Ident,
146    ty: Type,
147    kind: MetricKind,
148    level: Level,
149    mapped: Vec<MappedLabel>,
150}
151
152impl MetricField {
153    fn is_mapped(&self) -> bool {
154        !self.mapped.is_empty()
155    }
156}
157
158/// The struct-level configuration parsed from the attribute arguments.
159struct Container {
160    prefix: Ident,
161    labels: Vec<Ident>,
162}
163
164fn parse_container(attr: TokenStream2) -> syn::Result<Container> {
165    let mut prefix = None;
166    let mut labels = Vec::new();
167
168    let parser = syn::meta::parser(|meta| {
169        if meta.path.is_ident("prefix") {
170            // The prefix is a bare identifier, matching the declarative `static_metrics!` macro (`prefix => cache`).
171            // Parsing it as an `Ident` deliberately rejects a quoted `prefix = "cache"`.
172            let value: Ident = meta.value()?.parse()?;
173            prefix = Some(value);
174            Ok(())
175        } else if meta.path.is_ident("labels") {
176            meta.parse_nested_meta(|label| {
177                let ident = label
178                    .path
179                    .get_ident()
180                    .ok_or_else(|| label.error("label names must be simple identifiers"))?
181                    .clone();
182                labels.push(ident);
183                Ok(())
184            })
185        } else {
186            Err(meta.error("unknown `static_metrics` argument; expected `prefix` or `labels`"))
187        }
188    });
189    parser.parse2(attr)?;
190
191    let prefix = prefix.ok_or_else(|| {
192        syn::Error::new(
193            Span::call_site(),
194            "`static_metrics` requires a `prefix = <ident>` argument",
195        )
196    })?;
197
198    Ok(Container { prefix, labels })
199}
200
201/// Infers the metric kind from a field's type, matching on the last path segment so both `Counter` and
202/// `metrics::Counter` resolve.
203fn metric_kind(ty: &Type) -> Option<MetricKind> {
204    let Type::Path(type_path) = ty else {
205        return None;
206    };
207
208    match type_path.path.segments.last()?.ident.to_string().as_str() {
209        "Counter" => Some(MetricKind::Counter),
210        "Gauge" => Some(MetricKind::Gauge),
211        "Histogram" => Some(MetricKind::Histogram),
212        _ => None,
213    }
214}
215
216fn parse_field(field: &syn::Field) -> syn::Result<MetricField> {
217    let ident = field
218        .ident
219        .clone()
220        .ok_or_else(|| syn::Error::new_spanned(field, "metric fields must be named"))?;
221    let ty = field.ty.clone();
222    let kind = metric_kind(&ty).ok_or_else(|| {
223        syn::Error::new_spanned(
224            &field.ty,
225            "metric field type must be `Counter`, `Gauge`, or `Histogram`",
226        )
227    })?;
228
229    let mut level = Level::Info;
230    let mut mapped = Vec::new();
231    for attr in &field.attrs {
232        if !attr.path().is_ident("metric") {
233            continue;
234        }
235
236        attr.parse_nested_meta(|meta| {
237            if meta.path.is_ident("level") {
238                let value: Ident = meta.value()?.parse()?;
239                level = match value.to_string().as_str() {
240                    "info" => Level::Info,
241                    "debug" => Level::Debug,
242                    "trace" => Level::Trace,
243                    _ => {
244                        return Err(syn::Error::new_spanned(
245                            &value,
246                            "metric level must be `info`, `debug`, or `trace`",
247                        ))
248                    }
249                };
250                Ok(())
251            } else if meta.path.is_ident("mapped") {
252                let content;
253                syn::parenthesized!(content in meta.input);
254                let parsed = content.parse_terminated(MappedLabel::parse, Token![,])?;
255                if parsed.is_empty() {
256                    return Err(meta.error("`mapped(...)` requires at least one label name"));
257                }
258                mapped.extend(parsed);
259                Ok(())
260            } else {
261                Err(meta.error("unknown `metric` key; expected `level` or `mapped`"))
262            }
263        })?;
264    }
265
266    Ok(MetricField {
267        ident,
268        ty,
269        kind,
270        level,
271        mapped,
272    })
273}
274
275fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
276    let mut item = syn::parse2::<ItemStruct>(item)?;
277    let container = parse_container(attr)?;
278
279    let named = match &item.fields {
280        Fields::Named(named) => named,
281        _ => {
282            return Err(syn::Error::new_spanned(
283                &item,
284                "`static_metrics` requires a struct with named fields",
285            ))
286        }
287    };
288    if named.named.is_empty() {
289        return Err(syn::Error::new_spanned(
290            &item,
291            "`static_metrics` requires at least one metric field",
292        ));
293    }
294
295    // Parse every field, accumulating errors so multiple problems surface at once.
296    let mut errors = Vec::new();
297    let mut metrics = Vec::new();
298    for field in &named.named {
299        match parse_field(field) {
300            Ok(metric) => metrics.push(metric),
301            Err(error) => errors.push(error),
302        }
303    }
304    if let Some(error) = combine_errors(errors) {
305        return Err(error);
306    }
307
308    let prefix = container.prefix.to_string();
309    let any_mapped = metrics.iter().any(MetricField::is_mapped);
310
311    // The struct-level labels become generic, `Stringable`-bounded parameters on `new()`.
312    let label_generics: Vec<Ident> = (0..container.labels.len()).map(|i| format_ident!("L{}", i)).collect();
313    let new_params = container
314        .labels
315        .iter()
316        .zip(&label_generics)
317        .map(|(key, generic)| quote! { #key: #generic });
318    let new_generics = if label_generics.is_empty() {
319        quote! {}
320    } else {
321        quote! { < #(#label_generics),* > }
322    };
323    let new_where = if label_generics.is_empty() {
324        quote! {}
325    } else {
326        quote! { where #(#label_generics: ::saluki_metrics::Stringable,)* }
327    };
328    let label_entries = container.labels.iter().map(|key| {
329        let key_str = LitStr::new(&key.to_string(), key.span());
330        quote! {
331            ::saluki_metrics::reexport::metrics::Label::new(
332                #key_str,
333                ::saluki_metrics::Stringable::to_shared_string(&#key),
334            )
335        }
336    });
337    // When any field is mapped, the fixed label set is shared (via `Arc`) into each mapped field's storage.
338    let labels_binding = if any_mapped {
339        quote! {
340            let labels: ::std::vec::Vec<::saluki_metrics::reexport::metrics::Label> = ::std::vec![ #(#label_entries,)* ];
341            let labels = ::std::sync::Arc::new(labels);
342        }
343    } else {
344        quote! {
345            let labels: ::std::vec::Vec<::saluki_metrics::reexport::metrics::Label> = ::std::vec![ #(#label_entries,)* ];
346        }
347    };
348
349    let field_inits = metrics.iter().map(|metric| field_init(metric, &prefix));
350    let methods = metrics.iter().map(|metric| field_methods(metric, &prefix));
351
352    // Rewrite the struct: strip the `#[metric(...)]` field attributes (they are not inert for an attribute macro) and
353    // rewrite mapped fields' storage to `MappedMetric<Handle>`. Everything else (visibility, other attributes such as
354    // `#[derive(Clone)]`, generics) is preserved.
355    if let Fields::Named(named) = &mut item.fields {
356        for (field, metric) in named.named.iter_mut().zip(&metrics) {
357            field.attrs.retain(|attr| !attr.path().is_ident("metric"));
358            if metric.is_mapped() {
359                let handle_ty = &metric.ty;
360                field.ty = parse_quote! { ::saluki_metrics::MappedMetric<#handle_ty> };
361            }
362        }
363    }
364
365    let struct_name = &item.ident;
366    let struct_name_str = struct_name.to_string();
367    let (impl_generics, ty_generics, where_clause) = item.generics.split_for_impl();
368
369    Ok(quote! {
370        #item
371
372        impl #impl_generics #struct_name #ty_generics #where_clause {
373            pub fn new #new_generics ( #(#new_params),* ) -> Self #new_where {
374                #labels_binding
375
376                Self {
377                    #(#field_inits,)*
378                }
379            }
380
381            #(#methods)*
382        }
383
384        impl #impl_generics ::std::fmt::Debug for #struct_name #ty_generics #where_clause {
385            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
386                f.write_str(#struct_name_str)
387            }
388        }
389    })
390}
391
392/// Generates the code that sets a field inside `new()`.
393fn field_init(metric: &MetricField, prefix: &str) -> TokenStream2 {
394    let ident = &metric.ident;
395    if metric.is_mapped() {
396        quote! {
397            #ident: ::saluki_metrics::MappedMetric::new(::std::sync::Arc::clone(&labels))
398        }
399    } else {
400        let macro_ident = metric.kind.macro_ident();
401        let level = metric.level.to_tokens();
402        let name = LitStr::new(&format!("{}_{}", prefix, ident), ident.span());
403        quote! {
404            #ident: ::saluki_metrics::reexport::metrics::#macro_ident!(level: #level, #name, labels.iter())
405        }
406    }
407}
408
409/// Generates the accessors and `<field>_name()` helper for a metric field.
410fn field_methods(metric: &MetricField, prefix: &str) -> TokenStream2 {
411    let ident = &metric.ident;
412    let ty = &metric.ty;
413    let name_fn = format_ident!("{}_name", ident);
414    let name = LitStr::new(&format!("{}_{}", prefix, ident), ident.span());
415    let name_doc = format!("Gets the full name of the `{}` metric as it will be registered.", ident);
416    let name_helper = quote! {
417        #[doc = #name_doc]
418        #[doc = ""]
419        #[doc = "This can be useful when testing metrics, as it ensures you can grab the correct metric name to search for."]
420        pub fn #name_fn() -> &'static str {
421            #name
422        }
423    };
424
425    if !metric.is_mapped() {
426        return quote! {
427            pub fn #ident(&self) -> &#ty {
428                &self.#ident
429            }
430
431            #name_helper
432        };
433    }
434
435    // Mapped accessor: every label is accepted by value *or* by reference through a generic parameter, so lightweight
436    // `Copy` values (enums, integers) need not be borrowed at the call site. A bare label's parameter is bounded by
437    // `Stringable` directly; a typed label's parameter is bounded by `Borrow<T>`, which keeps the concrete type pinned
438    // (for misuse resistance) while accepting both `T` and `&T` (`T: Borrow<T>` and `&T: Borrow<T>` both hold). In
439    // either case we only ever need a shared reference to stringify the value via its `Display` impl.
440    let mut generics = Vec::new();
441    let mut bounds = Vec::new();
442    let mut params = Vec::new();
443    let mut key_lits = Vec::new();
444    let mut value_exprs = Vec::new();
445    for label in &metric.mapped {
446        let label_name = &label.name;
447        key_lits.push(LitStr::new(&label_name.to_string(), label_name.span()));
448
449        let generic = format_ident!("L{}", generics.len());
450        params.push(quote! { #label_name: #generic });
451        match &label.ty {
452            Some(ty) => {
453                bounds.push(quote! { #generic: ::std::borrow::Borrow<#ty> });
454                value_exprs.push(quote! {
455                    ::saluki_metrics::Stringable::to_shared_string(::std::borrow::Borrow::borrow(&#label_name))
456                });
457            }
458            None => {
459                bounds.push(quote! { #generic: ::saluki_metrics::Stringable });
460                value_exprs.push(quote! { ::saluki_metrics::Stringable::to_shared_string(&#label_name) });
461            }
462        }
463        generics.push(generic);
464    }
465
466    let getter_generics = if generics.is_empty() {
467        quote! {}
468    } else {
469        quote! { < #(#generics),* > }
470    };
471    let getter_where = if bounds.is_empty() {
472        quote! {}
473    } else {
474        quote! { where #(#bounds,)* }
475    };
476
477    let macro_ident = metric.kind.macro_ident();
478    let level = metric.level.to_tokens();
479
480    quote! {
481        pub fn #ident #getter_generics (&self, #(#params),*) -> #ty #getter_where {
482            let values = [ #(#value_exprs,)* ];
483            self.#ident.get_or_register(
484                &[ #(#key_lits,)* ],
485                &values,
486                |labels| ::saluki_metrics::reexport::metrics::#macro_ident!(level: #level, #name, labels.iter()),
487            )
488        }
489
490        #name_helper
491    }
492}
493
494/// Combines a collection of errors into a single error, if any are present.
495fn combine_errors(errors: Vec<syn::Error>) -> Option<syn::Error> {
496    let mut iter = errors.into_iter();
497    let mut combined = iter.next()?;
498    for error in iter {
499        combined.combine(error);
500    }
501    Some(combined)
502}
503
504#[cfg(test)]
505mod tests {
506    use quote::quote;
507
508    use super::*;
509
510    /// Expands the input and returns the generated tokens with all whitespace removed, so assertions can match token
511    /// sequences without depending on `TokenStream`'s inter-token spacing.
512    fn expand_str(attr: TokenStream2, item: TokenStream2) -> String {
513        expand(attr, item)
514            .expect("expansion failed")
515            .to_string()
516            .replace(char::is_whitespace, "")
517    }
518
519    fn expand_err(attr: TokenStream2, item: TokenStream2) -> String {
520        expand(attr, item)
521            .expect_err("expansion should have failed")
522            .to_string()
523    }
524
525    #[test]
526    fn generates_names_levels_accessors_and_debug() {
527        let out = expand_str(
528            quote! { prefix = cache, labels(id) },
529            quote! {
530                struct Telemetry {
531                    hits_total: Counter,
532                    #[metric(level = debug)]
533                    items_inserted_total: Counter,
534                }
535            },
536        );
537
538        assert!(out.contains("\"cache_hits_total\""));
539        assert!(out.contains("\"cache_items_inserted_total\""));
540        assert!(out.contains("Level::INFO"));
541        assert!(out.contains("Level::DEBUG"));
542        assert!(out.contains("fnnew<L0>"));
543        assert!(out.contains("hits_total_name"));
544        assert!(out.contains("write_str(\"Telemetry\")"));
545        // Nothing is mapped, so no map storage is emitted.
546        assert!(!out.contains("MappedMetric"));
547    }
548
549    #[test]
550    fn mapped_bare_label_generates_generic_getter() {
551        let out = expand_str(
552            quote! { prefix = component, labels(component_id) },
553            quote! {
554                struct Telemetry {
555                    #[metric(mapped(reason))]
556                    events_discarded_total: Counter,
557                }
558            },
559        );
560
561        // The field storage is rewritten to a `MappedMetric`, and the fixed labels are shared via `Arc`.
562        assert!(out.contains("MappedMetric<Counter>"));
563        assert!(out.contains("Arc::new(labels)"));
564        // The accessor is generic over the bare label's value type (accepted by value or reference) and resolves the
565        // handle lazily.
566        assert!(out.contains("fnevents_discarded_total<L0>"));
567        assert!(out.contains("reason:L0"));
568        assert!(out.contains("L0:::saluki_metrics::Stringable"));
569        assert!(out.contains("get_or_register"));
570        assert!(out.contains("\"reason\""));
571    }
572
573    #[test]
574    fn mapped_typed_label_uses_concrete_type() {
575        let out = expand_str(
576            quote! { prefix = component },
577            quote! {
578                struct Telemetry {
579                    #[metric(mapped(reason: DiscardReason))]
580                    events_discarded_total: Counter,
581                }
582            },
583        );
584
585        // A typed mapped label pins the parameter through `Borrow<T>`, so both `T` and `&T` are accepted at the call
586        // site while the concrete type stays fixed.
587        assert!(out.contains("fnevents_discarded_total<L0>(&self,reason:L0)"));
588        assert!(out.contains("L0:::std::borrow::Borrow<DiscardReason>"));
589    }
590
591    #[test]
592    fn mapped_mixed_multi_label() {
593        let out = expand_str(
594            quote! { prefix = component },
595            quote! {
596                struct Telemetry {
597                    #[metric(mapped(origin, reason: DiscardReason))]
598                    events_discarded_total: Counter,
599                }
600            },
601        );
602
603        assert!(out.contains("fnevents_discarded_total<L0,L1>"));
604        assert!(out.contains("origin:L0"));
605        assert!(out.contains("reason:L1"));
606        assert!(out.contains("L0:::saluki_metrics::Stringable"));
607        assert!(out.contains("L1:::std::borrow::Borrow<DiscardReason>"));
608        assert!(out.contains("\"origin\""));
609        assert!(out.contains("\"reason\""));
610    }
611
612    #[test]
613    fn rejects_warn_level() {
614        let err = expand_err(
615            quote! { prefix = p },
616            quote! { struct M { #[metric(level = warn)] bar: Counter } },
617        );
618        assert_eq!(err, "metric level must be `info`, `debug`, or `trace`");
619    }
620
621    #[test]
622    fn rejects_unknown_field_type() {
623        let err = expand_err(quote! { prefix = p }, quote! { struct M { bar: String } });
624        assert_eq!(err, "metric field type must be `Counter`, `Gauge`, or `Histogram`");
625    }
626
627    #[test]
628    fn requires_prefix() {
629        let err = expand_err(quote! { labels(id) }, quote! { struct M { bar: Counter } });
630        assert_eq!(err, "`static_metrics` requires a `prefix = <ident>` argument");
631    }
632
633    #[test]
634    fn rejects_string_literal_prefix() {
635        // The prefix must be a bare identifier (`prefix = cache`), mirroring the declarative `static_metrics!` macro; a
636        // quoted `prefix = "cache"` is rejected during parsing.
637        let err = expand_err(quote! { prefix = "cache" }, quote! { struct M { bar: Counter } });
638        assert!(err.contains("expected identifier"), "unexpected error message: {err}");
639    }
640
641    #[test]
642    fn rejects_non_struct() {
643        let err = expand_err(quote! { prefix = p }, quote! { enum E { A } });
644        assert_eq!(err, "expected `struct`");
645    }
646
647    #[test]
648    fn requires_at_least_one_field() {
649        let err = expand_err(quote! { prefix = p }, quote! { struct M {} });
650        assert_eq!(err, "`static_metrics` requires at least one metric field");
651    }
652
653    #[test]
654    fn rejects_empty_mapped() {
655        let err = expand_err(
656            quote! { prefix = p },
657            quote! { struct M { #[metric(mapped())] bar: Counter } },
658        );
659        assert_eq!(err, "`mapped(...)` requires at least one label name");
660    }
661}