Skip to main content

nmbrs_metrics/
validation.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! OpenMetrics 1.0 validation helpers.
5//!
6//! Closes the validation gaps documented in [SRD-40a §8].
7//! Each helper returns `Result<(), ValidationError>` —
8//! callers decide whether to fail-fast (strict mode), warn,
9//! or silently coerce. The recording paths in
10//! [`crate::snapshot`] are deliberately permissive (Rust
11//! `String` accepts anything UTF-8); the exposition paths
12//! in [`crate::reporters::openmetrics`] are stricter.
13//!
14//! ## What's checked here
15//!
16//! - **Metric-name ABNF**: `[A-Za-z_:][A-Za-z0-9_:]*` per
17//!   OpenMetrics §4.4 ABNF.
18//! - **Label-name ABNF**: `[A-Za-z_][A-Za-z0-9_]*` per
19//!   OpenMetrics §4.3 ABNF (no colons; tighter than metric
20//!   names).
21//! - **Reserved-name detection**: names beginning with `__`
22//!   are reserved for OpenMetrics-defined uses (`__name__`,
23//!   future spec extensions). User-supplied names hitting
24//!   this pattern are rejected.
25//! - **Unit-suffix invariant**: per spec §4.4, if a unit is
26//!   non-empty it MUST be a suffix of the family name
27//!   separated by an underscore.
28//! - **Bucket-monotonicity**: per spec §5.3, Histogram
29//!   bucket counts are monotonically non-decreasing across
30//!   sorted-by-`le` buckets. GaugeHistogram (§5.4) is
31//!   exempt — gauge histograms can decrease.
32//! - **Exemplar 128-char limit**: per spec §4.7, the
33//!   serialized form of an exemplar's LabelSet MUST NOT
34//!   exceed 128 UTF-8 characters.
35//!
36//! [SRD-40a §8]: ../../../docs/SRD/40a_metrics_model.md#8-gap-audit-current-state-2026-05-05
37
38use std::fmt;
39
40use crate::snapshot::{BucketBound, Exemplar};
41
42/// Validation outcome. Carries a kind tag (so the caller
43/// can decide if it cares) plus a human-readable message.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ValidationError {
46    pub kind: ValidationKind,
47    pub message: String,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ValidationKind {
52    /// Metric / label name violates the ABNF.
53    InvalidName,
54    /// Name uses a `__`-reserved prefix.
55    ReservedName,
56    /// Unit isn't a suffix of the metric name.
57    UnitSuffix,
58    /// Histogram bucket counts decrease across buckets.
59    NonMonotonic,
60    /// Exemplar LabelSet exceeds 128 UTF-8 chars.
61    ExemplarTooLong,
62}
63
64impl fmt::Display for ValidationError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(
67            f,
68            "{}: {}",
69            match self.kind {
70                ValidationKind::InvalidName => "invalid name",
71                ValidationKind::ReservedName => "reserved name",
72                ValidationKind::UnitSuffix => "unit suffix",
73                ValidationKind::NonMonotonic => "non-monotonic buckets",
74                ValidationKind::ExemplarTooLong => "exemplar label-set too long",
75            },
76            self.message
77        )
78    }
79}
80
81impl std::error::Error for ValidationError {}
82
83// =====================================================================
84// Name ABNF checks
85// =====================================================================
86
87/// Per OpenMetrics §4.4 ABNF:
88///
89/// ```text
90/// metricname              = metricname-initial-char 0*metricname-char
91/// metricname-initial-char = ALPHA / "_" / ":"
92/// metricname-char         = metricname-initial-char / DIGIT
93/// ```
94///
95/// Concretely: `[A-Za-z_:][A-Za-z0-9_:]*`. Empty names are
96/// rejected.
97pub fn check_metric_name(name: &str) -> Result<(), ValidationError> {
98    if name.is_empty() {
99        return Err(ValidationError {
100            kind: ValidationKind::InvalidName,
101            message: "metric name must not be empty".into(),
102        });
103    }
104    let mut chars = name.chars();
105    let first = chars.next().unwrap();
106    if !is_metric_initial(first) {
107        return Err(ValidationError {
108            kind: ValidationKind::InvalidName,
109            message: format!(
110                "metric name '{name}': first character must be \
111                 [A-Za-z_:], got '{first}'"
112            ),
113        });
114    }
115    for (i, c) in name.char_indices().skip(first.len_utf8()) {
116        if !is_metric_char(c) {
117            return Err(ValidationError {
118                kind: ValidationKind::InvalidName,
119                message: format!(
120                    "metric name '{name}': character at byte {i} \
121                     ('{c}') is not [A-Za-z0-9_:]"
122                ),
123            });
124        }
125    }
126    Ok(())
127}
128
129/// Per OpenMetrics §4.3 ABNF:
130///
131/// ```text
132/// label-name              = label-name-initial-char *label-name-char
133/// label-name-initial-char = ALPHA / "_"
134/// label-name-char         = label-name-initial-char / DIGIT
135/// ```
136///
137/// Concretely: `[A-Za-z_][A-Za-z0-9_]*`. **No colons**
138/// (tighter than metric names).
139pub fn check_label_name(name: &str) -> Result<(), ValidationError> {
140    if name.is_empty() {
141        return Err(ValidationError {
142            kind: ValidationKind::InvalidName,
143            message: "label name must not be empty".into(),
144        });
145    }
146    let mut chars = name.chars();
147    let first = chars.next().unwrap();
148    if !is_label_initial(first) {
149        return Err(ValidationError {
150            kind: ValidationKind::InvalidName,
151            message: format!(
152                "label name '{name}': first character must be \
153                 [A-Za-z_], got '{first}'"
154            ),
155        });
156    }
157    for (i, c) in name.char_indices().skip(first.len_utf8()) {
158        if !is_label_char(c) {
159            return Err(ValidationError {
160                kind: ValidationKind::InvalidName,
161                message: format!(
162                    "label name '{name}': character at byte {i} \
163                     ('{c}') is not [A-Za-z0-9_]"
164                ),
165            });
166        }
167    }
168    Ok(())
169}
170
171fn is_metric_initial(c: char) -> bool {
172    c.is_ascii_alphabetic() || c == '_' || c == ':'
173}
174fn is_metric_char(c: char) -> bool {
175    is_metric_initial(c) || c.is_ascii_digit()
176}
177fn is_label_initial(c: char) -> bool {
178    c.is_ascii_alphabetic() || c == '_'
179}
180fn is_label_char(c: char) -> bool {
181    is_label_initial(c) || c.is_ascii_digit()
182}
183
184// =====================================================================
185// Reserved-name detection
186// =====================================================================
187
188/// Per OpenMetrics §4.3 / §4.4: names starting with `__`
189/// are RESERVED for spec-defined uses. Catches user code
190/// that accidentally uses `__custom`, `__priv`, etc.
191///
192/// The reader-side synthetic name `__name__` is exempt —
193/// it's a recognised OpenMetrics convention.
194pub fn check_reserved_name(name: &str) -> Result<(), ValidationError> {
195    if name == "__name__" {
196        return Ok(());
197    }
198    if name.starts_with("__") {
199        return Err(ValidationError {
200            kind: ValidationKind::ReservedName,
201            message: format!(
202                "name '{name}' uses the `__`-reserved prefix; \
203                 only OpenMetrics-defined names may start with \
204                 double underscore"
205            ),
206        });
207    }
208    Ok(())
209}
210
211// =====================================================================
212// Unit-suffix invariant
213// =====================================================================
214
215/// Per OpenMetrics §4.4: if a unit is non-empty it MUST be
216/// a suffix of the family name separated by an underscore.
217/// Example: `process_cpu_seconds_total` with unit
218/// `seconds` is valid because `_seconds_` precedes `_total`
219/// (the family name without the OpenMetrics-suffix).
220///
221/// nmbrs's writer stores the family name as-emitted, so the
222/// check here is on the bare name (without per-type
223/// exposition suffixes). The check passes when:
224///
225/// - `unit` is `None` or empty (no constraint), OR
226/// - `name` contains `_<unit>` either as an actual suffix
227///   or as an infix immediately before a known
228///   exposition suffix (`_total` for counters, `_count`
229///   etc.).
230pub fn check_unit_suffix(name: &str, unit: Option<&str>) -> Result<(), ValidationError> {
231    let Some(unit) = unit else {
232        return Ok(());
233    };
234    if unit.is_empty() {
235        return Ok(());
236    }
237    let needle = format!("_{unit}");
238    // Suffix match.
239    if name.ends_with(&needle) {
240        return Ok(());
241    }
242    // Infix match before any known exposition suffix.
243    for suffix in [
244        "_total", "_created", "_count", "_sum", "_bucket", "_gcount", "_gsum", "_info",
245    ] {
246        if let Some(stem) = name.strip_suffix(suffix)
247            && stem.ends_with(&needle)
248        {
249            return Ok(());
250        }
251    }
252    Err(ValidationError {
253        kind: ValidationKind::UnitSuffix,
254        message: format!(
255            "unit '{unit}' is not a suffix of metric name '{name}'; \
256             OpenMetrics §4.4 requires `_{unit}` to appear before \
257             any exposition suffix"
258        ),
259    })
260}
261
262// =====================================================================
263// Bucket monotonicity (§5.3)
264// =====================================================================
265
266/// Per OpenMetrics §5.3: Histogram bucket counts are
267/// **cumulative** and MUST be monotonically non-decreasing
268/// across sorted-by-`le` buckets. Returns the first
269/// violating index pair on failure.
270///
271/// GaugeHistogram (§5.4) bucket counts MAY decrease;
272/// callers who deal with a GaugeHistogram should not call
273/// this.
274pub fn check_bucket_monotonicity(buckets: &[(BucketBound, u64)]) -> Result<(), ValidationError> {
275    let mut prev: Option<u64> = None;
276    for (i, (le, count)) in buckets.iter().enumerate() {
277        if let Some(p) = prev
278            && *count < p
279        {
280            let le_text = match le {
281                BucketBound::Finite(v) => v.to_string(),
282                BucketBound::PositiveInfinity => "+Inf".to_string(),
283            };
284            return Err(ValidationError {
285                kind: ValidationKind::NonMonotonic,
286                message: format!(
287                    "histogram bucket counts must be non-decreasing \
288                     (OpenMetrics §5.3); bucket {i} (le={le_text}) has \
289                     count {count} < previous {p}"
290                ),
291            });
292        }
293        prev = Some(*count);
294    }
295    Ok(())
296}
297
298// =====================================================================
299// Exemplar length limit (§4.7)
300// =====================================================================
301
302/// Per OpenMetrics §4.7: the serialized form of an
303/// exemplar's LabelSet MUST NOT exceed 128 UTF-8 character
304/// code points. The "serialized form" excludes structural
305/// punctuation (`",=`) per spec §4.6.1; our check sums the
306/// raw character lengths of all key/value strings.
307///
308/// 128 chars is small; a single trace ID typically fits in
309/// 32–64 chars, so workloads adding multiple labels (trace,
310/// span, sampler, environment) can run up against this.
311/// Exposition layers should drop violating exemplars
312/// silently per spec; this helper lets recording-side code
313/// emit a diagnostic before the data lands.
314pub const EXEMPLAR_LABELSET_MAX_CHARS: usize = 128;
315
316pub fn check_exemplar_length(exemplar: &Exemplar) -> Result<(), ValidationError> {
317    let total: usize = exemplar
318        .labels
319        .iter()
320        .map(|(k, v)| k.chars().count() + v.chars().count())
321        .sum();
322    if total > EXEMPLAR_LABELSET_MAX_CHARS {
323        return Err(ValidationError {
324            kind: ValidationKind::ExemplarTooLong,
325            message: format!(
326                "exemplar LabelSet serialized to {total} chars; \
327                 OpenMetrics §4.7 caps at {EXEMPLAR_LABELSET_MAX_CHARS}. \
328                 Exposition will drop this exemplar."
329            ),
330        });
331    }
332    Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    // ── Metric name ABNF ──
340
341    #[test]
342    fn metric_names_accept_valid_initial_chars() {
343        assert!(check_metric_name("foo").is_ok());
344        assert!(check_metric_name("_foo").is_ok());
345        assert!(check_metric_name(":foo").is_ok());
346        assert!(check_metric_name("foo_bar").is_ok());
347        assert!(check_metric_name("foo:bar").is_ok());
348        assert!(check_metric_name("a1").is_ok());
349    }
350
351    #[test]
352    fn metric_names_reject_invalid() {
353        assert!(check_metric_name("").is_err());
354        assert!(check_metric_name("1foo").is_err()); // starts with digit
355        assert!(check_metric_name("foo bar").is_err()); // space
356        assert!(check_metric_name("foo.bar").is_err()); // dot
357        assert!(check_metric_name("foo-bar").is_err()); // hyphen
358        assert!(check_metric_name("é").is_err()); // non-ASCII
359    }
360
361    // ── Label name ABNF ──
362
363    #[test]
364    fn label_names_accept_valid() {
365        assert!(check_label_name("foo").is_ok());
366        assert!(check_label_name("_foo").is_ok());
367        assert!(check_label_name("foo_bar").is_ok());
368        assert!(check_label_name("a1").is_ok());
369    }
370
371    #[test]
372    fn label_names_reject_colon() {
373        // Tighter than metric names.
374        let err = check_label_name("foo:bar").unwrap_err();
375        assert_eq!(err.kind, ValidationKind::InvalidName);
376    }
377
378    #[test]
379    fn label_names_reject_other_invalid() {
380        assert!(check_label_name("").is_err());
381        assert!(check_label_name("1foo").is_err());
382        assert!(check_label_name("foo bar").is_err());
383    }
384
385    // ── Reserved names ──
386
387    #[test]
388    fn reserved_check_allows_name_label_synthetic() {
389        assert!(check_reserved_name("__name__").is_ok());
390    }
391
392    #[test]
393    fn reserved_check_rejects_double_underscore_prefix() {
394        let err = check_reserved_name("__custom").unwrap_err();
395        assert_eq!(err.kind, ValidationKind::ReservedName);
396        assert!(check_reserved_name("__priv").is_err());
397        assert!(check_reserved_name("__").is_err());
398    }
399
400    #[test]
401    fn reserved_check_allows_single_underscore_prefix() {
402        assert!(check_reserved_name("_foo").is_ok());
403    }
404
405    // ── Unit suffix ──
406
407    #[test]
408    fn unit_suffix_passes_when_name_ends_with_unit() {
409        assert!(check_unit_suffix("memory_bytes", Some("bytes")).is_ok());
410        assert!(check_unit_suffix("uptime_seconds", Some("seconds")).is_ok());
411    }
412
413    #[test]
414    fn unit_suffix_passes_when_unit_precedes_known_exposition_suffix() {
415        assert!(check_unit_suffix("process_cpu_seconds_total", Some("seconds")).is_ok());
416        assert!(check_unit_suffix("requests_bytes_count", Some("bytes")).is_ok());
417        assert!(check_unit_suffix("latency_seconds_bucket", Some("seconds")).is_ok());
418    }
419
420    #[test]
421    fn unit_suffix_rejects_when_unit_not_suffix() {
422        assert!(check_unit_suffix("memory_used", Some("bytes")).is_err());
423        assert!(check_unit_suffix("foo", Some("seconds")).is_err());
424    }
425
426    #[test]
427    fn unit_suffix_passes_for_empty_unit() {
428        assert!(check_unit_suffix("anything", None).is_ok());
429        assert!(check_unit_suffix("anything", Some("")).is_ok());
430    }
431
432    // ── Bucket monotonicity ──
433
434    #[test]
435    fn buckets_monotonic_increasing_passes() {
436        let bs = vec![
437            (BucketBound::Finite(1), 5),
438            (BucketBound::Finite(10), 7),
439            (BucketBound::Finite(100), 12),
440            (BucketBound::PositiveInfinity, 15),
441        ];
442        assert!(check_bucket_monotonicity(&bs).is_ok());
443    }
444
445    #[test]
446    fn buckets_monotonic_constant_passes() {
447        // Equal counts across buckets are fine.
448        let bs = vec![
449            (BucketBound::Finite(1), 5),
450            (BucketBound::Finite(10), 5),
451            (BucketBound::PositiveInfinity, 5),
452        ];
453        assert!(check_bucket_monotonicity(&bs).is_ok());
454    }
455
456    #[test]
457    fn buckets_decreasing_rejected() {
458        let bs = vec![
459            (BucketBound::Finite(1), 10),
460            (BucketBound::Finite(10), 5),
461            (BucketBound::PositiveInfinity, 7),
462        ];
463        let err = check_bucket_monotonicity(&bs).unwrap_err();
464        assert_eq!(err.kind, ValidationKind::NonMonotonic);
465        assert!(
466            err.message.contains("le=10"),
467            "error should name the violating bucket: {err}"
468        );
469    }
470
471    // ── Exemplar length ──
472
473    #[test]
474    fn exemplar_under_limit_passes() {
475        let ex = Exemplar::new(crate::labels::Labels::of("trace_id", "abc123"), 42.0);
476        assert!(check_exemplar_length(&ex).is_ok());
477    }
478
479    #[test]
480    fn exemplar_over_limit_rejected() {
481        // Build a label whose serialized chars exceed 128.
482        let big_value = "x".repeat(150);
483        let ex = Exemplar::new(crate::labels::Labels::of("trace_id", big_value), 42.0);
484        let err = check_exemplar_length(&ex).unwrap_err();
485        assert_eq!(err.kind, ValidationKind::ExemplarTooLong);
486    }
487
488    #[test]
489    fn exemplar_at_exact_limit_passes() {
490        let v = "x".repeat(EXEMPLAR_LABELSET_MAX_CHARS - "trace_id".len());
491        let ex = Exemplar::new(crate::labels::Labels::of("trace_id", v), 42.0);
492        assert!(check_exemplar_length(&ex).is_ok());
493    }
494}