From e21072d41e3e55eea72ac0d606c004d0d652b455 Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:56:40 +0100 Subject: [PATCH 1/6] feat: add per-field labels support Add ability to attach labels to individual struct fields: #[derive(Metrics)] #[metrics(scope = "forwarder")] struct TransactionMetrics { #[metric(rename = "transactions", labels = [("outcome", "forwarded")])] forwarded_transactions: Counter, #[metric(rename = "transactions", labels = [("outcome", "dropped")])] dropped_transactions: Counter, } Field-level labels are appended to struct-level labels passed via new_with_labels(). Closes #1 --- Cargo.toml | 2 +- src/expand.rs | 140 ++++++++++++++++++++++++++++++++++++++++++-------- src/metric.rs | 13 ++++- 3 files changed, 130 insertions(+), 25 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0275a3c..6724e21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ proc-macro = true [dependencies] proc-macro2 = "1.0" quote = "1.0" -syn = "2.0" +syn = { version = "2.0", features = ["full"] } [dev-dependencies] metrics = "0.24.0" diff --git a/src/expand.rs b/src/expand.rs index b690b4b..b3b3c24 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -1,8 +1,11 @@ -use crate::{metric::Metric, with_attrs::WithAttrs}; +use crate::{ + metric::{LabelPair, Metric}, + with_attrs::WithAttrs, +}; use quote::{quote, ToTokens}; use syn::{ - punctuated::Punctuated, Attribute, Data, DeriveInput, Error, Expr, Field, Lit, LitBool, LitStr, - Meta, MetaNameValue, Result, Token, + punctuated::Punctuated, Attribute, Data, DeriveInput, Error, Expr, ExprTuple, Field, Lit, + LitBool, LitStr, Meta, MetaNameValue, Result, Token, }; enum MetricField<'a> { @@ -45,11 +48,33 @@ pub(crate) fn derive(node: &DeriveInput) -> Result { let register_method = metric.register_method()?; let describe_method = metric.describe_method()?; let description = &metric.description; + + let field_init = if metric.labels.is_empty() { + quote! { + __recorder.#register_method( + &metrics::Key::from_parts(#metric_name, __labels.clone()), + __metadata, + ) + } + } else { + let label_keys = metric.labels.iter().map(|(k, _)| k); + let label_values = metric.labels.iter().map(|(_, v)| v); + quote! { + { + let mut __field_labels = __labels.clone(); + __field_labels.extend([ + #(metrics::Label::new(#label_keys, #label_values)),* + ]); + __recorder.#register_method( + &metrics::Key::from_parts(#metric_name, __field_labels), + __metadata, + ) + } + } + }; + field_inits.push(quote! { - #field_name: __recorder.#register_method( - &metrics::Key::from_parts(#metric_name, __labels.clone()), - __metadata, - ), + #field_name: #field_init, }); describes.push(quote! { __recorder.#describe_method( @@ -162,14 +187,38 @@ pub(crate) fn derive(node: &DeriveInput) -> Result { let describe_method = metric.describe_method()?; let description = &metric.description; + let field_init = if metric.labels.is_empty() { + quote! { + __recorder.#register_method( + &metrics::Key::from_parts( + format!("{}{}{}", __scope, #separator, #name), + __labels.clone(), + ), + __metadata, + ) + } + } else { + let label_keys = metric.labels.iter().map(|(k, _)| k); + let label_values = metric.labels.iter().map(|(_, v)| v); + quote! { + { + let mut __field_labels = __labels.clone(); + __field_labels.extend([ + #(metrics::Label::new(#label_keys, #label_values)),* + ]); + __recorder.#register_method( + &metrics::Key::from_parts( + format!("{}{}{}", __scope, #separator, #name), + __field_labels, + ), + __metadata, + ) + } + } + }; + field_inits.push(quote! { - #field_name: __recorder.#register_method( - &metrics::Key::from_parts( - format!("{}{}{}", __scope, #separator, #name), - __labels.clone(), - ), - __metadata, - ), + #field_name: #field_init, }); describes.push(quote! { __recorder.#describe_method( @@ -319,7 +368,7 @@ fn parse_metric_fields(node: &DeriveInput) -> Result>> { let mut metrics = Vec::with_capacity(data.fields.len()); for field in &data.fields { - let (mut describe, mut rename, mut skip) = (None, None, false); + let (mut describe, mut rename, mut labels, mut skip) = (None, None, None, false); if let Some(metric_attr) = parse_single_attr(field, "metric")? { let parsed = metric_attr.parse_args_with(Punctuated::::parse_terminated)?; @@ -327,17 +376,22 @@ fn parse_metric_fields(node: &DeriveInput) -> Result>> { match meta { Meta::Path(path) if path.is_ident("skip") => skip = true, Meta::NameValue(kv) => { - let lit = match kv.value { - Expr::Lit(ref expr) => &expr.lit, - _ => continue, - }; - if kv.path.is_ident("describe") { + if kv.path.is_ident("labels") { + if labels.is_some() { + return Err(Error::new_spanned( + kv, + "duplicate `labels` value provided", + )); + } + labels = Some(parse_labels_expr(&kv.value)?); + } else if kv.path.is_ident("describe") { if describe.is_some() { return Err(Error::new_spanned( kv, "duplicate `describe` value provided", )); } + let lit = parse_expr_lit(&kv.value)?; describe = Some(parse_str_lit(lit)?); } else if kv.path.is_ident("rename") { if rename.is_some() { @@ -346,7 +400,8 @@ fn parse_metric_fields(node: &DeriveInput) -> Result>> { "duplicate `rename` value provided", )); } - rename = Some(parse_str_lit(lit)?) + let lit = parse_expr_lit(&kv.value)?; + rename = Some(parse_str_lit(lit)?); } else { return Err(Error::new_spanned(kv, "unsupported attribute entry")); } @@ -375,7 +430,8 @@ fn parse_metric_fields(node: &DeriveInput) -> Result>> { }, }; - metrics.push(MetricField::Included(Metric::new(field, description, rename))); + let labels = labels.unwrap_or_default(); + metrics.push(MetricField::Included(Metric::new(field, description, rename, labels))); } Ok(metrics) @@ -443,3 +499,43 @@ fn parse_bool_lit(lit: &Lit) -> Result { _ => Err(Error::new_spanned(lit, "value must be a boolean literal")), } } + +fn parse_expr_lit(expr: &Expr) -> Result<&Lit> { + match expr { + Expr::Lit(expr_lit) => Ok(&expr_lit.lit), + _ => Err(Error::new_spanned(expr, "value must be a literal")), + } +} + +/// Parses `labels = [("key", "value"), ...]` into a vector of label pairs. +fn parse_labels_expr(expr: &Expr) -> Result> { + let Expr::Array(arr) = expr else { + return Err(Error::new_spanned( + expr, + "labels must be an array of tuples, e.g. `labels = [(\"key\", \"value\")]`", + )); + }; + + let mut labels = Vec::with_capacity(arr.elems.len()); + for elem in &arr.elems { + let Expr::Tuple(ExprTuple { elems, .. }) = elem else { + return Err(Error::new_spanned( + elem, + "each label must be a tuple of two strings, e.g. `(\"key\", \"value\")`", + )); + }; + + if elems.len() != 2 { + return Err(Error::new_spanned( + elem, + "each label tuple must have exactly two elements: (key, value)", + )); + } + + let key = parse_str_lit(parse_expr_lit(&elems[0])?)?; + let value = parse_str_lit(parse_expr_lit(&elems[1])?)?; + labels.push((key, value)); + } + + Ok(labels) +} diff --git a/src/metric.rs b/src/metric.rs index eba4a7a..0352727 100644 --- a/src/metric.rs +++ b/src/metric.rs @@ -5,15 +5,24 @@ const COUNTER_TY: &str = "Counter"; const HISTOGRAM_TY: &str = "Histogram"; const GAUGE_TY: &str = "Gauge"; +/// A parsed label pair (key, value) from `#[metric(labels = [("k", "v"), ...])]`. +pub(crate) type LabelPair = (LitStr, LitStr); + pub(crate) struct Metric<'a> { pub(crate) field: &'a Field, pub(crate) description: String, + pub(crate) labels: Vec, rename: Option, } impl<'a> Metric<'a> { - pub(crate) const fn new(field: &'a Field, description: String, rename: Option) -> Self { - Self { field, description, rename } + pub(crate) fn new( + field: &'a Field, + description: String, + rename: Option, + labels: Vec, + ) -> Self { + Self { field, description, rename, labels } } pub(crate) fn name(&self) -> String { From 3fa5649a0325d1392a57c6b53312554cb76f3bc4 Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:57:57 +0100 Subject: [PATCH 2/6] test: add tests for per-field labels - Add FieldLabelMetrics and DynamicFieldLabelMetrics test structs - Add field_labels_static and field_labels_dynamic tests - Add compile-fail tests for invalid label syntax --- tests/compile-fail/metric_attr.rs | 35 +++++++++++++ tests/compile-fail/metric_attr.stderr | 30 +++++++++++ tests/metrics.rs | 71 +++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/tests/compile-fail/metric_attr.rs b/tests/compile-fail/metric_attr.rs index 3db0fcc..724b76f 100644 --- a/tests/compile-fail/metric_attr.rs +++ b/tests/compile-fail/metric_attr.rs @@ -60,3 +60,38 @@ struct CustomMetrics8 { #[metric(describe = "")] gauge: String, } + +#[derive(Metrics)] +#[metrics(scope = "some_scope")] +struct CustomMetrics9 { + #[metric(describe = "gauge", labels = "not_an_array")] + gauge: Gauge, +} + +#[derive(Metrics)] +#[metrics(scope = "some_scope")] +struct CustomMetrics10 { + #[metric(describe = "gauge", labels = ["not_a_tuple"])] + gauge: Gauge, +} + +#[derive(Metrics)] +#[metrics(scope = "some_scope")] +struct CustomMetrics11 { + #[metric(describe = "gauge", labels = [("only_one")])] + gauge: Gauge, +} + +#[derive(Metrics)] +#[metrics(scope = "some_scope")] +struct CustomMetrics12 { + #[metric(describe = "gauge", labels = [(123, "value")])] + gauge: Gauge, +} + +#[derive(Metrics)] +#[metrics(scope = "some_scope")] +struct CustomMetrics13 { + #[metric(describe = "gauge", labels = [], labels = [])] + gauge: Gauge, +} diff --git a/tests/compile-fail/metric_attr.stderr b/tests/compile-fail/metric_attr.stderr index 669cb6e..426eef4 100644 --- a/tests/compile-fail/metric_attr.stderr +++ b/tests/compile-fail/metric_attr.stderr @@ -46,3 +46,33 @@ error: unsupported metric type | 61 | gauge: String, | ^^^^^^ + +error: labels must be an array of tuples, e.g. `labels = [("key", "value")]` + --> tests/compile-fail/metric_attr.rs:67:43 + | +67 | #[metric(describe = "gauge", labels = "not_an_array")] + | ^^^^^^^^^^^^^^ + +error: each label must be a tuple of two strings, e.g. `("key", "value")` + --> tests/compile-fail/metric_attr.rs:74:44 + | +74 | #[metric(describe = "gauge", labels = ["not_a_tuple"])] + | ^^^^^^^^^^^^^ + +error: each label must be a tuple of two strings, e.g. `("key", "value")` + --> tests/compile-fail/metric_attr.rs:81:44 + | +81 | #[metric(describe = "gauge", labels = [("only_one")])] + | ^^^^^^^^^^^^ + +error: value must be a string literal + --> tests/compile-fail/metric_attr.rs:88:45 + | +88 | #[metric(describe = "gauge", labels = [(123, "value")])] + | ^^^ + +error: duplicate `labels` value provided + --> tests/compile-fail/metric_attr.rs:95:47 + | +95 | #[metric(describe = "gauge", labels = [], labels = [])] + | ^^^^^^^^^^^ diff --git a/tests/metrics.rs b/tests/metrics.rs index 3862fee..b5c8788 100644 --- a/tests/metrics.rs +++ b/tests/metrics.rs @@ -69,6 +69,35 @@ struct DynamicScopeMetrics { skipped_field_e: u128, } +/// Tests per-field labels with static scope. +#[allow(dead_code)] +#[derive(Metrics)] +#[metrics(scope = "transactions")] +struct FieldLabelMetrics { + /// Number of transactions. + #[metric(rename = "count", labels = [("outcome", "forwarded")])] + forwarded: Counter, + /// Number of transactions. + #[metric(rename = "count", labels = [("outcome", "dropped")])] + dropped: Counter, + /// Number of transactions. + #[metric(rename = "count", labels = [("outcome", "processed"), ("priority", "high")])] + processed_high: Counter, +} + +/// Tests per-field labels with dynamic scope. +#[allow(dead_code)] +#[derive(Metrics)] +#[metrics(dynamic = true)] +struct DynamicFieldLabelMetrics { + /// Number of transactions. + #[metric(rename = "count", labels = [("outcome", "forwarded")])] + forwarded: Counter, + /// Number of transactions. + #[metric(rename = "count", labels = [("outcome", "dropped")])] + dropped: Counter, +} + static RECORDER: LazyLock = LazyLock::new(TestRecorder::new); fn test_describe(scope: &str) { @@ -241,6 +270,48 @@ fn dynamic_label_metrics() { test_labels(scope); } +#[test] +fn field_labels_static() { + let _guard = RECORDER.enter(); + + let _metrics = FieldLabelMetrics::new_with_labels(&[("env", "prod")]); + + // "forwarded" field: struct labels + field labels + let forwarded = RECORDER.get_metric("transactions.count"); + assert!(forwarded.is_some()); + let metric = forwarded.unwrap(); + assert_eq!(metric.ty, TestMetricTy::Counter); + // We can't distinguish between the three "count" metrics by name alone, + // but we can check that at least one was registered with the expected labels. + // The last one registered will be stored (processed_high with 3 labels). + let labels = metric.labels.unwrap(); + // Last registered is processed_high: env=prod, outcome=processed, priority=high + assert_eq!(labels.len(), 3); + assert!(labels.contains(&Label::new("env", "prod"))); + assert!(labels.contains(&Label::new("outcome", "processed"))); + assert!(labels.contains(&Label::new("priority", "high"))); +} + +#[test] +fn field_labels_dynamic() { + let _guard = RECORDER.enter(); + + let scope = "dynamic_tx"; + + let _metrics = DynamicFieldLabelMetrics::new_with_labels(scope, &[("env", "staging")]); + + // Check that field labels are appended to struct labels + let metric = RECORDER.get_metric(&format!("{scope}.count")); + assert!(metric.is_some()); + let metric = metric.unwrap(); + assert_eq!(metric.ty, TestMetricTy::Counter); + // Last registered is "dropped": env=staging, outcome=dropped + let labels = metric.labels.unwrap(); + assert_eq!(labels.len(), 2); + assert!(labels.contains(&Label::new("env", "staging"))); + assert!(labels.contains(&Label::new("outcome", "dropped"))); +} + struct TestRecorder { // Metrics map: key => Option metrics: Mutex>, From 1ccc3a0afdfbd25537965edb03d533e1b437d27e Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:58:19 +0100 Subject: [PATCH 3/6] docs: document per-field labels feature --- src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 239f07c..b96bab2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -131,6 +131,44 @@ mod with_attrs; /// } /// } /// ``` +/// +/// ## Per-field labels +/// +/// You can attach labels to individual fields using the `labels` attribute. +/// This is useful when you want multiple fields to share the same metric name +/// but be distinguished by label values: +/// +/// ``` +/// use metrics::Counter; +/// use metrics_derive::Metrics; +/// +/// #[derive(Metrics)] +/// #[metrics(scope = "forwarder")] +/// pub struct TransactionMetrics { +/// /// Number of transactions. +/// #[metric(rename = "transactions", labels = [("outcome", "forwarded")])] +/// forwarded: Counter, +/// /// Number of transactions. +/// #[metric(rename = "transactions", labels = [("outcome", "dropped")])] +/// dropped: Counter, +/// } +/// ``` +/// +/// Field-level labels are appended to any struct-level labels passed via +/// `new_with_labels()`. Multiple labels can be specified per field: +/// +/// ``` +/// use metrics::Counter; +/// use metrics_derive::Metrics; +/// +/// #[derive(Metrics)] +/// #[metrics(scope = "api")] +/// pub struct RequestMetrics { +/// /// Request count. +/// #[metric(rename = "requests", labels = [("method", "GET"), ("status", "200")])] +/// get_success: Counter, +/// } +/// ``` #[proc_macro_derive(Metrics, attributes(metrics, metric))] pub fn derive_metrics(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); From 6aec2f7b119a5e33f39efd9a79124b9fbc8b86db Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:03:59 +0100 Subject: [PATCH 4/6] feat: add global labels on struct attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to specify labels at the struct level that apply to all fields: #[derive(Metrics)] #[metrics(scope = "api", labels = [("service", "gateway")])] struct ApiMetrics { /// Total requests. requests: Counter, } Label order: instance labels (new_with_labels) → global labels → field labels. --- src/expand.rs | 36 +++++++++-- src/lib.rs | 22 +++++++ tests/compile-fail/metrics_attr.rs | 8 +++ tests/compile-fail/metrics_attr.stderr | 30 ++++++--- tests/metrics.rs | 90 ++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 15 deletions(-) diff --git a/src/expand.rs b/src/expand.rs index b3b3c24..1daff37 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -29,6 +29,18 @@ pub(crate) fn derive(node: &DeriveInput) -> Result { let metrics_attr = parse_metrics_attr(node)?; let metric_fields = parse_metric_fields(node)?; + let global_labels_init = if metrics_attr.labels.is_empty() { + quote! {} + } else { + let label_keys = metrics_attr.labels.iter().map(|(k, _)| k); + let label_values = metrics_attr.labels.iter().map(|(_, v)| v); + quote! { + __labels.extend([ + #(metrics::Label::new(#label_keys, #label_values)),* + ]); + } + }; + let register_and_describe = match &metrics_attr.scope { MetricsScope::Static(scope) => { let mut field_inits = Vec::with_capacity(metric_fields.len()); @@ -133,7 +145,9 @@ pub(crate) fn derive(node: &DeriveInput) -> Result { ::core::option::Option::Some(module_path!()), ); let __metadata = &__METADATA; - let __labels = labels; + #[allow(unused_mut)] + let mut __labels = labels; + #global_labels_init Self { #(#field_inits)* } @@ -260,7 +274,9 @@ pub(crate) fn derive(node: &DeriveInput) -> Result { ); let __metadata = &__METADATA; let __scope = scope; - let __labels = labels; + #[allow(unused_mut)] + let mut __labels = labels; + #global_labels_init Self { #(#field_inits)* } @@ -296,6 +312,7 @@ pub(crate) fn derive(node: &DeriveInput) -> Result { pub(crate) struct MetricsAttr { pub(crate) scope: MetricsScope, pub(crate) separator: Option, + pub(crate) labels: Vec, } impl MetricsAttr { @@ -318,11 +335,19 @@ fn parse_metrics_attr(node: &DeriveInput) -> Result { let metrics_attr = parse_single_required_attr(node, "metrics")?; let parsed = metrics_attr.parse_args_with(Punctuated::::parse_terminated)?; - let (mut scope, mut separator, mut dynamic) = (None, None, None); + let (mut scope, mut separator, mut dynamic, mut labels) = (None, None, None, None); for kv in parsed { + if kv.path.is_ident("labels") { + if labels.is_some() { + return Err(Error::new_spanned(kv, "duplicate `labels` value provided")); + } + labels = Some(parse_labels_expr(&kv.value)?); + continue; + } + let lit = match kv.value { Expr::Lit(ref expr) => &expr.lit, - _ => continue, + _ => return Err(Error::new_spanned(&kv.value, "value must be a literal")), }; if kv.path.is_ident("scope") { if scope.is_some() { @@ -358,7 +383,8 @@ fn parse_metrics_attr(node: &DeriveInput) -> Result { } }; - Ok(MetricsAttr { scope, separator }) + let labels = labels.unwrap_or_default(); + Ok(MetricsAttr { scope, separator, labels }) } fn parse_metric_fields(node: &DeriveInput) -> Result>> { diff --git a/src/lib.rs b/src/lib.rs index b96bab2..d7a4e3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -169,6 +169,28 @@ mod with_attrs; /// get_success: Counter, /// } /// ``` +/// +/// ## Global labels +/// +/// You can also specify labels at the struct level that apply to all fields. +/// These are combined with any instance labels passed via `new_with_labels()`: +/// +/// ``` +/// use metrics::Counter; +/// use metrics_derive::Metrics; +/// +/// #[derive(Metrics)] +/// #[metrics(scope = "api", labels = [("service", "gateway"), ("version", "v1")])] +/// pub struct ApiMetrics { +/// /// Total requests. +/// requests: Counter, +/// /// Successful requests (with additional field-level labels). +/// #[metric(labels = [("status", "success")])] +/// success: Counter, +/// } +/// ``` +/// +/// Label order: instance labels (from `new_with_labels`) → global labels → field labels. #[proc_macro_derive(Metrics, attributes(metrics, metric))] pub fn derive_metrics(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); diff --git a/tests/compile-fail/metrics_attr.rs b/tests/compile-fail/metrics_attr.rs index c90d445..bb6cf5c 100644 --- a/tests/compile-fail/metrics_attr.rs +++ b/tests/compile-fail/metrics_attr.rs @@ -54,3 +54,11 @@ struct CustomMetrics12; #[derive(Metrics)] #[metrics(scope = "scope", dynamic = true)] struct CustomMetrics13; + +#[derive(Metrics)] +#[metrics(scope = "scope", labels = "not_an_array")] +struct CustomMetrics14; + +#[derive(Metrics)] +#[metrics(scope = "scope", labels = [], labels = [])] +struct CustomMetrics15; diff --git a/tests/compile-fail/metrics_attr.stderr b/tests/compile-fail/metrics_attr.stderr index e2bb366..f378f06 100644 --- a/tests/compile-fail/metrics_attr.stderr +++ b/tests/compile-fail/metrics_attr.stderr @@ -17,12 +17,11 @@ error: either `scope = ..` or `dynamic = true` must be set 16 | | struct CustomMetrics3; | |______________________^ -error: either `scope = ..` or `dynamic = true` must be set - --> tests/compile-fail/metrics_attr.rs:19:1 +error: value must be a literal + --> tests/compile-fail/metrics_attr.rs:19:19 | -19 | / #[metrics(scope = value)] -20 | | struct CustomMetrics4; - | |______________________^ +19 | #[metrics(scope = value)] + | ^^^^^ error: value must be a string literal --> tests/compile-fail/metrics_attr.rs:23:19 @@ -36,12 +35,11 @@ error: duplicate `scope` value provided 31 | #[metrics(scope = "some_scope", scope = "another_scope")] | ^^^^^^^^^^^^^^^^^^^^^^^ -error: either `scope = ..` or `dynamic = true` must be set - --> tests/compile-fail/metrics_attr.rs:35:1 +error: value must be a literal + --> tests/compile-fail/metrics_attr.rs:35:23 | -35 | / #[metrics(separator = value)] -36 | | struct CustomMetrics8; - | |______________________^ +35 | #[metrics(separator = value)] + | ^^^^^ error: value must be a string literal --> tests/compile-fail/metrics_attr.rs:39:23 @@ -74,3 +72,15 @@ error: `scope = ..` conflicts with `dynamic = true` 55 | / #[metrics(scope = "scope", dynamic = true)] 56 | | struct CustomMetrics13; | |_______________________^ + +error: labels must be an array of tuples, e.g. `labels = [("key", "value")]` + --> tests/compile-fail/metrics_attr.rs:59:37 + | +59 | #[metrics(scope = "scope", labels = "not_an_array")] + | ^^^^^^^^^^^^^^ + +error: duplicate `labels` value provided + --> tests/compile-fail/metrics_attr.rs:63:41 + | +63 | #[metrics(scope = "scope", labels = [], labels = [])] + | ^^^^^^^^^^^ diff --git a/tests/metrics.rs b/tests/metrics.rs index b5c8788..2d50fe3 100644 --- a/tests/metrics.rs +++ b/tests/metrics.rs @@ -98,6 +98,27 @@ struct DynamicFieldLabelMetrics { dropped: Counter, } +/// Tests global labels on the struct attribute (static scope). +#[allow(dead_code)] +#[derive(Metrics)] +#[metrics(scope = "global_test", labels = [("service", "api"), ("version", "v1")])] +struct GlobalLabelMetrics { + /// A counter. + requests: Counter, + /// A counter with field-level labels too. + #[metric(labels = [("method", "GET")])] + get_requests: Counter, +} + +/// Tests global labels with dynamic scope. +#[allow(dead_code)] +#[derive(Metrics)] +#[metrics(dynamic = true, labels = [("env", "prod")])] +struct DynamicGlobalLabelMetrics { + /// A counter. + requests: Counter, +} + static RECORDER: LazyLock = LazyLock::new(TestRecorder::new); fn test_describe(scope: &str) { @@ -312,6 +333,75 @@ fn field_labels_dynamic() { assert!(labels.contains(&Label::new("outcome", "dropped"))); } +#[test] +fn global_labels_static() { + let _guard = RECORDER.enter(); + + let _metrics = GlobalLabelMetrics::default(); + + // Check "requests" has global labels only + let metric = RECORDER.get_metric("global_test.requests"); + assert!(metric.is_some()); + let metric = metric.unwrap(); + assert_eq!(metric.ty, TestMetricTy::Counter); + let labels = metric.labels.unwrap(); + assert_eq!(labels.len(), 2); + assert!(labels.contains(&Label::new("service", "api"))); + assert!(labels.contains(&Label::new("version", "v1"))); +} + +#[test] +fn global_labels_with_field_labels() { + let _guard = RECORDER.enter(); + + let _metrics = GlobalLabelMetrics::default(); + + // Check "get_requests" has global labels + field labels + let metric = RECORDER.get_metric("global_test.get_requests"); + assert!(metric.is_some()); + let metric = metric.unwrap(); + assert_eq!(metric.ty, TestMetricTy::Counter); + let labels = metric.labels.unwrap(); + // Global: service=api, version=v1; Field: method=GET + assert_eq!(labels.len(), 3); + assert!(labels.contains(&Label::new("service", "api"))); + assert!(labels.contains(&Label::new("version", "v1"))); + assert!(labels.contains(&Label::new("method", "GET"))); +} + +#[test] +fn global_labels_dynamic() { + let _guard = RECORDER.enter(); + + let _metrics = DynamicGlobalLabelMetrics::new("dyn"); + + let metric = RECORDER.get_metric("dyn.requests"); + assert!(metric.is_some()); + let metric = metric.unwrap(); + assert_eq!(metric.ty, TestMetricTy::Counter); + let labels = metric.labels.unwrap(); + assert_eq!(labels.len(), 1); + assert!(labels.contains(&Label::new("env", "prod"))); +} + +#[test] +fn global_labels_with_new_with_labels() { + let _guard = RECORDER.enter(); + + // Instance labels + global labels should combine + let _metrics = GlobalLabelMetrics::new_with_labels(&[("instance", "i-123")]); + + let metric = RECORDER.get_metric("global_test.requests"); + assert!(metric.is_some()); + let metric = metric.unwrap(); + let labels = metric.labels.unwrap(); + // Instance: instance=i-123; Global: service=api, version=v1 + assert_eq!(labels.len(), 3); + assert!(labels.contains(&Label::new("instance", "i-123"))); + assert!(labels.contains(&Label::new("service", "api"))); + assert!(labels.contains(&Label::new("version", "v1"))); +} + struct TestRecorder { // Metrics map: key => Option metrics: Mutex>, From 93b1ac2d2e6ea537b9c3006bd1b961b854a878e1 Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:05:41 +0100 Subject: [PATCH 5/6] feat: support arbitrary expressions for label values Label values can now be constants or other expressions, not just string literals: const SERVICE_NAME: &str = "gateway"; #[derive(Metrics)] #[metrics(scope = "api", labels = [("service", SERVICE_NAME)])] struct ApiMetrics { requests: Counter, } Label keys must still be string literals. --- src/expand.rs | 9 ++-- src/lib.rs | 16 ++++++ src/metric.rs | 5 +- tests/compile-fail/metric_attr.stderr | 4 +- tests/metrics.rs | 75 +++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/expand.rs b/src/expand.rs index 1daff37..4a845c1 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -533,7 +533,8 @@ fn parse_expr_lit(expr: &Expr) -> Result<&Lit> { } } -/// Parses `labels = [("key", "value"), ...]` into a vector of label pairs. +/// Parses `labels = [("key", expr), ...]` into a vector of label pairs. +/// Keys must be string literals; values can be arbitrary expressions. fn parse_labels_expr(expr: &Expr) -> Result> { let Expr::Array(arr) = expr else { return Err(Error::new_spanned( @@ -547,7 +548,7 @@ fn parse_labels_expr(expr: &Expr) -> Result> { let Expr::Tuple(ExprTuple { elems, .. }) = elem else { return Err(Error::new_spanned( elem, - "each label must be a tuple of two strings, e.g. `(\"key\", \"value\")`", + "each label must be a tuple, e.g. `(\"key\", \"value\")` or `(\"key\", CONST)`", )); }; @@ -558,8 +559,10 @@ fn parse_labels_expr(expr: &Expr) -> Result> { )); } + // Key must be a string literal let key = parse_str_lit(parse_expr_lit(&elems[0])?)?; - let value = parse_str_lit(parse_expr_lit(&elems[1])?)?; + // Value can be any expression (string literal, constant, etc.) + let value = elems[1].clone(); labels.push((key, value)); } diff --git a/src/lib.rs b/src/lib.rs index d7a4e3e..52a7ca8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -191,6 +191,22 @@ mod with_attrs; /// ``` /// /// Label order: instance labels (from `new_with_labels`) → global labels → field labels. +/// +/// Label values can also be arbitrary expressions, such as constants: +/// +/// ``` +/// use metrics::Counter; +/// use metrics_derive::Metrics; +/// +/// const SERVICE_NAME: &str = "gateway"; +/// +/// #[derive(Metrics)] +/// #[metrics(scope = "api", labels = [("service", SERVICE_NAME)])] +/// pub struct ConstLabelMetrics { +/// /// Total requests. +/// requests: Counter, +/// } +/// ``` #[proc_macro_derive(Metrics, attributes(metrics, metric))] pub fn derive_metrics(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); diff --git a/src/metric.rs b/src/metric.rs index 0352727..20a8e7c 100644 --- a/src/metric.rs +++ b/src/metric.rs @@ -1,12 +1,13 @@ use quote::quote; -use syn::{Error, Field, LitStr, Result, Type}; +use syn::{Error, Expr, Field, LitStr, Result, Type}; const COUNTER_TY: &str = "Counter"; const HISTOGRAM_TY: &str = "Histogram"; const GAUGE_TY: &str = "Gauge"; /// A parsed label pair (key, value) from `#[metric(labels = [("k", "v"), ...])]`. -pub(crate) type LabelPair = (LitStr, LitStr); +/// Keys must be string literals; values can be arbitrary expressions (e.g., constants). +pub(crate) type LabelPair = (LitStr, Expr); pub(crate) struct Metric<'a> { pub(crate) field: &'a Field, diff --git a/tests/compile-fail/metric_attr.stderr b/tests/compile-fail/metric_attr.stderr index 426eef4..83cbf8b 100644 --- a/tests/compile-fail/metric_attr.stderr +++ b/tests/compile-fail/metric_attr.stderr @@ -53,13 +53,13 @@ error: labels must be an array of tuples, e.g. `labels = [("key", "value")]` 67 | #[metric(describe = "gauge", labels = "not_an_array")] | ^^^^^^^^^^^^^^ -error: each label must be a tuple of two strings, e.g. `("key", "value")` +error: each label must be a tuple, e.g. `("key", "value")` or `("key", CONST)` --> tests/compile-fail/metric_attr.rs:74:44 | 74 | #[metric(describe = "gauge", labels = ["not_a_tuple"])] | ^^^^^^^^^^^^^ -error: each label must be a tuple of two strings, e.g. `("key", "value")` +error: each label must be a tuple, e.g. `("key", "value")` or `("key", CONST)` --> tests/compile-fail/metric_attr.rs:81:44 | 81 | #[metric(describe = "gauge", labels = [("only_one")])] diff --git a/tests/metrics.rs b/tests/metrics.rs index 2d50fe3..a3fea32 100644 --- a/tests/metrics.rs +++ b/tests/metrics.rs @@ -119,6 +119,32 @@ struct DynamicGlobalLabelMetrics { requests: Counter, } +/// Constants for testing expression-based label values. +const SERVICE_NAME: &str = "gateway"; +const API_VERSION: &str = "v2"; + +/// Tests labels with constant expressions instead of string literals. +#[allow(dead_code)] +#[derive(Metrics)] +#[metrics(scope = "const_test", labels = [("service", SERVICE_NAME)])] +struct ConstLabelMetrics { + /// A counter with a constant label value. + #[metric(labels = [("version", API_VERSION)])] + requests: Counter, + /// A counter mixing literals and constants. + #[metric(labels = [("method", "GET"), ("version", API_VERSION)])] + get_requests: Counter, +} + +/// Tests dynamic scope with constant labels. +#[allow(dead_code)] +#[derive(Metrics)] +#[metrics(dynamic = true, labels = [("service", SERVICE_NAME)])] +struct DynamicConstLabelMetrics { + /// A counter. + requests: Counter, +} + static RECORDER: LazyLock = LazyLock::new(TestRecorder::new); fn test_describe(scope: &str) { @@ -402,6 +428,55 @@ fn global_labels_with_new_with_labels() { assert!(labels.contains(&Label::new("version", "v1"))); } +#[test] +fn const_labels_static() { + let _guard = RECORDER.enter(); + + let _metrics = ConstLabelMetrics::default(); + + // Check "requests" has global label (service=gateway) + field label (version=v2) + let metric = RECORDER.get_metric("const_test.requests"); + assert!(metric.is_some()); + let metric = metric.unwrap(); + assert_eq!(metric.ty, TestMetricTy::Counter); + let labels = metric.labels.unwrap(); + assert_eq!(labels.len(), 2); + assert!(labels.contains(&Label::new("service", SERVICE_NAME))); + assert!(labels.contains(&Label::new("version", API_VERSION))); +} + +#[test] +fn const_labels_mixed() { + let _guard = RECORDER.enter(); + + let _metrics = ConstLabelMetrics::default(); + + // Check "get_requests" has global + mixed field labels + let metric = RECORDER.get_metric("const_test.get_requests"); + assert!(metric.is_some()); + let metric = metric.unwrap(); + let labels = metric.labels.unwrap(); + // Global: service=gateway; Field: method=GET, version=v2 + assert_eq!(labels.len(), 3); + assert!(labels.contains(&Label::new("service", SERVICE_NAME))); + assert!(labels.contains(&Label::new("method", "GET"))); + assert!(labels.contains(&Label::new("version", API_VERSION))); +} + +#[test] +fn const_labels_dynamic() { + let _guard = RECORDER.enter(); + + let _metrics = DynamicConstLabelMetrics::new("dyn_const"); + + let metric = RECORDER.get_metric("dyn_const.requests"); + assert!(metric.is_some()); + let metric = metric.unwrap(); + let labels = metric.labels.unwrap(); + assert_eq!(labels.len(), 1); + assert!(labels.contains(&Label::new("service", SERVICE_NAME))); +} + struct TestRecorder { // Metrics map: key => Option metrics: Mutex>, From 30dc8be5ff63c3bceb1e348301d938ca027459ed Mon Sep 17 00:00:00 2001 From: DaniPopes <57450786+DaniPopes@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:18:07 +0100 Subject: [PATCH 6/6] fix: detect duplicate label keys Error at compile time if the same label key is specified multiple times within a single labels attribute. --- src/expand.rs | 9 +++++++++ tests/compile-fail/metric_attr.rs | 7 +++++++ tests/compile-fail/metric_attr.stderr | 6 ++++++ tests/compile-fail/metrics_attr.rs | 4 ++++ tests/compile-fail/metrics_attr.stderr | 6 ++++++ 5 files changed, 32 insertions(+) diff --git a/src/expand.rs b/src/expand.rs index 4a845c1..35cba30 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -544,6 +544,8 @@ fn parse_labels_expr(expr: &Expr) -> Result> { }; let mut labels = Vec::with_capacity(arr.elems.len()); + let mut seen_keys = std::collections::HashSet::new(); + for elem in &arr.elems { let Expr::Tuple(ExprTuple { elems, .. }) = elem else { return Err(Error::new_spanned( @@ -561,6 +563,13 @@ fn parse_labels_expr(expr: &Expr) -> Result> { // Key must be a string literal let key = parse_str_lit(parse_expr_lit(&elems[0])?)?; + + // Check for duplicate keys + let key_str = key.value(); + if !seen_keys.insert(key_str.clone()) { + return Err(Error::new_spanned(&elems[0], format!("duplicate label key `{key_str}`"))); + } + // Value can be any expression (string literal, constant, etc.) let value = elems[1].clone(); labels.push((key, value)); diff --git a/tests/compile-fail/metric_attr.rs b/tests/compile-fail/metric_attr.rs index 724b76f..18e12fe 100644 --- a/tests/compile-fail/metric_attr.rs +++ b/tests/compile-fail/metric_attr.rs @@ -95,3 +95,10 @@ struct CustomMetrics13 { #[metric(describe = "gauge", labels = [], labels = [])] gauge: Gauge, } + +#[derive(Metrics)] +#[metrics(scope = "some_scope")] +struct CustomMetrics14 { + #[metric(describe = "gauge", labels = [("key", "value1"), ("key", "value2")])] + gauge: Gauge, +} diff --git a/tests/compile-fail/metric_attr.stderr b/tests/compile-fail/metric_attr.stderr index 83cbf8b..7caf168 100644 --- a/tests/compile-fail/metric_attr.stderr +++ b/tests/compile-fail/metric_attr.stderr @@ -76,3 +76,9 @@ error: duplicate `labels` value provided | 95 | #[metric(describe = "gauge", labels = [], labels = [])] | ^^^^^^^^^^^ + +error: duplicate label key `key` + --> tests/compile-fail/metric_attr.rs:102:64 + | +102 | #[metric(describe = "gauge", labels = [("key", "value1"), ("key", "value2")])] + | ^^^^^ diff --git a/tests/compile-fail/metrics_attr.rs b/tests/compile-fail/metrics_attr.rs index bb6cf5c..256efce 100644 --- a/tests/compile-fail/metrics_attr.rs +++ b/tests/compile-fail/metrics_attr.rs @@ -62,3 +62,7 @@ struct CustomMetrics14; #[derive(Metrics)] #[metrics(scope = "scope", labels = [], labels = [])] struct CustomMetrics15; + +#[derive(Metrics)] +#[metrics(scope = "scope", labels = [("key", "value1"), ("key", "value2")])] +struct CustomMetrics16; diff --git a/tests/compile-fail/metrics_attr.stderr b/tests/compile-fail/metrics_attr.stderr index f378f06..302f33a 100644 --- a/tests/compile-fail/metrics_attr.stderr +++ b/tests/compile-fail/metrics_attr.stderr @@ -84,3 +84,9 @@ error: duplicate `labels` value provided | 63 | #[metrics(scope = "scope", labels = [], labels = [])] | ^^^^^^^^^^^ + +error: duplicate label key `key` + --> tests/compile-fail/metrics_attr.rs:67:58 + | +67 | #[metrics(scope = "scope", labels = [("key", "value1"), ("key", "value2")])] + | ^^^^^