Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
16 changes: 16 additions & 0 deletions opentelemetry-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## vNext

- **Relaxed instrument name validation** to accept the expanded character set
defined by the OpenTelemetry specification. The allowed character set now
includes `:`, `\`, `(`, `)`, `%`, `*`, `#`, and space (with space restricted
to non-leading, non-trailing positions), and the first character is no
longer required to be alphabetic. This enables names shaped like Windows
performance counters (`\Processor(_Total)\% Processor Time`,
`\.NET CLR Memory(*)\# Bytes in all Heaps`) to be used directly without
workarounds. ASCII, case-insensitivity, and the 255-character maximum are
unchanged. See
[opentelemetry-specification#5092](https://github.com/open-telemetry/opentelemetry-specification/pull/5092).
- **Removed** the `experimental_metrics_disable_name_validation` Cargo feature.
With the relaxed validation rules above, the use cases this feature was
introduced for (notably Windows performance counter names) are now supported
by the default validation. Consumers that previously enabled this feature
should drop it from their Cargo.toml.

## 0.32.0

Released 2026-May-08
Expand Down
1 change: 0 additions & 1 deletion opentelemetry-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ spec_unstable_metrics_views = ["metrics"]
experimental_metrics_custom_reader = ["metrics"]
experimental_logs_batch_log_processor_with_async_runtime = ["logs", "experimental_async_runtime"]
experimental_trace_batch_span_processor_with_async_runtime = ["tokio/sync", "trace", "experimental_async_runtime"]
experimental_metrics_disable_name_validation = ["metrics"]
experimental_metrics_bound_instruments = ["metrics", "opentelemetry/experimental_metrics_bound_instruments"]
bench_profiling = []

Expand Down
52 changes: 37 additions & 15 deletions opentelemetry-sdk/src/metrics/instrument.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ use crate::metrics::internal::BoundMeasure;
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,
INSTRUMENT_NAME_EMPTY, INSTRUMENT_NAME_INVALID_CHAR, INSTRUMENT_NAME_LENGTH,
INSTRUMENT_UNIT_INVALID_CHAR, INSTRUMENT_UNIT_LENGTH,
};

use super::Temporality;
Expand Down Expand Up @@ -231,12 +231,14 @@ impl StreamBuilder {
return Err(INSTRUMENT_NAME_LENGTH.into());
}

