metrics_helper_macros/
lib.rs

1//! Proc-macros for idiomatic Prometheus metrics instrumentation
2//!
3//! Provides the `#[instrument]` attribute macro for ergonomic
4//! function instrumentation with counters, histograms, and error tracking.
5//!
6//! # Quick Start
7//!
8//! Metric names are automatically derived from the function name:
9//!
10//! ```ignore
11//! use metrics_helper_macros::instrument;
12//!
13//! // Auto-generates metrics:
14//! // - counter: "get_users_total"
15//! // - histogram: "get_users_duration_seconds"
16//! // - error_counter: "get_users_errors_total"
17//! #[instrument]
18//! async fn get_users(method: &str) -> Result<Vec<User>, ApiError> {
19//!     // Your code here...
20//! }
21//! ```
22//!
23//! You can also override specific metric names:
24//!
25//! ```ignore
26//! #[instrument(
27//!     counter = "http_requests_total",  // Override counter name
28//!     labels(endpoint = "/users", method),
29//! )]
30//! async fn get_users(method: &str) -> Result<Vec<User>, ApiError> {
31//!     // Your code here...
32//! }
33//! ```
34//!
35//! # Labels
36//!
37//! Labels can be either **static** (fixed values) or **dynamic** (from function parameters):
38//!
39//! ## Static Labels
40//!
41//! Use `key = "value"` syntax for labels with fixed values:
42//!
43//! ```ignore
44//! #[instrument(labels(service = "api", version = "v1"))]
45//! fn handle() { }
46//! ```
47//!
48//! ## Dynamic Labels (from function parameters)
49//!
50//! Use just the parameter name (without a value) to capture it as a label.
51//! The parameter must implement `Display`:
52//!
53//! ```ignore
54//! #[instrument(labels(
55//!     table = "users",  // static: always "users"
56//!     operation,        // dynamic: captured from function param
57//!     tenant_id,        // dynamic: captured from function param
58//! ))]
59//! async fn query(operation: &str, tenant_id: &str, limit: usize) -> Result<Data, Error> {
60//!     // Metrics will include: table="users", operation=<value>, tenant_id=<value>
61//! }
62//! ```
63//!
64//! This generates metrics like:
65//! ```text
66//! query_total{table="users", operation="select", tenant_id="acme-corp"} 1
67//! ```
68//!
69//! ## Dynamic Labels (from struct fields)
70//!
71//! Use dot notation (`param.field`) to capture struct fields as labels.
72//! The field must implement `Display`:
73//!
74//! ```ignore
75//! struct Request {
76//!     method: String,
77//!     path: String,
78//!     user_id: u64,
79//! }
80//!
81//! #[instrument(labels(
82//!     service = "api",   // static label
83//!     request.method,    // captures request.method as "method" label
84//!     request.path,      // captures request.path as "path" label
85//! ))]
86//! fn handle_request(request: &Request) {
87//!     // Metrics will include: service="api", method=<value>, path=<value>
88//! }
89//! ```
90//!
91//! You can also specify an explicit key name:
92//!
93//! ```ignore
94//! #[instrument(labels(http_method = request.method))]
95//! fn handle(request: &Request) { }
96//! ```
97//!
98//! Nested field access is also supported:
99//!
100//! ```ignore
101//! #[instrument(labels(ctx.request.method))]
102//! fn process(ctx: &Context) { }
103//! ```
104//!
105use darling::ast::NestedMeta;
106use darling::{Error, FromMeta};
107use proc_macro::TokenStream;
108use proc_macro2::TokenStream as TokenStream2;
109use quote::{quote, ToTokens};
110use syn::parse::{Parse, ParseStream, Parser};
111use syn::punctuated::Punctuated;
112use syn::{parse_macro_input, ItemFn, ReturnType, Token};
113
114#[derive(Debug)]
115enum LabelValue {
116    /// Static string value: `method = "sync"`
117    Static(String),
118    /// Dynamic value from an expression: `request.method` or just `method`
119    /// The expression will have `.to_string()` called on it
120    Dynamic(syn::Expr),
121}
122
123/// A single label item parsed from the labels(...) block
124/// Supports:
125/// - `key = "value"` - static label
126/// - `key = expr` - dynamic label with explicit key
127/// - `method` - dynamic label from variable (key = variable name)
128/// - `request.method` - dynamic label from field (key = field name)
129struct LabelItem {
130    key: String,
131    value: LabelValue,
132}
133
134impl Parse for LabelItem {
135    fn parse(input: ParseStream) -> syn::Result<Self> {
136        // Try to parse as `key = value` first
137        if input.peek(syn::Ident) && input.peek2(Token![=]) {
138            let key: syn::Ident = input.parse()?;
139            let _: Token![=] = input.parse()?;
140            let value: syn::Expr = input.parse()?;
141
142            // Check if it's a string literal (static label)
143            if let syn::Expr::Lit(syn::ExprLit {
144                lit: syn::Lit::Str(s),
145                ..
146            }) = &value
147            {
148                return Ok(LabelItem {
149                    key: key.to_string(),
150                    value: LabelValue::Static(s.value()),
151                });
152            }
153
154            // Otherwise it's a dynamic expression
155            return Ok(LabelItem {
156                key: key.to_string(),
157                value: LabelValue::Dynamic(value),
158            });
159        }
160
161        // Otherwise parse as an expression (path or field access)
162        let expr: syn::Expr = input.parse()?;
163
164        match &expr {
165            // Simple path like `method`
166            syn::Expr::Path(path) => {
167                let key = path
168                    .path
169                    .get_ident()
170                    .map(|i| i.to_string())
171                    .ok_or_else(|| {
172                        syn::Error::new_spanned(&expr, "expected simple identifier for label")
173                    })?;
174                Ok(LabelItem {
175                    key,
176                    value: LabelValue::Dynamic(expr),
177                })
178            }
179            // Field access like `request.method`
180            syn::Expr::Field(field) => {
181                let key = field.member.to_token_stream().to_string();
182                Ok(LabelItem {
183                    key,
184                    value: LabelValue::Dynamic(expr),
185                })
186            }
187            _ => Err(syn::Error::new_spanned(
188                &expr,
189                "expected identifier, field access (e.g., request.method), or key = value",
190            )),
191        }
192    }
193}
194
195impl LabelItem {
196    /// Generate the variable name used to capture this label's value
197    fn capture_var_name(&self) -> syn::Ident {
198        syn::Ident::new(
199            &format!("__metrics_label_{}", self.key),
200            proc_macro2::Span::call_site(),
201        )
202    }
203
204    /// Generate code to capture dynamic label values upfront.
205    /// Returns None for static labels (no capture needed).
206    fn capture_statement(&self) -> Option<proc_macro2::TokenStream> {
207        match &self.value {
208            LabelValue::Static(_) => None,
209            LabelValue::Dynamic(expr) => {
210                let var_name = self.capture_var_name();
211                Some(quote! {
212                    let #var_name = (#expr).to_string();
213                })
214            }
215        }
216    }
217
218    /// Generate the label key-value pair for use in metrics macros.
219    /// For dynamic labels, references the captured variable.
220    fn to_token_stream(&self) -> proc_macro2::TokenStream {
221        let key = &self.key;
222        match &self.value {
223            LabelValue::Static(value) => {
224                quote! { #key => #value }
225            }
226            LabelValue::Dynamic(_) => {
227                let var_name = self.capture_var_name();
228                quote! { #key => #var_name.clone() }
229            }
230        }
231    }
232}
233
234/// Parsed attributes for the instrument macro
235#[derive(Debug, FromMeta)]
236#[darling(allow_unknown_fields)]
237struct InstrumentArgs {
238    /// Counter name override (default: `{fn_name}_total`)
239    #[darling(default)]
240    counter: Option<String>,
241
242    /// Histogram name override (default: `{fn_name}_duration_seconds`)
243    #[darling(default)]
244    histogram: Option<String>,
245
246    /// Error counter name override (default: `{fn_name}_errors_total`)
247    #[darling(default)]
248    error_counter: Option<String>,
249    // Note: labels are parsed directly from meta in parse_labels_from_meta()
250    // rather than through darling, since darling can't handle the nested syntax
251}
252
253/// Attribute macro for instrumenting functions with metrics.
254///
255/// Metric names are automatically derived from the function name:
256/// - Counter: `{fn_name}_total`
257/// - Histogram: `{fn_name}_duration_seconds`
258/// - Error counter: `{fn_name}_errors_total`
259///
260/// # Usage
261///
262/// ```ignore
263/// // Simple usage - all metrics auto-derived from function name
264/// #[instrument]
265/// async fn sync_data() -> Result<(), Error> {
266///     // Generates: sync_data_total, sync_data_duration_seconds, sync_data_errors_total
267/// }
268///
269/// // With labels
270/// #[instrument(labels(method = "sync"))]
271/// async fn sync(&self, request: Request<SyncRequest>) -> Result<Response<SyncResponse>, Status> {
272///     // ...
273/// }
274///
275/// // Override specific metric names
276/// #[instrument(counter = "custom_requests_total")]
277/// fn handle_request() {
278///     // Uses custom_requests_total but auto-derives histogram name
279/// }
280/// ```
281///
282/// # Attributes
283///
284/// - `counter`: Override counter name (default: `{fn_name}_total`)
285/// - `histogram`: Override histogram name (default: `{fn_name}_duration_seconds`)
286/// - `error_counter`: Override error counter name (default: `{fn_name}_errors_total`)
287/// - `labels(...)`: Labels to attach to all metrics
288///
289/// # Labels
290///
291/// Labels support multiple syntaxes:
292///
293/// ## Static Labels
294/// Use `key = "value"` for fixed label values:
295/// ```ignore
296/// labels(service = "api", version = "v1")
297/// ```
298///
299/// ## Dynamic Labels (from function parameters)
300/// Use just the parameter name to capture its value at runtime.
301/// The parameter must implement `Display`:
302/// ```ignore
303/// #[instrument(labels(method, user_id))]
304/// fn handle(method: &str, user_id: u64, payload: Bytes) { }
305/// ```
306///
307/// ## Dynamic Labels (from struct fields)
308/// Use dot notation to capture struct field values:
309/// ```ignore
310/// #[instrument(labels(request.method, request.path))]
311/// fn handle(request: &Request) { }
312/// ```
313///
314/// You can also use an explicit key: `labels(http_method = request.method)`
315///
316/// You can mix all styles:
317/// ```ignore
318/// labels(service = "api", method, request.path)
319/// ```
320#[proc_macro_attribute]
321pub fn instrument(attr: TokenStream, item: TokenStream) -> TokenStream {
322    let attr_args = match NestedMeta::parse_meta_list(attr.into()) {
323        Ok(v) => v,
324        Err(e) => return TokenStream::from(Error::from(e).write_errors()),
325    };
326    let input_fn = parse_macro_input!(item as ItemFn);
327
328    match instrument_impl(attr_args, input_fn) {
329        Ok(tokens) => tokens.into(),
330        Err(err) => err.write_errors().into(),
331    }
332}
333
334fn instrument_impl(attr_args: Vec<NestedMeta>, input_fn: ItemFn) -> Result<TokenStream2, Error> {
335    // Parse attributes using darling
336    let args = InstrumentArgs::from_list(&attr_args)?;
337
338    // Parse labels from the nested meta
339    let labels = parse_labels_from_meta(&attr_args)?;
340
341    // Extract function components
342    let attrs = &input_fn.attrs;
343    let vis = &input_fn.vis;
344    let sig = &input_fn.sig;
345    let block = &input_fn.block;
346    let is_async = sig.asyncness.is_some();
347    let fn_name = sig.ident.to_string();
348
349    // Check if return type is Result for error tracking
350    let returns_result = matches!(&sig.output, ReturnType::Type(_, ty) if is_result_type(ty));
351
352    // Derive metric names from function name (with optional overrides)
353    let counter_name = args.counter.unwrap_or_else(|| format!("{}_total", fn_name));
354    let histogram_name = args
355        .histogram
356        .unwrap_or_else(|| format!("{}_duration_seconds", fn_name));
357    let error_counter_name = args
358        .error_counter
359        .unwrap_or_else(|| format!("{}_errors_total", fn_name));
360
361    // Build label captures (evaluated upfront before async block or function body)
362    // This ensures we capture values before they might be moved/consumed
363    let label_captures = build_label_captures(&labels);
364
365    // Build label tokens for metrics macros (references the captured values)
366    let label_tokens = build_label_tokens(&labels);
367
368    // Build the counter increment code
369    let counter_code = quote! {
370        ::metrics::counter!(#counter_name #label_tokens).increment(1);
371    };
372
373    // Build the histogram recording code
374    let histogram_code = quote! {
375        ::metrics::histogram!(#histogram_name #label_tokens).record(__metrics_start.elapsed().as_secs_f64());
376    };
377
378    // Build the error counter code (only if returns Result)
379    let error_counter_code = if returns_result {
380        Some(quote! {
381            if __metrics_result.is_err() {
382                ::metrics::counter!(#error_counter_name #label_tokens).increment(1);
383            }
384        })
385    } else {
386        None
387    };
388
389    // Build the instrumented function body
390    let instrumented_body = if is_async {
391        build_async_body(
392            block,
393            label_captures,
394            Some(counter_code),
395            Some(histogram_code),
396            error_counter_code,
397            true, // always need timing now
398        )
399    } else {
400        build_sync_body(
401            block,
402            label_captures,
403            Some(counter_code),
404            Some(histogram_code),
405            error_counter_code,
406            true, // always need timing now
407        )
408    };
409
410    // Reconstruct the function
411    Ok(quote! {
412        #(#attrs)*
413        #vis #sig {
414            #instrumented_body
415        }
416    })
417}
418
419fn parse_labels_from_meta(attr_args: &[NestedMeta]) -> Result<Vec<LabelItem>, Error> {
420    for meta in attr_args {
421        if let NestedMeta::Meta(syn::Meta::List(list)) = meta {
422            if list.path.is_ident("labels") {
423                // Parse the labels(...) content as comma-separated LabelItems
424                let parser = Punctuated::<LabelItem, Token![,]>::parse_terminated;
425                let items = parser
426                    .parse2(list.tokens.clone())
427                    .map_err(|e: syn::Error| Error::custom(e.to_string()))?;
428                return Ok(items.into_iter().collect());
429            }
430        }
431    }
432
433    Ok(Vec::new())
434}
435
436/// Generate code to capture all dynamic label values upfront.
437/// This must be called before any code that might move/consume the labeled values.
438fn build_label_captures(labels: &[LabelItem]) -> TokenStream2 {
439    let captures: Vec<TokenStream2> = labels
440        .iter()
441        .filter_map(|label| label.capture_statement())
442        .collect();
443
444    if captures.is_empty() {
445        quote! {}
446    } else {
447        quote! { #(#captures)* }
448    }
449}
450
451fn build_label_tokens(labels: &[LabelItem]) -> TokenStream2 {
452    if labels.is_empty() {
453        return quote! {};
454    }
455
456    let label_pairs: Vec<TokenStream2> =
457        labels.iter().map(|label| label.to_token_stream()).collect();
458
459    quote! { , #(#label_pairs),* }
460}
461
462fn build_async_body(
463    block: &syn::Block,
464    label_captures: TokenStream2,
465    counter_code: Option<TokenStream2>,
466    histogram_code: Option<TokenStream2>,
467    error_counter_code: Option<TokenStream2>,
468    needs_timing: bool,
469) -> TokenStream2 {
470    let timing_start = if needs_timing {
471        quote! { let __metrics_start = ::std::time::Instant::now(); }
472    } else {
473        quote! {}
474    };
475
476    let counter = counter_code.unwrap_or_else(|| quote! {});
477    let histogram = histogram_code.unwrap_or_else(|| quote! {});
478    let error_counter = error_counter_code.unwrap_or_else(|| quote! {});
479
480    quote! {
481        // Capture dynamic label values upfront before async block
482        #label_captures
483
484        #counter
485        #timing_start
486
487        let __metrics_result = async #block.await;
488
489        #histogram
490        #error_counter
491
492        __metrics_result
493    }
494}
495
496fn build_sync_body(
497    block: &syn::Block,
498    label_captures: TokenStream2,
499    counter_code: Option<TokenStream2>,
500    histogram_code: Option<TokenStream2>,
501    error_counter_code: Option<TokenStream2>,
502    needs_timing: bool,
503) -> TokenStream2 {
504    let timing_start = if needs_timing {
505        quote! { let __metrics_start = ::std::time::Instant::now(); }
506    } else {
507        quote! {}
508    };
509
510    let counter = counter_code.unwrap_or_else(|| quote! {});
511    let histogram = histogram_code.unwrap_or_else(|| quote! {});
512    let error_counter = error_counter_code.unwrap_or_else(|| quote! {});
513
514    quote! {
515        // Capture dynamic label values upfront
516        #label_captures
517
518        #counter
519        #timing_start
520
521        let __metrics_result = #block;
522
523        #histogram
524        #error_counter
525
526        __metrics_result
527    }
528}
529
530fn is_result_type(ty: &syn::Type) -> bool {
531    if let syn::Type::Path(type_path) = ty {
532        if let Some(segment) = type_path.path.segments.last() {
533            return segment.ident == "Result";
534        }
535    }
536    false
537}
538
539#[cfg(test)]
540mod tests {
541    // Compile tests are better done with trybuild in integration tests
542}