Skip to main content

metrics_lib/
labels.rs

1//! Labels (tags) for metric instances.
2//!
3//! A [`LabelSet`] is a sorted, deduplicated collection of `(key, value)` pairs
4//! that distinguishes one metric *instance* from another sharing the same
5//! name. Every exporter in [`crate::exporters`] renders labels in its native
6//! format (`name{k="v",k="v"}` for Prometheus / OpenMetrics, `name|#k:v,k:v`
7//! for StatsD, `attributes` for OTLP, JSON object for snapshots).
8//!
9//! # Cardinality
10//!
11//! Labels are the most common source of unbounded metric growth — a single
12//! per-request label value (`user_id`, `request_id`, `trace_id`, …) can blow
13//! up the registry within seconds. [`Registry`](crate::Registry) enforces a
14//! **per-registry hard cap** on unique `(name, labels)` combinations across
15//! all labeled metrics; calls exceeding the cap return `Err(MetricsError::
16//! CardinalityExceeded)` from the `try_*` variants and route to a shared
17//! `__cardinality_overflow__` sink from the non-`try` variants so the hot
18//! path stays panic-free.
19//!
20//! See `MetricsCore::counter_with`, `MetricsCore::gauge_with`,
21//! `MetricsCore::timer_with`, `MetricsCore::rate_with`,
22//! `MetricsCore::histogram_with` and their `try_*` counterparts for the
23//! labeled lookup entry points.
24//!
25//! # Example
26//!
27//! ```
28//! # #[cfg(feature = "count")]
29//! # {
30//! use metrics_lib::{init, metrics, LabelSet};
31//! init();
32//!
33//! // Build a label set in a single expression.
34//! let labels = LabelSet::from([("method", "GET"), ("status", "200")]);
35//! metrics().counter_with("http_requests", &labels).inc();
36//!
37//! // …or build it incrementally.
38//! let mut l = LabelSet::new();
39//! l.add("tenant", "acme");
40//! l.add("region", "us-east-1");
41//! metrics().counter_with("auth_failures", &l).inc();
42//! # }
43//! ```
44
45use std::borrow::Cow;
46use std::cmp::Ordering;
47use std::hash::{Hash, Hasher};
48
49/// A single label key/value pair.
50///
51/// Both halves are `Cow<'static, str>` so static literals stay zero-alloc
52/// while runtime-derived values are supported transparently.
53pub type Label = (Cow<'static, str>, Cow<'static, str>);
54
55/// An ordered, deduplicated set of label key/value pairs.
56///
57/// `LabelSet` maintains its inner `Vec<Label>` sorted by key for stable
58/// hashing and rendering: two semantically equivalent sets (same pairs in any
59/// insertion order) compare equal, hash equal, and render identically.
60///
61/// Construction is allocation-free for an empty set ([`LabelSet::EMPTY`]) and
62/// allocates the backing `Vec` on first insertion.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct LabelSet {
65    /// Sorted by key (binary-searched on insert).
66    pairs: Vec<Label>,
67}
68
69impl LabelSet {
70    /// The empty label set. Suitable for use as a `const` default.
71    pub const EMPTY: LabelSet = LabelSet { pairs: Vec::new() };
72
73    /// Construct a new, empty label set.
74    #[inline]
75    pub const fn new() -> Self {
76        Self::EMPTY
77    }
78
79    /// Number of `(key, value)` pairs in the set.
80    #[inline]
81    pub fn len(&self) -> usize {
82        self.pairs.len()
83    }
84
85    /// `true` when the set has no pairs.
86    #[inline]
87    pub fn is_empty(&self) -> bool {
88        self.pairs.is_empty()
89    }
90
91    /// Iterate label pairs as `(&str, &str)` in sorted order.
92    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
93        self.pairs.iter().map(|(k, v)| (k.as_ref(), v.as_ref()))
94    }
95
96    /// Insert a `(key, value)` pair, replacing any existing value for `key`.
97    ///
98    /// Maintains sort order via `binary_search_by`. Returns `&mut self` for
99    /// chaining.
100    pub fn add(
101        &mut self,
102        key: impl Into<Cow<'static, str>>,
103        value: impl Into<Cow<'static, str>>,
104    ) -> &mut Self {
105        let key = key.into();
106        let value = value.into();
107        match self
108            .pairs
109            .binary_search_by(|(k, _)| (k.as_ref()).cmp(key.as_ref()))
110        {
111            Ok(idx) => self.pairs[idx].1 = value,
112            Err(idx) => self.pairs.insert(idx, (key, value)),
113        }
114        self
115    }
116
117    /// Builder-style insert: consumes `self` and returns it with `(key,
118    /// value)` added or replaced.
119    #[must_use]
120    pub fn with(
121        mut self,
122        key: impl Into<Cow<'static, str>>,
123        value: impl Into<Cow<'static, str>>,
124    ) -> Self {
125        self.add(key, value);
126        self
127    }
128
129    /// Lookup a label value by key.
130    pub fn get(&self, key: &str) -> Option<&str> {
131        self.pairs
132            .binary_search_by(|(k, _)| (k.as_ref()).cmp(key))
133            .ok()
134            .map(|idx| self.pairs[idx].1.as_ref())
135    }
136
137    /// Remove a label by key. Returns `true` if a pair was removed.
138    pub fn remove(&mut self, key: &str) -> bool {
139        if let Ok(idx) = self.pairs.binary_search_by(|(k, _)| (k.as_ref()).cmp(key)) {
140            self.pairs.remove(idx);
141            true
142        } else {
143            false
144        }
145    }
146
147    /// Render labels in Prometheus / OpenMetrics format: `{k="v",k="v"}`.
148    ///
149    /// Returns an empty string for an empty set. Label values are escaped per
150    /// the Prometheus exposition format: `\` → `\\`, `"` → `\"`, `\n` → `\n`.
151    /// Label keys are emitted verbatim (Prometheus requires `[a-zA-Z_][a-zA-Z0-9_]*`);
152    /// callers are responsible for choosing valid keys.
153    pub fn to_prometheus(&self) -> String {
154        if self.pairs.is_empty() {
155            return String::new();
156        }
157        let mut out = String::with_capacity(2 + self.pairs.len() * 16);
158        out.push('{');
159        for (i, (k, v)) in self.pairs.iter().enumerate() {
160            if i > 0 {
161                out.push(',');
162            }
163            out.push_str(k);
164            out.push_str("=\"");
165            escape_prometheus_value(&mut out, v);
166            out.push('"');
167        }
168        out.push('}');
169        out
170    }
171
172    /// Render labels in StatsD DogStatsD format: `|#k:v,k:v`.
173    ///
174    /// Returns an empty string for an empty set. Values are sanitised by
175    /// replacing `|`, `,`, `\n`, and `:` with `_` (the format has no escape
176    /// sequence; sanitisation is the standard practice).
177    pub fn to_statsd(&self) -> String {
178        if self.pairs.is_empty() {
179            return String::new();
180        }
181        let mut out = String::with_capacity(2 + self.pairs.len() * 16);
182        out.push_str("|#");
183        for (i, (k, v)) in self.pairs.iter().enumerate() {
184            if i > 0 {
185                out.push(',');
186            }
187            out.push_str(k);
188            out.push(':');
189            for c in v.chars() {
190                if matches!(c, '|' | ',' | '\n' | ':') {
191                    out.push('_');
192                } else {
193                    out.push(c);
194                }
195            }
196        }
197        out
198    }
199}
200
201impl Hash for LabelSet {
202    fn hash<H: Hasher>(&self, state: &mut H) {
203        // Hash the length explicitly so [("a","b")] doesn't collide with
204        // [("a","b"), ()] etc.
205        self.pairs.len().hash(state);
206        for (k, v) in &self.pairs {
207            k.as_ref().hash(state);
208            v.as_ref().hash(state);
209        }
210    }
211}
212
213impl PartialOrd for LabelSet {
214    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
215        Some(self.cmp(other))
216    }
217}
218
219impl Ord for LabelSet {
220    fn cmp(&self, other: &Self) -> Ordering {
221        let a = self.pairs.iter().map(|(k, v)| (k.as_ref(), v.as_ref()));
222        let b = other.pairs.iter().map(|(k, v)| (k.as_ref(), v.as_ref()));
223        a.cmp(b)
224    }
225}
226
227impl<K, V> FromIterator<(K, V)> for LabelSet
228where
229    K: Into<Cow<'static, str>>,
230    V: Into<Cow<'static, str>>,
231{
232    fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
233        let mut s = Self::new();
234        for (k, v) in iter {
235            s.add(k, v);
236        }
237        s
238    }
239}
240
241impl<K, V, const N: usize> From<[(K, V); N]> for LabelSet
242where
243    K: Into<Cow<'static, str>>,
244    V: Into<Cow<'static, str>>,
245{
246    fn from(arr: [(K, V); N]) -> Self {
247        arr.into_iter().collect()
248    }
249}
250
251#[cfg(feature = "serde")]
252impl serde::Serialize for LabelSet {
253    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
254        use serde::ser::SerializeMap;
255        let mut map = serializer.serialize_map(Some(self.pairs.len()))?;
256        for (k, v) in &self.pairs {
257            map.serialize_entry(k.as_ref(), v.as_ref())?;
258        }
259        map.end()
260    }
261}
262
263fn escape_prometheus_value(out: &mut String, v: &str) {
264    for c in v.chars() {
265        match c {
266            '\\' => out.push_str("\\\\"),
267            '"' => out.push_str("\\\""),
268            '\n' => out.push_str("\\n"),
269            c => out.push(c),
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn empty_set_is_empty_and_renders_empty() {
280        let l = LabelSet::EMPTY;
281        assert!(l.is_empty());
282        assert_eq!(l.len(), 0);
283        assert_eq!(l.to_prometheus(), "");
284        assert_eq!(l.to_statsd(), "");
285    }
286
287    #[test]
288    fn add_keeps_sorted_and_deduplicates() {
289        let mut l = LabelSet::new();
290        l.add("status", "200");
291        l.add("method", "GET");
292        l.add("region", "us");
293        // Insertion order: status, method, region.
294        // Sorted order:    method, region, status.
295        let pairs: Vec<_> = l.iter().collect();
296        assert_eq!(
297            pairs,
298            vec![("method", "GET"), ("region", "us"), ("status", "200")]
299        );
300
301        // Replacing a key updates the value in place.
302        l.add("status", "500");
303        assert_eq!(l.get("status"), Some("500"));
304        assert_eq!(l.len(), 3);
305    }
306
307    #[test]
308    fn equal_sets_hash_equal_regardless_of_insertion_order() {
309        use std::collections::hash_map::DefaultHasher;
310        fn hash(l: &LabelSet) -> u64 {
311            let mut h = DefaultHasher::new();
312            l.hash(&mut h);
313            h.finish()
314        }
315        let a = LabelSet::from([("a", "1"), ("b", "2"), ("c", "3")]);
316        let b = LabelSet::from([("c", "3"), ("a", "1"), ("b", "2")]);
317        assert_eq!(a, b);
318        assert_eq!(hash(&a), hash(&b));
319    }
320
321    #[test]
322    fn remove_keeps_invariants() {
323        let mut l = LabelSet::from([("a", "1"), ("b", "2"), ("c", "3")]);
324        assert!(l.remove("b"));
325        assert!(!l.remove("b"));
326        assert_eq!(l.iter().collect::<Vec<_>>(), vec![("a", "1"), ("c", "3")]);
327    }
328
329    #[test]
330    fn prometheus_rendering_escapes_correctly() {
331        let l = LabelSet::from([("path", r#"/foo "bar"\baz"#), ("note", "line1\nline2")]);
332        let s = l.to_prometheus();
333        // Sorted alphabetically: note, path.
334        assert_eq!(s, r#"{note="line1\nline2",path="/foo \"bar\"\\baz"}"#);
335    }
336
337    #[test]
338    fn statsd_rendering_sanitises_specials() {
339        let l = LabelSet::from([("k1", "with|pipe"), ("k2", "with,comma:colon")]);
340        let s = l.to_statsd();
341        assert_eq!(s, "|#k1:with_pipe,k2:with_comma_colon");
342    }
343
344    #[test]
345    fn ordering_is_lexicographic_over_pairs() {
346        let a = LabelSet::from([("a", "1")]);
347        let b = LabelSet::from([("a", "2")]);
348        let c = LabelSet::from([("b", "0")]);
349        let mut v = vec![c.clone(), b.clone(), a.clone()];
350        v.sort();
351        assert_eq!(v, vec![a, b, c]);
352    }
353
354    #[cfg(feature = "serde")]
355    #[test]
356    fn serde_serializes_as_map() {
357        let l = LabelSet::from([("method", "GET"), ("status", "200")]);
358        let j = serde_json::to_string(&l).unwrap();
359        assert_eq!(j, r#"{"method":"GET","status":"200"}"#);
360    }
361}