if name.starts_with(|c: char| !c.is_ascii_alphabetic()) {
return Err(INSTRUMENT_NAME_FIRST_ALPHABETIC.into());
// First and last character must not be a space.
if name.starts_with(' ') || name.ends_with(' ') {
return Err(INSTRUMENT_NAME_INVALID_CHAR.into());
}

if name.contains(|c: char| {
!c.is_ascii_alphanumeric()
c != ' '
&& !c.is_ascii_alphanumeric()
&& !super::meter::INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS.contains(&c)
}) {
return Err(INSTRUMENT_NAME_INVALID_CHAR.into());
Expand Down Expand Up @@ -445,32 +447,52 @@ impl<T: Copy + Send + Sync + 'static> AsyncInstrument<T> for Observable<T> {
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,
INSTRUMENT_NAME_EMPTY, 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![
// Standard names
("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", ""),
// Empty / length / non-ASCII
("", INSTRUMENT_NAME_EMPTY),
("\\allow\\slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("\\allow\\$$slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("a".repeat(256).leak(), INSTRUMENT_NAME_LENGTH),
("utf8char锈", INSTRUMENT_NAME_INVALID_CHAR),
// Newly valid: leading non-alphabetic characters and new chars
("_startWithUnderscore", ""),
("-startWithDash", ""),
("1startWithDigit", ""),
(":startWithColon", ""),
("\\startWithBackslash", ""),
("with:colon", ""),
("with\\backslash", ""),
("with(parens)", ""),
("with%percent", ""),
("with*asterisk", ""),
("with#hash", ""),
// Newly valid: space in middle and Windows perf counter shapes
("invalid name", ""),
("\\Processor(_Total)\\% Processor Time", ""),
("\\.NET CLR Memory(*)\\# Bytes in all Heaps", ""),
("\\allow\\slash /sec", ""),
("/not / allowed but now is", ""),
// Still invalid: leading / trailing space
(" leadingSpace", INSTRUMENT_NAME_INVALID_CHAR),
("trailingSpace ", INSTRUMENT_NAME_INVALID_CHAR),
// Still invalid: characters outside the allowlist
("\\allow\\$$slash /sec", INSTRUMENT_NAME_INVALID_CHAR),
("Total $ Count", INSTRUMENT_NAME_INVALID_CHAR),
(
"\\test\\UsagePercent(Total) > 80%",
INSTRUMENT_NAME_FIRST_ALPHABETIC,
INSTRUMENT_NAME_INVALID_CHAR,
),
("/not / allowed", INSTRUMENT_NAME_FIRST_ALPHABETIC),
];

for (name, expected_error) in stream_name_test_cases {
Expand Down
153 changes: 84 additions & 69 deletions opentelemetry-sdk/src/metrics/meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ use super::noop::NoopSyncInstrument;
pub(crate) const INSTRUMENT_NAME_MAX_LENGTH: usize = 255;
// maximum length of instrument unit name
pub(crate) const INSTRUMENT_UNIT_NAME_MAX_LENGTH: usize = 63;
// Characters allowed in instrument name
pub(crate) const INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS: [char; 4] = ['_', '.', '-', '/'];
// Non-alphanumeric characters allowed in instrument name (in addition to ASCII
// alphanumeric). Space is also allowed but is treated separately because it is
// only permitted in non-leading, non-trailing positions.
pub(crate) const INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS: [char; 11] =
['_', '.', '-', '/', ':', '\\', '(', ')', '%', '*', '#'];

// 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";
pub(crate) const INSTRUMENT_NAME_INVALID_CHAR: &str = "characters in name must be ASCII alphanumeric or one of '_', '.', '-', '/', ':', '\\', '(', ')', '%', '*', '#', or space; first and last characters must not be a space";

// unit validation error strings
pub(crate) const INSTRUMENT_UNIT_LENGTH: &str = "unit must be less than 64 characters";
Expand Down Expand Up @@ -574,13 +574,6 @@ fn validate_bucket_boundaries(boundaries: &[f64]) -> MetricResult<()> {
Ok(())
}

#[cfg(feature = "experimental_metrics_disable_name_validation")]
fn validate_instrument_name(_name: &str) -> MetricResult<()> {
// No name restrictions when name validation is disabled
Ok(())
}

#[cfg(not(feature = "experimental_metrics_disable_name_validation"))]
fn validate_instrument_name(name: &str) -> MetricResult<()> {
if name.is_empty() {
return Err(MetricError::InvalidInstrumentConfiguration(
Expand All @@ -593,13 +586,27 @@ fn validate_instrument_name(name: &str) -> MetricResult<()> {
));
}

if name.starts_with(|c: char| !c.is_ascii_alphabetic()) {
// First character must not be a space.
if name.starts_with(' ') {
return Err(MetricError::InvalidInstrumentConfiguration(
INSTRUMENT_NAME_FIRST_ALPHABETIC,
INSTRUMENT_NAME_INVALID_CHAR,
));
}

// Last character must not be a space.
if name.ends_with(' ') {
return Err(MetricError::InvalidInstrumentConfiguration(
INSTRUMENT_NAME_INVALID_CHAR,
));
}

// Every character must be ASCII alphanumeric, one of the allowed
// non-alphanumeric characters, or a space (space allowed in middle only;
// leading/trailing already rejected above).
if name.contains(|c: char| {
!c.is_ascii_alphanumeric() && !INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS.contains(&c)
c != ' '
&& !c.is_ascii_alphanumeric()
&& !INSTRUMENT_NAME_ALLOWED_NON_ALPHANUMERIC_CHARS.contains(&c)
}) {
return Err(MetricError::InvalidInstrumentConfiguration(
INSTRUMENT_NAME_INVALID_CHAR,
Expand Down Expand Up @@ -688,80 +695,88 @@ mod tests {

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

#[test]
#[cfg(not(feature = "experimental_metrics_disable_name_validation"))]
fn instrument_name_validation() {
// (name, expected error)
let instrument_name_test_cases = vec![
// Standard names
("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", ""),
// Empty / length / non-ASCII
("", INSTRUMENT_NAME_EMPTY),
("\\allow\\slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("\\allow\\$$slash /sec", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("a".repeat(256).leak(), INSTRUMENT_NAME_LENGTH),
("utf8char锈", INSTRUMENT_NAME_INVALID_CHAR),
// Newly valid: leading non-alphabetic characters
("_startWithUnderscore", ""),
("-startWithDash", ""),
(".startWithDot", ""),
("1startWithDigit", ""),
(":startWithColon", ""),
("\\startWithBackslash", ""),
("(startWithParen", ""),
("%startWithPercent", ""),
("*startWithAsterisk", ""),
("#startWithHash", ""),
// Newly valid: ':', '\', '(', ')', '%', '*', '#'
("with:colon", ""),
("with\\backslash", ""),
("with(parens)", ""),
("with%percent", ""),
("with*asterisk", ""),
("with#hash", ""),
// Newly valid: space in middle
("invalid name", ""),
("name with multiple words", ""),
("name with consecutive spaces", ""),
// Newly valid: real-world Windows performance counter shapes
("\\Processor(_Total)\\% Processor Time", ""),
("\\Memory\\Available Bytes", ""),
("\\.NET CLR Memory(*)\\# Bytes in all Heaps", ""),
("\\.NET CLR Memory(_Global_)\\# Gen 0 Collections", ""),
("\\test\\UsagePercent(Total) 80%", ""),
("\\allow\\slash /sec", ""),
("/not / allowed but now is", ""),
// Still invalid: leading / trailing space
(" leadingSpace", INSTRUMENT_NAME_INVALID_CHAR),
("trailingSpace ", INSTRUMENT_NAME_INVALID_CHAR),
(" ", INSTRUMENT_NAME_INVALID_CHAR),
(" ", INSTRUMENT_NAME_INVALID_CHAR),
// Still invalid: characters outside the allowlist
("\\allow\\$$slash /sec", INSTRUMENT_NAME_INVALID_CHAR),
("Total $ Count", INSTRUMENT_NAME_INVALID_CHAR),
(
"\\test\\UsagePercent(Total) > 80%",
INSTRUMENT_NAME_FIRST_ALPHABETIC,
INSTRUMENT_NAME_INVALID_CHAR,
),
("/not / allowed", INSTRUMENT_NAME_FIRST_ALPHABETIC),
("name\twith\ttab", INSTRUMENT_NAME_INVALID_CHAR),
("name\nwith\nnewline", INSTRUMENT_NAME_INVALID_CHAR),
("name+with+plus", INSTRUMENT_NAME_INVALID_CHAR),
("name[with[bracket", INSTRUMENT_NAME_INVALID_CHAR),
("name;with;semicolon", INSTRUMENT_NAME_INVALID_CHAR),
("name=with=equals", INSTRUMENT_NAME_INVALID_CHAR),
];
for (name, expected_error) in instrument_name_test_cases {
let assert = |result: Result<_, MetricError>| {
if expected_error.is_empty() {
assert!(result.is_ok());
} else {
assert!(matches!(
result.unwrap_err(),
MetricError::InvalidInstrumentConfiguration(msg) if msg == expected_error
));
}
};

assert(validate_instrument_name(name).map(|_| ()));
}
}

#[test]
#[cfg(feature = "experimental_metrics_disable_name_validation")]
fn instrument_name_validation_disabled() {
// (name, expected error)
let instrument_name_test_cases = vec![
("validateName", ""),
("_startWithNoneAlphabet", ""),
("utf8char锈", ""),
("a".repeat(255).leak(), ""),
("a".repeat(256).leak(), ""),
("invalid name", ""),
("allow/slash", ""),
("allow_under_score", ""),
("allow.dots.ok", ""),
("", ""),
("\\allow\\slash /sec", ""),
("\\allow\\$$slash /sec", ""),
("Total $ Count", ""),
("\\test\\UsagePercent(Total) > 80%", ""),
("/not / allowed", ""),
];
for (name, expected_error) in instrument_name_test_cases {
let assert = |result: Result<_, MetricError>| {
if expected_error.is_empty() {
assert!(result.is_ok());
assert!(
result.is_ok(),
"expected {name:?} to be valid, but got error: {result:?}"
);
} else {
assert!(matches!(
result.unwrap_err(),
MetricError::InvalidInstrumentConfiguration(msg) if msg == expected_error
));
assert!(
matches!(
result.unwrap_err(),
MetricError::InvalidInstrumentConfiguration(msg) if msg == expected_error
),
"expected {name:?} to fail with {expected_error:?}"
);
}
};

Expand Down
Loading
Loading