Skip to content

Commit 2018959

Browse files
authored
fix: Fix validation in Metric stream (#2991)
1 parent 8c29ca7 commit 2018959

File tree

2 files changed

+331
-24
lines changed

2 files changed

+331
-24
lines changed

opentelemetry-sdk/src/metrics/instrument.rs

Lines changed: 316 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ use opentelemetry::{
77

88
use crate::metrics::{aggregation::Aggregation, internal::Measure};
99

10+
use super::meter::{
11+
INSTRUMENT_NAME_EMPTY, INSTRUMENT_NAME_FIRST_ALPHABETIC, INSTRUMENT_NAME_INVALID_CHAR,
12+
INSTRUMENT_NAME_LENGTH, INSTRUMENT_UNIT_INVALID_CHAR, INSTRUMENT_UNIT_LENGTH,
13+
};
14+
1015
use super::Temporality;
1116

1217
/// The identifier of a group of instruments that all perform the same function.
@@ -207,27 +212,53 @@ impl StreamBuilder {
207212
///
208213
/// A Result containing the new Stream instance or an error if the build failed.
209214
pub fn build(self) -> Result<Stream, Box<dyn Error>> {
210-
// TODO: Add same validation as already done while
211-
// creating instruments. It is better to move validation logic
212-
// to a common helper and call it from both places.
213-
// The current implementations does a basic validation
214-
// only to close the overall API design.
215+
// TODO: Avoid copying the validation logic from meter.rs,
216+
// and instead move it to a common place and do it once.
217+
// It is a bug that validations are done in meter.rs
218+
// as it'll not allow users to fix instrumentation mistakes
219+
// using views.
215220

216-
// if name is provided, it must not be empty
221+
// Validate name if provided
217222
if let Some(name) = &self.name {
218223
if name.is_empty() {
219-
return Err("Stream name must not be empty".into());
224+
return Err(INSTRUMENT_NAME_EMPTY.into());
225+
}
226+
227+
if name.len() > super::meter::INSTRUMENT_NAME_MAX_LENGTH {
228+
return Err(INSTRUMENT_NAME_LENGTH.into());
229+
}
230+
231+
if name.starts_with(|c: char| !c.is_ascii_alphabetic()) {
232+
return Err(INSTRUMENT_NAME_FIRST_ALPHABETIC.into());
233+
}
234+
235+
if name.contains(|c: char| {
236+
!c.is_ascii_alphanumeric()
237+
&& !super::meter::INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS.contains(&c)
238+
}) {
239+
return Err(INSTRUMENT_NAME_INVALID_CHAR.into());
220240
}
221241
}
222242

223-
// if cardinality limit is provided, it must be greater than 0
243+
// Validate unit if provided
244+
if let Some(unit) = &self.unit {
245+
if unit.len() > super::meter::INSTRUMENT_UNIT_NAME_MAX_LENGTH {
246+
return Err(INSTRUMENT_UNIT_LENGTH.into());
247+
}
248+
249+
if unit.contains(|c: char| !c.is_ascii()) {
250+
return Err(INSTRUMENT_UNIT_INVALID_CHAR.into());
251+
}
252+
}
253+
254+
// Validate cardinality limit
224255
if let Some(limit) = self.cardinality_limit {
225256
if limit == 0 {
226257
return Err("Cardinality limit must be greater than 0".into());
227258
}
228259
}
229260

230-
// If the aggregation is set to ExplicitBucketHistogram, validate the bucket boundaries.
261+
// Validate bucket boundaries if using ExplicitBucketHistogram
231262
if let Some(Aggregation::ExplicitBucketHistogram { boundaries, .. }) = &self.aggregation {
232263
validate_bucket_boundaries(boundaries)?;
233264
}
@@ -356,3 +387,279 @@ impl<T: Copy + Send + Sync + 'static> AsyncInstrument<T> for Observable<T> {
356387
}
357388
}
358389
}
390+
391+
#[cfg(test)]
392+
mod tests {
393+
use super::StreamBuilder;
394+
use crate::metrics::meter::{
395+
INSTRUMENT_NAME_EMPTY, INSTRUMENT_NAME_FIRST_ALPHABETIC, INSTRUMENT_NAME_INVALID_CHAR,
396+
INSTRUMENT_NAME_LENGTH, INSTRUMENT_UNIT_INVALID_CHAR, INSTRUMENT_UNIT_LENGTH,
397+
};
398+
399+
#[test]
400+
fn stream_name_validation() {
401+
// (name, expected error)
402+
let stream_name_test_cases = vec![
403+
("validateName", ""),
404+
("_startWithNoneAlphabet", INSTRUMENT_NAME_FIRST_ALPHABETIC),
405+
("utf8char锈", INSTRUMENT_NAME_INVALID_CHAR),
406+
("a".repeat(255).leak(), ""),
407+
("a".repeat(256).leak(), INSTRUMENT_NAME_LENGTH),
408+
("invalid name", INSTRUMENT_NAME_INVALID_CHAR),
409+
("allow/slash", ""),
410+
("allow_under_score", ""),
411+
("allow.dots.ok", ""),
412+
("", INSTRUMENT_NAME_EMPTY),
413+
("\\allow\\slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
414+
("\\allow\\$$slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
415+
("Total $ Count", INSTRUMENT_NAME_INVALID_CHAR),
416+
(
417+
"\\test\\UsagePercent(Total) > 80%",
418+
INSTRUMENT_NAME_FIRST_ALPHABETIC,
419+
),
420+
("/not / allowed", INSTRUMENT_NAME_FIRST_ALPHABETIC),
421+
];
422+
423+
for (name, expected_error) in stream_name_test_cases {
424+
let builder = StreamBuilder::new().with_name(name);
425+
let result = builder.build();
426+
427+
if expected_error.is_empty() {
428+
assert!(
429+
result.is_ok(),
430+
"Expected successful build for name '{}', but got error: {:?}",
431+
name,
432+
result.err()
433+
);
434+
} else {
435+
let err = result.err().unwrap();
436+
let err_str = err.to_string();
437+
assert!(
438+
err_str == expected_error,
439+
"For name '{}', expected error '{}', but got '{}'",
440+
name,
441+
expected_error,
442+
err_str
443+
);
444+
}
445+
}
446+
}
447+
448+
#[test]
449+
fn stream_unit_validation() {
450+
// (unit, expected error)
451+
let stream_unit_test_cases = vec![
452+
(
453+
"0123456789012345678901234567890123456789012345678901234567890123",
454+
INSTRUMENT_UNIT_LENGTH,
455+
),
456+
("utf8char锈", INSTRUMENT_UNIT_INVALID_CHAR),
457+
("kb", ""),
458+
("Kb/sec", ""),
459+
("%", ""),
460+
("", ""),
461+
];
462+
463+
for (unit, expected_error) in stream_unit_test_cases {
464+
// Use a valid name to isolate unit validation
465+
let builder = StreamBuilder::new().with_name("valid_name").with_unit(unit);
466+
467+
let result = builder.build();
468+
469+
if expected_error.is_empty() {
470+
assert!(
471+
result.is_ok(),
472+
"Expected successful build for unit '{}', but got error: {:?}",
473+
unit,
474+
result.err()
475+
);
476+
} else {
477+
let err = result.err().unwrap();
478+
let err_str = err.to_string();
479+
assert!(
480+
err_str == expected_error,
481+
"For unit '{}', expected error '{}', but got '{}'",
482+
unit,
483+
expected_error,
484+
err_str
485+
);
486+
}
487+
}
488+
}
489+
490+
#[test]
491+
fn stream_cardinality_limit_validation() {
492+
// Test zero cardinality limit (invalid)
493+
let builder = StreamBuilder::new()
494+
.with_name("valid_name")
495+
.with_cardinality_limit(0);
496+
497+
let result = builder.build();
498+
assert!(result.is_err(), "Expected error for zero cardinality limit");
499+
assert_eq!(
500+
result.err().unwrap().to_string(),
501+
"Cardinality limit must be greater than 0",
502+
"Expected cardinality limit validation error message"
503+
);
504+
505+
// Test valid cardinality limits
506+
let valid_limits = vec![1, 10, 100, 1000];
507+
for limit in valid_limits {
508+
let builder = StreamBuilder::new()
509+
.with_name("valid_name")
510+
.with_cardinality_limit(limit);
511+
512+
let result = builder.build();
513+
assert!(
514+
result.is_ok(),
515+
"Expected successful build for cardinality limit {}, but got error: {:?}",
516+
limit,
517+
result.err()
518+
);
519+
}
520+
}
521+
522+
#[test]
523+
fn stream_valid_build() {
524+
// Test with valid configuration
525+
let stream = StreamBuilder::new()
526+
.with_name("valid_name")
527+
.with_description("Valid description")
528+
.with_unit("ms")
529+
.with_cardinality_limit(100)
530+
.build();
531+
532+
assert!(
533+
stream.is_ok(),
534+
"Expected valid Stream to be built successfully"
535+
);
536+
}
537+
538+
#[cfg(feature = "spec_unstable_metrics_views")]
539+
#[test]
540+
fn stream_histogram_bucket_validation() {
541+
use super::Aggregation;
542+
543+
// Test with valid bucket boundaries
544+
let valid_boundaries = vec![1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0];
545+
let builder = StreamBuilder::new()
546+
.with_name("valid_histogram")
547+
.with_aggregation(Aggregation::ExplicitBucketHistogram {
548+
boundaries: valid_boundaries.clone(),
549+
record_min_max: true,
550+
});
551+
552+
let result = builder.build();
553+
assert!(
554+
result.is_ok(),
555+
"Expected successful build with valid bucket boundaries"
556+
);
557+
558+
// Test with invalid bucket boundaries (NaN and Infinity)
559+
560+
// Test with NaN
561+
let invalid_nan_boundaries = vec![1.0, 2.0, f64::NAN, 10.0];
562+
563+
let builder = StreamBuilder::new()
564+
.with_name("invalid_histogram_nan")
565+
.with_aggregation(Aggregation::ExplicitBucketHistogram {
566+
boundaries: invalid_nan_boundaries,
567+
record_min_max: true,
568+
});
569+
570+
let result = builder.build();
571+
assert!(
572+
result.is_err(),
573+
"Expected error for NaN in bucket boundaries"
574+
);
575+
assert_eq!(
576+
result.err().unwrap().to_string(),
577+
"Bucket boundaries must not contain NaN, Infinity, or -Infinity",
578+
"Expected correct validation error for NaN"
579+
);
580+
581+
// Test with infinity
582+
let invalid_inf_boundaries = vec![1.0, 5.0, f64::INFINITY, 100.0];
583+
584+
let builder = StreamBuilder::new()
585+
.with_name("invalid_histogram_inf")
586+
.with_aggregation(Aggregation::ExplicitBucketHistogram {
587+
boundaries: invalid_inf_boundaries,
588+
record_min_max: true,
589+
});
590+
591+
let result = builder.build();
592+
assert!(
593+
result.is_err(),
594+
"Expected error for Infinity in bucket boundaries"
595+
);
596+
assert_eq!(
597+
result.err().unwrap().to_string(),
598+
"Bucket boundaries must not contain NaN, Infinity, or -Infinity",
599+
"Expected correct validation error for Infinity"
600+
);
601+
602+
// Test with negative infinity
603+
let invalid_neg_inf_boundaries = vec![f64::NEG_INFINITY, 5.0, 10.0, 100.0];
604+
605+
let builder = StreamBuilder::new()
606+
.with_name("invalid_histogram_neg_inf")
607+
.with_aggregation(Aggregation::ExplicitBucketHistogram {
608+
boundaries: invalid_neg_inf_boundaries,
609+
record_min_max: true,
610+
});
611+
612+
let result = builder.build();
613+
assert!(
614+
result.is_err(),
615+
"Expected error for negative Infinity in bucket boundaries"
616+
);
617+
assert_eq!(
618+
result.err().unwrap().to_string(),
619+
"Bucket boundaries must not contain NaN, Infinity, or -Infinity",
620+
"Expected correct validation error for negative Infinity"
621+
);
622+
623+
// Test with unsorted bucket boundaries
624+
let unsorted_boundaries = vec![1.0, 5.0, 2.0, 10.0]; // 2.0 comes after 5.0, which is incorrect
625+
626+
let builder = StreamBuilder::new()
627+
.with_name("unsorted_histogram")
628+
.with_aggregation(Aggregation::ExplicitBucketHistogram {
629+
boundaries: unsorted_boundaries,
630+
record_min_max: true,
631+
});
632+
633+
let result = builder.build();
634+
assert!(
635+
result.is_err(),
636+
"Expected error for unsorted bucket boundaries"
637+
);
638+
assert_eq!(
639+
result.err().unwrap().to_string(),
640+
"Bucket boundaries must be sorted and not contain any duplicates",
641+
"Expected correct validation error for unsorted boundaries"
642+
);
643+
644+
// Test with duplicate bucket boundaries
645+
let duplicate_boundaries = vec![1.0, 2.0, 5.0, 5.0, 10.0]; // 5.0 appears twice
646+
647+
let builder = StreamBuilder::new()
648+
.with_name("duplicate_histogram")
649+
.with_aggregation(Aggregation::ExplicitBucketHistogram {
650+
boundaries: duplicate_boundaries,
651+
record_min_max: true,
652+
});
653+
654+
let result = builder.build();
655+
assert!(
656+
result.is_err(),
657+
"Expected error for duplicate bucket boundaries"
658+
);
659+
assert_eq!(
660+
result.err().unwrap().to_string(),
661+
"Bucket boundaries must be sorted and not contain any duplicates",
662+
"Expected correct validation error for duplicate boundaries"
663+
);
664+
}
665+
}

opentelemetry-sdk/src/metrics/meter.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,23 @@ use crate::metrics::{
2121
use super::noop::NoopSyncInstrument;
2222

2323
// maximum length of instrument name
24-
const INSTRUMENT_NAME_MAX_LENGTH: usize = 255;
24+
pub(crate) const INSTRUMENT_NAME_MAX_LENGTH: usize = 255;
2525
// maximum length of instrument unit name
26-
const INSTRUMENT_UNIT_NAME_MAX_LENGTH: usize = 63;
26+
pub(crate) const INSTRUMENT_UNIT_NAME_MAX_LENGTH: usize = 63;
2727
// Characters allowed in instrument name
28-
const INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS: [char; 4] = ['_', '.', '-', '/'];
29-
30-
// instrument name validation error strings
31-
const INSTRUMENT_NAME_EMPTY: &str = "instrument name must be non-empty";
32-
const INSTRUMENT_NAME_LENGTH: &str = "instrument name must be less than 256 characters";
33-
const INSTRUMENT_NAME_INVALID_CHAR: &str =
34-
"characters in instrument name must be ASCII and belong to the alphanumeric characters, '_', '.', '-' and '/'";
35-
const INSTRUMENT_NAME_FIRST_ALPHABETIC: &str =
36-
"instrument name must start with an alphabetic character";
37-
38-
// instrument unit validation error strings
39-
const INSTRUMENT_UNIT_LENGTH: &str = "instrument unit must be less than 64 characters";
40-
const INSTRUMENT_UNIT_INVALID_CHAR: &str = "characters in instrument unit must be ASCII";
28+
pub(crate) const INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS: [char; 4] = ['_', '.', '-', '/'];
29+
30+
// name validation error strings
31+
pub(crate) const INSTRUMENT_NAME_EMPTY: &str = "name must be non-empty";
32+
pub(crate) const INSTRUMENT_NAME_LENGTH: &str = "name must be less than 256 characters";
33+
pub(crate) const INSTRUMENT_NAME_INVALID_CHAR: &str =
34+
"characters in name must be ASCII and belong to the alphanumeric characters, '_', '.', '-' and '/'";
35+
pub(crate) const INSTRUMENT_NAME_FIRST_ALPHABETIC: &str =
36+
"name must start with an alphabetic character";
37+
38+
// unit validation error strings
39+
pub(crate) const INSTRUMENT_UNIT_LENGTH: &str = "unit must be less than 64 characters";
40+
pub(crate) const INSTRUMENT_UNIT_INVALID_CHAR: &str = "characters in unit must be ASCII";
4141

4242
/// Handles the creation and coordination of all metric instruments.
4343
///

0 commit comments

Comments
 (0)