Skip to main content

firewood_macros/
lib.rs

1// Copyright (C) 2023, Ava Labs, Inc. All rights reserved.
2// See the file LICENSE.md for licensing terms.
3
4//! Proc macros for Firewood metrics
5
6use proc_macro::TokenStream;
7use quote::quote;
8use syn::parse::{Parse, ParseStream};
9use syn::{ItemFn, Lit, ReturnType, Token, parse_macro_input};
10
11/// Arguments for the metrics macro
12struct MetricsArgs {
13    name: String,
14    description: Option<String>,
15}
16
17impl Parse for MetricsArgs {
18    fn parse(input: ParseStream) -> syn::Result<Self> {
19        let name_lit: Lit = input.parse()?;
20        let name = match name_lit {
21            Lit::Str(s) => s.value(),
22            _ => {
23                return Err(syn::Error::new_spanned(
24                    name_lit,
25                    "Expected string literal for metric name",
26                ));
27            }
28        };
29
30        let description = if input.parse::<Token![,]>().is_ok() {
31            let desc_lit: Lit = input.parse()?;
32            match desc_lit {
33                Lit::Str(s) => Some(s.value()),
34                _ => {
35                    return Err(syn::Error::new_spanned(
36                        desc_lit,
37                        "Expected string literal for description",
38                    ));
39                }
40            }
41        } else {
42            None
43        };
44
45        Ok(MetricsArgs { name, description })
46    }
47}
48
49/// A proc macro attribute that automatically adds metrics timing to functions.
50///
51/// This macro adds timing instrumentation to functions that return `Result<T, E>`.
52/// It generates two counters:
53/// 1. A count counter with the provided prefix that increments by 1
54/// 2. A timing counter with the prefix + "_ms" that records elapsed time in milliseconds
55///
56/// Both counters include a "success" label that is "true" for Ok results and "false" for Err results.
57/// The metrics are automatically registered with descriptions for better observability.
58///
59/// # Usage
60/// ```rust,ignore
61/// use firewood_macros::metrics;
62///
63/// // Basic usage with just a metric name
64/// #[metrics("my.operation")]
65/// fn my_function() -> Result<String, &'static str> {
66///     // function body
67///     Ok("success".to_string())
68/// }
69///
70/// // With an optional description
71/// #[metrics("my.operation", "Description of what this operation does")]
72/// fn my_function_with_desc() -> Result<String, &'static str> {
73///     // function body
74///     Ok("success".to_string())
75/// }
76/// ```
77///
78/// # Generated Code
79/// The macro transforms the function to include:
80/// - Metric registration with descriptions at the beginning
81/// - A timer start at the beginning of the function
82/// - Metrics collection before returning, with success/failure labels
83/// - Preservation of the original return value
84///
85/// # Requirements
86/// - The function must return a `Result<T, E>` type
87/// - The `metrics` crate must be available
88#[proc_macro_attribute]
89pub fn metrics(args: TokenStream, input: TokenStream) -> TokenStream {
90    let input_fn = parse_macro_input!(input as ItemFn);
91
92    // Parse the attribute arguments - expecting metric name and optional description
93    let (metric_prefix, description) = if args.is_empty() {
94        return syn::Error::new_spanned(
95            &input_fn,
96            "Expected string literal for metric prefix, e.g., #[metrics(\"my.operation\")] or #[metrics(\"my.operation\", \"description\")]",
97        )
98        .to_compile_error()
99        .into();
100    } else {
101        match syn::parse::<MetricsArgs>(args) {
102            Ok(parsed_args) => (parsed_args.name, parsed_args.description),
103            Err(e) => {
104                return syn::Error::new(
105                    e.span(),
106                    "Expected string literal(s) for metric name and optional description",
107                )
108                .to_compile_error()
109                .into();
110            }
111        }
112    };
113
114    // Validate that the function returns a Result
115    let return_type = match &input_fn.sig.output {
116        ReturnType::Type(_, ty) => ty,
117        ReturnType::Default => {
118            return syn::Error::new_spanned(
119                &input_fn.sig,
120                "Function must return a Result<T, E> to use #[metrics] attribute",
121            )
122            .to_compile_error()
123            .into();
124        }
125    };
126
127    // Check if it's a Result type (this is a simple check, could be more sophisticated)
128    let is_result = match return_type.as_ref() {
129        syn::Type::Path(type_path) => type_path
130            .path
131            .segments
132            .last()
133            .is_some_and(|seg| seg.ident == "Result"),
134        _ => false,
135    };
136
137    if !is_result {
138        return syn::Error::new_spanned(
139            return_type,
140            "Function must return a Result<T, E> to use #[metrics] attribute",
141        )
142        .to_compile_error()
143        .into();
144    }
145
146    let args = MetricsArgs {
147        name: metric_prefix,
148        description,
149    };
150
151    let expanded = generate_metrics_wrapper(&input_fn, &args);
152    TokenStream::from(expanded)
153}
154
155fn generate_metrics_wrapper(input_fn: &ItemFn, args: &MetricsArgs) -> proc_macro2::TokenStream {
156    let fn_vis = &input_fn.vis;
157    let fn_sig = &input_fn.sig;
158    let fn_block = &input_fn.block;
159    let fn_attrs = &input_fn.attrs;
160    let metric_prefix = &args.name;
161
162    // Generate description registration code if description is provided
163    let registration_code = if let Some(desc) = &args.description {
164        let count_desc = format!("Number of {desc} operations");
165        let timing_desc = format!("Timing of {desc} operations in milliseconds");
166        quote! {
167            // Register metrics with descriptions (only runs once due to static guard)
168            static __METRICS_REGISTERED: std::sync::Once = std::sync::Once::new();
169            __METRICS_REGISTERED.call_once(|| {
170                metrics::describe_counter!(#metric_prefix, #count_desc);
171                metrics::describe_counter!(concat!(#metric_prefix, "_ms"), #timing_desc);
172            });
173        }
174    } else {
175        quote! {
176            // Register metrics without descriptions (only runs once due to static guard)
177            static __METRICS_REGISTERED: std::sync::Once = std::sync::Once::new();
178            __METRICS_REGISTERED.call_once(|| {
179                metrics::describe_counter!(#metric_prefix, "Operation counter");
180                metrics::describe_counter!(concat!(#metric_prefix, "_ms"), "Operation timing in milliseconds");
181            });
182        }
183    };
184
185    quote! {
186        #(#fn_attrs)*
187        #fn_vis #fn_sig {
188            #registration_code
189
190            let __metrics_start = ::std::time::Instant::now();
191
192            let __metrics_result = { #fn_block };
193
194            // Use static label arrays to avoid runtime allocation
195            static __METRICS_LABELS_SUCCESS: &[(&str, &str)] = &[("success", "true")];
196            static __METRICS_LABELS_ERROR: &[(&str, &str)] = &[("success", "false")];
197            let __metrics_labels = if __metrics_result.is_err() {
198                __METRICS_LABELS_ERROR
199            } else {
200                __METRICS_LABELS_SUCCESS
201            };
202
203            // Increment count counter (base name)
204            metrics::counter!(#metric_prefix, __metrics_labels).increment(1);
205
206            // Increment timing counter (base name + "_ms") using compile-time concatenation
207            metrics::counter!(concat!(#metric_prefix, "_ms"), __metrics_labels)
208                .increment(__metrics_start.elapsed().as_millis() as u64);
209
210            __metrics_result
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    #![expect(clippy::unwrap_used)]
218
219    use super::*;
220
221    #[test]
222    fn test_slow_proc_macro_compilation() {
223        // Test that the proc macro generates compilable code
224        let t = trybuild::TestCases::new();
225        t.pass("tests/compile_pass/*.rs");
226        t.compile_fail("tests/compile_fail/*.rs");
227    }
228
229    #[test]
230    fn test_metrics_args_parsing() {
231        // Test single argument parsing
232        let input = quote::quote! { "test.metric" };
233        let parsed: MetricsArgs = syn::parse2(input).unwrap();
234        assert_eq!(parsed.name, "test.metric");
235        assert_eq!(parsed.description, None);
236
237        // Test two argument parsing
238        let input = quote::quote! { "test.metric", "test description" };
239        let parsed: MetricsArgs = syn::parse2(input).unwrap();
240        assert_eq!(parsed.name, "test.metric");
241        assert_eq!(parsed.description, Some("test description".to_string()));
242    }
243
244    #[test]
245    fn test_invalid_args_parsing() {
246        // Test that invalid arguments fail to parse
247        let input = quote::quote! { 123 };
248        let result: syn::Result<MetricsArgs> = syn::parse2(input);
249        assert!(result.is_err());
250
251        // Test that too many arguments fail
252        let input = quote::quote! { "test.metric", "description", "extra" };
253        let result: syn::Result<MetricsArgs> = syn::parse2(input);
254        assert!(result.is_err());
255    }
256
257    #[test]
258    fn test_generated_code_structure() {
259        // Test that the proc macro generates the expected code structure
260        use syn::{ItemFn, parse_quote};
261
262        let input: ItemFn = parse_quote! {
263            fn test_function() -> Result<(), &'static str> {
264                Ok(())
265            }
266        };
267
268        let args = MetricsArgs {
269            name: "test.metric".to_string(),
270            description: Some("test description".to_string()),
271        };
272
273        let result = generate_metrics_wrapper(&input, &args);
274        let generated_code = result.to_string();
275
276        // Verify key components are present in the generated code
277        assert!(generated_code.contains("__METRICS_LABELS_SUCCESS"));
278        assert!(generated_code.contains("__METRICS_LABELS_ERROR"));
279        assert!(generated_code.contains("test.metric"));
280        assert!(
281            generated_code.contains("std")
282                && generated_code.contains("Instant")
283                && generated_code.contains("now")
284        );
285        assert!(generated_code.contains("metrics") && generated_code.contains("counter"));
286        assert!(generated_code.contains("Number of test description operations"));
287        assert!(generated_code.contains("Timing of test description operations"));
288
289        // Check for the _ms suffix - it should be generated by concat!
290        assert!(generated_code.contains("concat") && generated_code.contains('!'));
291        assert!(generated_code.contains("_ms"));
292    }
293}