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