Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
325 changes: 316 additions & 9 deletions opentelemetry-sdk/src/metrics/instrument.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

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

use super::meter::{
INSTRUMENT_NAME_EMPTY, INSTRUMENT_NAME_FIRST_ALPHABETIC, INSTRUMENT_NAME_INVALID_CHAR,
INSTRUMENT_NAME_LENGTH, INSTRUMENT_UNIT_INVALID_CHAR, INSTRUMENT_UNIT_LENGTH,
};

use super::Temporality;

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

// if name is provided, it must not be empty
// Validate name if provided
if let Some(name) = &self.name {
if name.is_empty() {
return Err("Stream name must not be empty".into());
return Err(INSTRUMENT_NAME_EMPTY.into());
}

if name.len() > super::meter::INSTRUMENT_NAME_MAX_LENGTH {
return Err(INSTRUMENT_NAME_LENGTH.into());
}

if name.starts_with(|c: char| !c.is_ascii_alphabetic()) {
return Err(INSTRUMENT_NAME_FIRST_ALPHABETIC.into());
}

if name.contains(|c: char| {
!c.is_ascii_alphanumeric()
&& !super::meter::INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS.contains(&c)
}) {
return Err(INSTRUMENT_NAME_INVALID_CHAR.into());
}
}

// if cardinality limit is provided, it must be greater than 0
// Validate unit if provided
if let Some(unit) = &self.unit {
if unit.len() > super::meter::INSTRUMENT_UNIT_NAME_MAX_LENGTH {
return Err(INSTRUMENT_UNIT_LENGTH.into());
}

if unit.contains(|c: char| !c.is_ascii()) {
return Err(INSTRUMENT_UNIT_INVALID_CHAR.into());
}
}

// Validate cardinality limit
if let Some(limit) = self.cardinality_limit {
if limit == 0 {
return Err("Cardinality limit must be greater than 0".into());
}
}

// If the aggregation is set to ExplicitBucketHistogram, validate the bucket boundaries.
// Validate bucket boundaries if using ExplicitBucketHistogram
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we are doing the validation while building the stream, we could get rid of this code:

#[cfg(feature = "spec_unstable_metrics_views")]
{
// TODO: When views are used, validate this upfront
bounds.retain(|v| !v.is_nan());
bounds.sort_by(|a, b| a.partial_cmp(b).expect("NaNs filtered out"));
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, will tackle it soon.

if let Some(Aggregation::ExplicitBucketHistogram { boundaries, .. }) = &self.aggregation {
validate_bucket_boundaries(boundaries)?;
}
Expand Down Expand Up @@ -356,3 +387,279 @@
}
}
}

