Skip to main content

nmbrs_metrics/
labels.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dimensional metric labels.
5//!
6//! Every metric carries a set of key-value labels for identification.
7//! Labels are immutable, `Arc`-shared for cheap cloning, and compose
8//! hierarchically (child inherits parent).
9
10use std::fmt;
11use std::sync::Arc;
12
13/// An immutable set of key-value label pairs.
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
15pub struct Labels {
16    pairs: Arc<Vec<(String, String)>>,
17}
18
19impl Default for Labels {
20    fn default() -> Self {
21        Self::empty()
22    }
23}
24
25impl Labels {
26    pub fn empty() -> Self {
27        Self {
28            pairs: Arc::new(Vec::new()),
29        }
30    }
31
32    pub fn of(key: impl Into<String>, value: impl Into<String>) -> Self {
33        Self {
34            pairs: Arc::new(vec![(key.into(), value.into())]),
35        }
36    }
37
38    pub fn with(&self, key: impl Into<String>, value: impl Into<String>) -> Self {
39        let mut pairs = (*self.pairs).clone();
40        let key = key.into();
41        if let Some(pos) = pairs.iter().position(|(k, _)| k == &key) {
42            pairs[pos].1 = value.into();
43        } else {
44            pairs.push((key, value.into()));
45        }
46        Self {
47            pairs: Arc::new(pairs),
48        }
49    }
50
51    pub fn extend(&self, child: &Labels) -> Labels {
52        let mut pairs = (*self.pairs).clone();
53        for (k, v) in child.pairs.iter() {
54            if let Some(pos) = pairs.iter().position(|(pk, _)| pk == k) {
55                pairs[pos].1 = v.clone();
56            } else {
57                pairs.push((k.clone(), v.clone()));
58            }
59        }
60        Labels {
61            pairs: Arc::new(pairs),
62        }
63    }
64
65    pub fn get(&self, key: &str) -> Option<&str> {
66        self.pairs
67            .iter()
68            .find(|(k, _)| k == key)
69            .map(|(_, v)| v.as_str())
70    }
71
72    pub fn len(&self) -> usize {
73        self.pairs.len()
74    }
75    pub fn is_empty(&self) -> bool {
76        self.pairs.is_empty()
77    }
78
79    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
80        self.pairs.iter().map(|(k, v)| (k.as_str(), v.as_str()))
81    }
82
83    pub fn to_prometheus(&self) -> String {
84        if self.pairs.is_empty() {
85            return String::new();
86        }
87        let inner: Vec<String> = self
88            .pairs
89            .iter()
90            .map(|(k, v)| format!("{k}=\"{v}\""))
91            .collect();
92        format!("{{{}}}", inner.join(","))
93    }
94
95    pub fn to_dotted(&self) -> String {
96        self.pairs
97            .iter()
98            .map(|(_, v)| v.as_str())
99            .collect::<Vec<_>>()
100            .join(".")
101    }
102
103    /// Content-defined identity hash for this label set.
104    ///
105    /// Stability contract:
106    ///   * Order-independent — pairs are sorted by key before
107    ///     hashing, so `Labels::of("a","1").with("b","2")` and
108    ///     `Labels::of("b","2").with("a","1")` hash to the same
109    ///     value. Without this, two code paths that construct
110    ///     "the same" label set in different orders create two
111    ///     distinct `metric_instance` rows in the sqlite sink —
112    ///     a silent double-count on every aggregate query.
113    ///   * Uses FNV-1a 64 (defined inline). `DefaultHasher`
114    ///     (SipHasher13) is deterministic within a Rust version
115    ///     but the std docs explicitly reserve the right to
116    ///     change it — anything that writes a hash to durable
117    ///     storage needs a hasher that we own.
118    pub fn identity_hash(&self) -> u64 {
119        // Sort by key (then value for total order) without
120        // mutating the Arc'd vec. Borrowed view is cheap; the
121        // pair count is small (typically <16).
122        let mut sorted: Vec<(&str, &str)> = self
123            .pairs
124            .iter()
125            .map(|(k, v)| (k.as_str(), v.as_str()))
126            .collect();
127        sorted.sort();
128        let mut h: u64 = 0xcbf29ce484222325; // FNV-1a 64-bit offset
129        for (k, v) in &sorted {
130            for b in k.as_bytes() {
131                h ^= *b as u64;
132                h = h.wrapping_mul(0x100000001b3);
133            }
134            // `=` separator so `a=bc` and `ab=c` don't collide.
135            h ^= b'=' as u64;
136            h = h.wrapping_mul(0x100000001b3);
137            for b in v.as_bytes() {
138                h ^= *b as u64;
139                h = h.wrapping_mul(0x100000001b3);
140            }
141            // `\0` separator between pairs.
142            h ^= 0;
143            h = h.wrapping_mul(0x100000001b3);
144        }
145        h
146    }
147
148    /// Canonical sorted-by-key view of the pairs, borrowed.
149    /// Used by reporters that need a stable serialised form
150    /// (filename, db spec, etc.) so the on-disk identity
151    /// matches `identity_hash`.
152    pub fn sorted_pairs(&self) -> Vec<(&str, &str)> {
153        let mut v: Vec<(&str, &str)> = self
154            .pairs
155            .iter()
156            .map(|(k, val)| (k.as_str(), val.as_str()))
157            .collect();
158        v.sort();
159        v
160    }
161
162    /// Build the OpenMetrics-canonical sample identifier for
163    /// this label set under the given metric family name —
164    /// the text form used by Prometheus, OpenMetrics, and
165    /// VictoriaMetrics for uniquely naming a time series.
166    ///
167    /// Shape: `metric_name{key="value",key="value"}`
168    ///   * Labels are sorted by name (canonical: two label
169    ///     dicts that are equal as a mapping produce equal
170    ///     spec text).
171    ///   * `__name__` is excluded from the labels block (the
172    ///     metric name is the prefix, per spec); other code
173    ///     paths still see `__name__` as a regular label row
174    ///     in the database for query uniformity.
175    ///   * Empty label values are dropped (OpenMetrics spec
176    ///     §"Label": "Empty label values SHOULD be treated as
177    ///     if the label was not present").
178    ///   * Values are escaped per OpenMetrics §"Escaping":
179    ///     `\` → `\\`, `"` → `\"`, `\n` → `\n` literal.
180    pub fn to_canonical_spec(&self, metric_name: &str) -> String {
181        let mut pairs: Vec<(&str, &str)> = self
182            .pairs
183            .iter()
184            .map(|(k, v)| (k.as_str(), v.as_str()))
185            .filter(|(k, v)| !v.is_empty() && *k != "__name__")
186            .collect();
187        pairs.sort();
188        let mut out = String::with_capacity(metric_name.len() + 32);
189        out.push_str(metric_name);
190        out.push('{');
191        let mut first = true;
192        for (k, v) in pairs {
193            if !first {
194                out.push(',');
195            }
196            first = false;
197            out.push_str(k);
198            out.push_str("=\"");
199            escape_label_value_into(&mut out, v);
200            out.push('"');
201        }
202        out.push('}');
203        out
204    }
205}
206
207/// Escape a label value per OpenMetrics §"Escaping" rules:
208///   `\`  → `\\`
209///   `"`  → `\"`
210///   `\n` → `\n` (two-char literal backslash-n)
211/// Other characters pass through unchanged.
212pub fn escape_label_value_into(out: &mut String, v: &str) {
213    for c in v.chars() {
214        match c {
215            '\\' => out.push_str("\\\\"),
216            '"' => out.push_str("\\\""),
217            '\n' => out.push_str("\\n"),
218            c => out.push(c),
219        }
220    }
221}
222
223/// Convenience: standalone form of [`escape_label_value_into`]
224/// for callers that don't already have a buffer.
225pub fn escape_label_value(v: &str) -> String {
226    let mut out = String::with_capacity(v.len());
227    escape_label_value_into(&mut out, v);
228    out
229}
230
231impl fmt::Display for Labels {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(f, "{}", self.to_prometheus())
234    }
235}
236
237/// Semantic category for metric filtering.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
239pub enum MetricCategory {
240    Core,
241    Progress,
242    Errors,
243    Driver,
244    Internals,
245    Verification,
246    Config,
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn labels_with_override() {
255        let l = Labels::of("a", "1").with("a", "2");
256        assert_eq!(l.get("a"), Some("2"));
257        assert_eq!(l.len(), 1);
258    }
259
260    #[test]
261    fn labels_extend_child_wins() {
262        let parent = Labels::of("session", "s1").with("activity", "write");
263        let child = Labels::of("name", "timer1").with("activity", "read");
264        let merged = parent.extend(&child);
265        assert_eq!(merged.get("session"), Some("s1"));
266        assert_eq!(merged.get("activity"), Some("read"));
267        assert_eq!(merged.get("name"), Some("timer1"));
268    }
269
270    #[test]
271    fn labels_prometheus_format() {
272        let l = Labels::of("session", "abc").with("name", "ops_total");
273        assert_eq!(l.to_prometheus(), r#"{session="abc",name="ops_total"}"#);
274    }
275
276    #[test]
277    fn identity_hash_order_independent() {
278        // Stability contract: two code paths that construct
279        // the same logical label set in different orders MUST
280        // hash equally. Otherwise downstream reporters create
281        // duplicate `metric_instance` rows for the same
282        // logical instance.
283        let a = Labels::of("k", "1")
284            .with("optimize_for", "recall")
285            .with("phase", "ann_query");
286        let b = Labels::of("phase", "ann_query")
287            .with("k", "1")
288            .with("optimize_for", "recall");
289        let c = Labels::of("optimize_for", "recall")
290            .with("phase", "ann_query")
291            .with("k", "1");
292        assert_eq!(a.identity_hash(), b.identity_hash());
293        assert_eq!(a.identity_hash(), c.identity_hash());
294    }
295
296    #[test]
297    fn identity_hash_distinguishes_distinct_sets() {
298        let a = Labels::of("phase", "ann_query");
299        let b = Labels::of("phase", "pvs_query");
300        assert_ne!(a.identity_hash(), b.identity_hash());
301    }
302
303    #[test]
304    fn identity_hash_no_collision_between_split_keys() {
305        // Defensive: ensure the `=`/`\0` separators stop
306        // `ab=c` colliding with `a=bc`.
307        let a = Labels::of("ab", "c");
308        let b = Labels::of("a", "bc");
309        assert_ne!(a.identity_hash(), b.identity_hash());
310    }
311
312    #[test]
313    fn canonical_spec_sorts_and_quotes() {
314        let l = Labels::of("phase", "ann_query")
315            .with("k", "1")
316            .with("optimize_for", "recall");
317        assert_eq!(
318            l.to_canonical_spec("recall_mean"),
319            r#"recall_mean{k="1",optimize_for="recall",phase="ann_query"}"#,
320        );
321    }
322
323    #[test]
324    fn canonical_spec_order_independent() {
325        let a = Labels::of("phase", "ann").with("k", "1");
326        let b = Labels::of("k", "1").with("phase", "ann");
327        assert_eq!(
328            a.to_canonical_spec("recall_mean"),
329            b.to_canonical_spec("recall_mean"),
330        );
331    }
332
333    #[test]
334    fn canonical_spec_drops_empty_values_and_underscore_name() {
335        let l = Labels::of("phase", "ann")
336            .with("hint", "")
337            .with("__name__", "should_be_ignored_here");
338        let spec = l.to_canonical_spec("ops_total");
339        // Empty value `hint=""` dropped (OpenMetrics §"Label"
340        // empty-value clause); `__name__` excluded from
341        // labels block.
342        assert_eq!(spec, r#"ops_total{phase="ann"}"#);
343    }
344
345    #[test]
346    fn canonical_spec_escapes_values() {
347        let l = Labels::of("note", r#"has "quotes" and \ slash"#);
348        assert_eq!(
349            l.to_canonical_spec("m"),
350            r#"m{note="has \"quotes\" and \\ slash"}"#,
351        );
352    }
353
354    #[test]
355    fn canonical_spec_empty_labels() {
356        assert_eq!(
357            Labels::empty().to_canonical_spec("ops_total"),
358            "ops_total{}"
359        );
360    }
361
362    #[test]
363    fn labels_clone_shares_arc() {
364        let l = Labels::of("a", "1").with("b", "2");
365        let l2 = l.clone();
366        assert!(Arc::ptr_eq(&l.pairs, &l2.pairs));
367    }
368}