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}