1use std::fmt;
39
40use crate::snapshot::{BucketBound, Exemplar};
41
42#[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 InvalidName,
54 ReservedName,
56 UnitSuffix,
58 NonMonotonic,
60 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
83pub 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
129pub 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
184pub 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
211pub 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 if name.ends_with(&needle) {
240 return Ok(());
241 }
242 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
262pub 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
298pub 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 #[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()); assert!(check_metric_name("foo bar").is_err()); assert!(check_metric_name("foo.bar").is_err()); assert!(check_metric_name("foo-bar").is_err()); assert!(check_metric_name("é").is_err()); }
360
361 #[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 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 #[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 #[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 #[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 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 #[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 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}