#[cfg(test)]
mod tests {
use super::StreamBuilder;
use crate::metrics::meter::{
INSTRUMENT_NAME_EMPTY, INSTRUMENT_NAME_FIRST_ALPHABETIC, INSTRUMENT_NAME_INVALID_CHAR,
INSTRUMENT_NAME_LENGTH, INSTRUMENT_UNIT_INVALID_CHAR, INSTRUMENT_UNIT_LENGTH,
};

#[test]
fn stream_name_validation() {
// (name, expected error)
let stream_name_test_cases = vec![
("validateName", ""),
("_startWithNoneAlphabet", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("utf8char锈", INSTRUMENT_NAME_INVALID_CHAR),
("a".repeat(255).leak(), ""),
("a".repeat(256).leak(), INSTRUMENT_NAME_LENGTH),
("invalid name", INSTRUMENT_NAME_INVALID_CHAR),
("allow/slash", ""),
("allow_under_score", ""),
("allow.dots.ok", ""),
("", INSTRUMENT_NAME_EMPTY),
("\\allow\\slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("\\allow\\$$slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("Total $ Count", INSTRUMENT_NAME_INVALID_CHAR),
(
"\\test\\UsagePercent(Total) > 80%",
INSTRUMENT_NAME_FIRST_ALPHABETIC,
),
("/not / allowed", INSTRUMENT_NAME_FIRST_ALPHABETIC),
];

for (name, expected_error) in stream_name_test_cases {
let builder = StreamBuilder::new().with_name(name);
let result = builder.build();

if expected_error.is_empty() {
assert!(
result.is_ok(),
"Expected successful build for name '{}', but got error: {:?}",
name,
result.err()

Check warning on line 432 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L430-L432

Added lines #L430 - L432 were not covered by tests
);
} else {
let err = result.err().unwrap();
Copy link
Contributor

@utpilla utpilla May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: You could use unwrap_err() and possibly even get rid of the err.to_string() check below.

Suggested change
let err = result.err().unwrap();
let err = result.unwrap_err();

let err_str = err.to_string();
assert!(
err_str == expected_error,
"For name '{}', expected error '{}', but got '{}'",

Check warning on line 439 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L439

Added line #L439 was not covered by tests
name,
expected_error,
err_str
);
}
}
}

#[test]
fn stream_unit_validation() {
// (unit, expected error)
let stream_unit_test_cases = vec![
(
"0123456789012345678901234567890123456789012345678901234567890123",
INSTRUMENT_UNIT_LENGTH,
),
("utf8char锈", INSTRUMENT_UNIT_INVALID_CHAR),
("kb", ""),
("Kb/sec", ""),
("%", ""),
("", ""),
];

for (unit, expected_error) in stream_unit_test_cases {
// Use a valid name to isolate unit validation
let builder = StreamBuilder::new().with_name("valid_name").with_unit(unit);

let result = builder.build();

if expected_error.is_empty() {
assert!(
result.is_ok(),
"Expected successful build for unit '{}', but got error: {:?}",
unit,
result.err()

Check warning on line 474 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L472-L474

Added lines #L472 - L474 were not covered by tests
);
} else {
let err = result.err().unwrap();
let err_str = err.to_string();
assert!(
err_str == expected_error,
"For unit '{}', expected error '{}', but got '{}'",

Check warning on line 481 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L481

Added line #L481 was not covered by tests
unit,
expected_error,
err_str
);
}
}
}

#[test]
fn stream_cardinality_limit_validation() {
// Test zero cardinality limit (invalid)
let builder = StreamBuilder::new()
.with_name("valid_name")
.with_cardinality_limit(0);

let result = builder.build();
assert!(result.is_err(), "Expected error for zero cardinality limit");
assert_eq!(
result.err().unwrap().to_string(),
"Cardinality limit must be greater than 0",
"Expected cardinality limit validation error message"

Check warning on line 502 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L502

Added line #L502 was not covered by tests
);

// Test valid cardinality limits
let valid_limits = vec![1, 10, 100, 1000];
for limit in valid_limits {
let builder = StreamBuilder::new()
.with_name("valid_name")
.with_cardinality_limit(limit);

let result = builder.build();
assert!(
result.is_ok(),
"Expected successful build for cardinality limit {}, but got error: {:?}",
limit,
result.err()

Check warning on line 517 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L515-L517

Added lines #L515 - L517 were not covered by tests
);
}
}

#[test]
fn stream_valid_build() {
// Test with valid configuration
let stream = StreamBuilder::new()
.with_name("valid_name")
.with_description("Valid description")
.with_unit("ms")
.with_cardinality_limit(100)
.build();

assert!(
stream.is_ok(),
"Expected valid Stream to be built successfully"

Check warning on line 534 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L534

Added line #L534 was not covered by tests
);
}

#[cfg(feature = "spec_unstable_metrics_views")]
#[test]
fn stream_histogram_bucket_validation() {
use super::Aggregation;

// Test with valid bucket boundaries
let valid_boundaries = vec![1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0];
let builder = StreamBuilder::new()
.with_name("valid_histogram")
.with_aggregation(Aggregation::ExplicitBucketHistogram {
boundaries: valid_boundaries.clone(),
record_min_max: true,
});

let result = builder.build();
assert!(
result.is_ok(),
"Expected successful build with valid bucket boundaries"

Check warning on line 555 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L555

Added line #L555 was not covered by tests
);

// Test with invalid bucket boundaries (NaN and Infinity)

// Test with NaN
let invalid_nan_boundaries = vec![1.0, 2.0, f64::NAN, 10.0];

let builder = StreamBuilder::new()
.with_name("invalid_histogram_nan")
.with_aggregation(Aggregation::ExplicitBucketHistogram {
boundaries: invalid_nan_boundaries,
record_min_max: true,
});

let result = builder.build();
assert!(
result.is_err(),
"Expected error for NaN in bucket boundaries"

Check warning on line 573 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L573

Added line #L573 was not covered by tests
);
assert_eq!(
result.err().unwrap().to_string(),
"Bucket boundaries must not contain NaN, Infinity, or -Infinity",
"Expected correct validation error for NaN"

Check warning on line 578 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L578

Added line #L578 was not covered by tests
);

// Test with infinity
let invalid_inf_boundaries = vec![1.0, 5.0, f64::INFINITY, 100.0];

let builder = StreamBuilder::new()
.with_name("invalid_histogram_inf")
.with_aggregation(Aggregation::ExplicitBucketHistogram {
boundaries: invalid_inf_boundaries,
record_min_max: true,
});

let result = builder.build();
assert!(
result.is_err(),
"Expected error for Infinity in bucket boundaries"

Check warning on line 594 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L594

Added line #L594 was not covered by tests
);
assert_eq!(
result.err().unwrap().to_string(),
"Bucket boundaries must not contain NaN, Infinity, or -Infinity",
"Expected correct validation error for Infinity"

Check warning on line 599 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L599

Added line #L599 was not covered by tests
);

// Test with negative infinity
let invalid_neg_inf_boundaries = vec![f64::NEG_INFINITY, 5.0, 10.0, 100.0];

let builder = StreamBuilder::new()
.with_name("invalid_histogram_neg_inf")
.with_aggregation(Aggregation::ExplicitBucketHistogram {
boundaries: invalid_neg_inf_boundaries,
record_min_max: true,
});

let result = builder.build();
assert!(
result.is_err(),
"Expected error for negative Infinity in bucket boundaries"

Check warning on line 615 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L615

Added line #L615 was not covered by tests
);
assert_eq!(
result.err().unwrap().to_string(),
"Bucket boundaries must not contain NaN, Infinity, or -Infinity",
"Expected correct validation error for negative Infinity"

Check warning on line 620 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L620

Added line #L620 was not covered by tests
);

// Test with unsorted bucket boundaries
let unsorted_boundaries = vec![1.0, 5.0, 2.0, 10.0]; // 2.0 comes after 5.0, which is incorrect

let builder = StreamBuilder::new()
.with_name("unsorted_histogram")
.with_aggregation(Aggregation::ExplicitBucketHistogram {
boundaries: unsorted_boundaries,
record_min_max: true,
});

let result = builder.build();
assert!(
result.is_err(),
"Expected error for unsorted bucket boundaries"

Check warning on line 636 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L636

Added line #L636 was not covered by tests
);
assert_eq!(
result.err().unwrap().to_string(),
"Bucket boundaries must be sorted and not contain any duplicates",
"Expected correct validation error for unsorted boundaries"

Check warning on line 641 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L641

Added line #L641 was not covered by tests
);

// Test with duplicate bucket boundaries
let duplicate_boundaries = vec![1.0, 2.0, 5.0, 5.0, 10.0]; // 5.0 appears twice

let builder = StreamBuilder::new()
.with_name("duplicate_histogram")
.with_aggregation(Aggregation::ExplicitBucketHistogram {
boundaries: duplicate_boundaries,
record_min_max: true,
});

let result = builder.build();
assert!(
result.is_err(),
"Expected error for duplicate bucket boundaries"

Check warning on line 657 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L657

Added line #L657 was not covered by tests
);
assert_eq!(
result.err().unwrap().to_string(),
"Bucket boundaries must be sorted and not contain any duplicates",
"Expected correct validation error for duplicate boundaries"

Check warning on line 662 in opentelemetry-sdk/src/metrics/instrument.rs

View check run for this annotation

Codecov / codecov/patch

opentelemetry-sdk/src/metrics/instrument.rs#L662

Added line #L662 was not covered by tests
);
}
}
30 changes: 15 additions & 15 deletions opentelemetry-sdk/src/metrics/meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,23 @@ use crate::metrics::{
use super::noop::NoopSyncInstrument;

// maximum length of instrument name
const INSTRUMENT_NAME_MAX_LENGTH: usize = 255;
pub(crate) const INSTRUMENT_NAME_MAX_LENGTH: usize = 255;
// maximum length of instrument unit name
const INSTRUMENT_UNIT_NAME_MAX_LENGTH: usize = 63;
pub(crate) const INSTRUMENT_UNIT_NAME_MAX_LENGTH: usize = 63;
// Characters allowed in instrument name
const INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS: [char; 4] = ['_', '.', '-', '/'];

// instrument name validation error strings
const INSTRUMENT_NAME_EMPTY: &str = "instrument name must be non-empty";
const INSTRUMENT_NAME_LENGTH: &str = "instrument name must be less than 256 characters";
const INSTRUMENT_NAME_INVALID_CHAR: &str =
"characters in instrument name must be ASCII and belong to the alphanumeric characters, '_', '.', '-' and '/'";
const INSTRUMENT_NAME_FIRST_ALPHABETIC: &str =
"instrument name must start with an alphabetic character";

// instrument unit validation error strings
const INSTRUMENT_UNIT_LENGTH: &str = "instrument unit must be less than 64 characters";
const INSTRUMENT_UNIT_INVALID_CHAR: &str = "characters in instrument unit must be ASCII";
pub(crate) const INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS: [char; 4] = ['_', '.', '-', '/'];

// name validation error strings
pub(crate) const INSTRUMENT_NAME_EMPTY: &str = "name must be non-empty";
pub(crate) const INSTRUMENT_NAME_LENGTH: &str = "name must be less than 256 characters";
pub(crate) const INSTRUMENT_NAME_INVALID_CHAR: &str =
"characters in name must be ASCII and belong to the alphanumeric characters, '_', '.', '-' and '/'";
pub(crate) const INSTRUMENT_NAME_FIRST_ALPHABETIC: &str =
"name must start with an alphabetic character";

// unit validation error strings
pub(crate) const INSTRUMENT_UNIT_LENGTH: &str = "unit must be less than 64 characters";
pub(crate) const INSTRUMENT_UNIT_INVALID_CHAR: &str = "characters in unit must be ASCII";

/// Handles the creation and coordination of all metric instruments.
///
Expand Down