1#![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#[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#[derive(Clone, Copy)]
88enum MetricKind {
89 Counter,
90 Gauge,
91 Histogram,
92}
93
94impl MetricKind {
95 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#[derive(Clone, Copy)]
107enum Level {
108 Info,
109 Debug,
110 Trace,
111}
112
113impl Level {
114 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
124struct 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
143struct 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
158struct 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 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
201fn 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 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 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 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 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
392fn 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
409fn 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 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
494fn 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 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 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 assert!(out.contains("MappedMetric<Counter>"));
563 assert!(out.contains("Arc::new(labels)"));
564 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 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 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}