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]
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#[derive(Clone, Copy)]
90enum MetricKind {
91 Counter,
92 Gauge,
93 Histogram,
94}
95
96impl MetricKind {
97 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#[derive(Clone, Copy)]
109enum Level {
110 Info,
111 Debug,
112 Trace,
113}
114
115impl Level {
116 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
126struct 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
145struct 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
160struct 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 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
203fn 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 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 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 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 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
394fn 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
411fn 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 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
496fn 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 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 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 assert!(out.contains("MappedMetric<Counter>"));
565 assert!(out.contains("Arc::new(labels)"));
566 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 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 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}