From f7dc9cd080079dae475d8b5c4510ec653af95b2b Mon Sep 17 00:00:00 2001 From: Michael Sierks Date: Tue, 22 Jul 2025 10:49:46 -0500 Subject: [PATCH] feat: add support for OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT --- opentelemetry-sdk/CHANGELOG.md | 1 + opentelemetry-sdk/src/trace/config.rs | 7 ++ opentelemetry-sdk/src/trace/provider.rs | 9 ++ opentelemetry-sdk/src/trace/span.rs | 61 +++++++++++- opentelemetry-sdk/src/trace/span_limit.rs | 4 + opentelemetry-sdk/src/trace/tracer.rs | 8 ++ opentelemetry/src/common.rs | 113 +++++++++++++++++++++- 7 files changed, 200 insertions(+), 3 deletions(-) diff --git a/opentelemetry-sdk/CHANGELOG.md b/opentelemetry-sdk/CHANGELOG.md index 7e7031d2b0..41f6dde172 100644 --- a/opentelemetry-sdk/CHANGELOG.md +++ b/opentelemetry-sdk/CHANGELOG.md @@ -6,6 +6,7 @@ - *Fix* SpanProcessor::on_start is no longer called on non recording spans - **Fix**: Restore true parallel exports in the async-native `BatchSpanProcessor` by honoring `OTEL_BSP_MAX_CONCURRENT_EXPORTS` ([#2959](https://github.com/open-telemetry/opentelemetry-rust/pull/3028)). A regression in [#2685](https://github.com/open-telemetry/opentelemetry-rust/pull/2685) inadvertently awaited the `export()` future directly in `opentelemetry-sdk/src/trace/span_processor_with_async_runtime.rs` instead of spawning it on the runtime, forcing all exports to run sequentially. - **Feature**: Added `Clone` implementation to `SdkLogger` for API consistency with `SdkTracer` ([#3058](https://github.com/open-telemetry/opentelemetry-rust/issues/3058)). +- Add support for max span attribute value length; Applies to `Value::String` and `Array::String` only. ## 0.30.0 diff --git a/opentelemetry-sdk/src/trace/config.rs b/opentelemetry-sdk/src/trace/config.rs index 67db9509dc..96babfa53d 100644 --- a/opentelemetry-sdk/src/trace/config.rs +++ b/opentelemetry-sdk/src/trace/config.rs @@ -43,6 +43,13 @@ impl Default for Config { config.span_limits.max_attributes_per_span = max_attributes_per_span; } + if let Some(max_attribute_value_length) = env::var("OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT") + .ok() + .and_then(|count_limit| i32::from_str(&count_limit).ok()) + { + config.span_limits.max_attribute_value_length = max_attribute_value_length; + } + if let Some(max_events_per_span) = env::var("OTEL_SPAN_EVENT_COUNT_LIMIT") .ok() .and_then(|max_events| u32::from_str(&max_events).ok()) diff --git a/opentelemetry-sdk/src/trace/provider.rs b/opentelemetry-sdk/src/trace/provider.rs index 2b05f89aea..92811e65f1 100644 --- a/opentelemetry-sdk/src/trace/provider.rs +++ b/opentelemetry-sdk/src/trace/provider.rs @@ -378,6 +378,15 @@ impl TracerProviderBuilder { self } + /// Specify the maximum allowed length of attribute values. + /// + /// This limit applies to [Value::String](opentelemetry::Value::String) and [Array::String](opentelemetry::Array::String) + /// attributes only. When the limit is exceeded, the value is truncated without any indication that truncation has occurred. + pub fn with_max_attribute_value_length(mut self, max_attribute_value_length: u32) -> Self { + self.config.span_limits.max_attribute_value_length = max_attribute_value_length as i32; + self + } + /// Specify the number of events to be recorded per span. pub fn with_max_links_per_span(mut self, max_links: u32) -> Self { self.config.span_limits.max_links_per_span = max_links; diff --git a/opentelemetry-sdk/src/trace/span.rs b/opentelemetry-sdk/src/trace/span.rs index 9b0ae253ce..9f73ee5a46 100644 --- a/opentelemetry-sdk/src/trace/span.rs +++ b/opentelemetry-sdk/src/trace/span.rs @@ -104,7 +104,6 @@ impl opentelemetry::trace::Span for Span { let dropped_attributes_count = attributes.len().saturating_sub(event_attributes_limit); attributes.truncate(event_attributes_limit); - data.events.add_event(Event::new( name, timestamp, @@ -134,10 +133,16 @@ impl opentelemetry::trace::Span for Span { /// Note that the OpenTelemetry project documents certain ["standard /// attributes"](https://github.com/open-telemetry/opentelemetry-specification/tree/v0.5.0/specification/trace/semantic_conventions/README.md) /// that have prescribed semantic meanings. - fn set_attribute(&mut self, attribute: KeyValue) { + fn set_attribute(&mut self, mut attribute: KeyValue) { let span_attribute_limit = self.span_limits.max_attributes_per_span as usize; + let span_attribute_value_limit = self.span_limits.max_attribute_value_length; self.with_data(|data| { if data.attributes.len() < span_attribute_limit { + if span_attribute_value_limit > -1 { + attribute + .value + .truncate(span_attribute_value_limit as usize); + } data.attributes.push(attribute); } else { data.dropped_attributes_count += 1; @@ -278,6 +283,7 @@ mod tests { use crate::trace::{SpanEvents, SpanLinks}; use opentelemetry::trace::{self, SpanBuilder, TraceFlags, TraceId, Tracer}; use opentelemetry::{trace::Span as _, trace::TracerProvider}; + use opentelemetry::{Array, StringValue, Value}; use std::time::Duration; use std::vec; @@ -566,6 +572,57 @@ mod tests { ); } + #[test] + fn exceed_span_attributes_value_length_limit() { + let max_attribute_value_length = 4; + let exporter = NoopSpanExporter::new(); + let provider_builder = crate::trace::SdkTracerProvider::builder() + .with_simple_exporter(exporter) + .with_max_attribute_value_length(max_attribute_value_length); + let provider = provider_builder.build(); + let tracer = provider.tracer("opentelemetry-test"); + + let initial_attributes = vec![ + KeyValue::new("first", String::from("test")), + KeyValue::new("second", String::from("test data")), + KeyValue::new( + "third", + Value::Array( + [ + StringValue::from("t"), + StringValue::from("test"), + StringValue::from("test data"), + StringValue::from("test data, once again"), + ] + .to_vec() + .into(), + ), + ), + ]; + let span_builder = SpanBuilder::from_name("test_span").with_attributes(initial_attributes); + + let mut span = tracer.build(span_builder); + span.set_attribute(KeyValue::new("fourth", "test data, after span builder")); + + span.with_data(|data| { + for attribute in &data.attributes { + if let Value::String(_) = &attribute.value { + assert!( + attribute.value.as_str().len() <= max_attribute_value_length as usize, + "Span attribute values should be truncated to the max length" + ); + } else if let Value::Array(Array::String(elems)) = &attribute.value { + for elem in elems { + assert!( + elem.as_str().len() <= max_attribute_value_length as usize, + "Span attribute values should be truncated to the max length" + ); + } + } + } + }); + } + #[test] fn exceed_event_attributes_limit() { let exporter = NoopSpanExporter::new(); diff --git a/opentelemetry-sdk/src/trace/span_limit.rs b/opentelemetry-sdk/src/trace/span_limit.rs index 7dedce089a..371eb9fabd 100644 --- a/opentelemetry-sdk/src/trace/span_limit.rs +++ b/opentelemetry-sdk/src/trace/span_limit.rs @@ -14,6 +14,7 @@ /// index in the collection. The one added to collections later will be dropped first. pub(crate) const DEFAULT_MAX_EVENT_PER_SPAN: u32 = 128; pub(crate) const DEFAULT_MAX_ATTRIBUTES_PER_SPAN: u32 = 128; +pub(crate) const DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH: i32 = -1; pub(crate) const DEFAULT_MAX_LINKS_PER_SPAN: u32 = 128; pub(crate) const DEFAULT_MAX_ATTRIBUTES_PER_EVENT: u32 = 128; pub(crate) const DEFAULT_MAX_ATTRIBUTES_PER_LINK: u32 = 128; @@ -25,6 +26,8 @@ pub struct SpanLimits { pub max_events_per_span: u32, /// The max attributes that can be added to a `Span`. pub max_attributes_per_span: u32, + /// The max length of attribute values added to a `Span`. + pub max_attribute_value_length: i32, /// The max links that can be added to a `Span`. pub max_links_per_span: u32, /// The max attributes that can be added into an `Event` @@ -38,6 +41,7 @@ impl Default for SpanLimits { SpanLimits { max_events_per_span: DEFAULT_MAX_EVENT_PER_SPAN, max_attributes_per_span: DEFAULT_MAX_ATTRIBUTES_PER_SPAN, + max_attribute_value_length: DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH, max_links_per_span: DEFAULT_MAX_LINKS_PER_SPAN, max_attributes_per_link: DEFAULT_MAX_ATTRIBUTES_PER_LINK, max_attributes_per_event: DEFAULT_MAX_ATTRIBUTES_PER_EVENT, diff --git a/opentelemetry-sdk/src/trace/tracer.rs b/opentelemetry-sdk/src/trace/tracer.rs index a87147e0c2..679eda036b 100644 --- a/opentelemetry-sdk/src/trace/tracer.rs +++ b/opentelemetry-sdk/src/trace/tracer.rs @@ -73,6 +73,14 @@ impl SdkTracer { .saturating_sub(span_attributes_limit); attribute_options.truncate(span_attributes_limit); let dropped_attributes_count = dropped_attributes_count as u32; + let span_attribute_value_limit = span_limits.max_attribute_value_length; + if span_attribute_value_limit > -1 { + for attribute in attribute_options.iter_mut() { + attribute + .value + .truncate(span_attribute_value_limit as usize); + } + } // Links are available as Option> in the builder // If it is None, then there are no links to process. diff --git a/opentelemetry/src/common.rs b/opentelemetry/src/common.rs index 55a9e1dd67..490dfa5b78 100644 --- a/opentelemetry/src/common.rs +++ b/opentelemetry/src/common.rs @@ -263,6 +263,31 @@ impl StringValue { pub fn as_str(&self) -> &str { self.0.as_str() } + + /// Shortens this `StringValue` to the specified character length. + pub fn truncate(&mut self, new_len: usize) { + let s = self.as_ref(); + if s.len() > new_len { + let new_value = truncate_str(s, new_len).to_string(); + + match &self.0 { + OtelString::Owned(_) | OtelString::Static(_) => { + self.0 = OtelString::Owned(new_value.into_boxed_str()); + } + OtelString::RefCounted(_) => { + self.0 = OtelString::RefCounted(Arc::from(new_value.as_str())) + } + } + } + } +} + +/// Returns a str truncated to the specified character length. +fn truncate_str(s: &str, new_len: usize) -> &str { + match s.char_indices().nth(new_len) { + None => s, + Some((idx, _)) => &s[..idx], + } } impl From for String { @@ -327,6 +352,17 @@ impl Value { Value::Array(v) => format!("{v}").into(), } } + + /// Shortens this `Value` to the specified length. + /// + /// Only [Value::String] and [Array::String] values are truncated. + pub fn truncate(&mut self, new_len: usize) { + match self { + Value::Array(Array::String(v)) => v.iter_mut().for_each(|s| s.truncate(new_len)), + Value::String(s) => s.truncate(new_len), + _ => (), + } + } } macro_rules! from_values { @@ -628,7 +664,7 @@ impl InstrumentationScopeBuilder { mod tests { use std::hash::{Hash, Hasher}; - use crate::{InstrumentationScope, KeyValue}; + use crate::{Array, InstrumentationScope, KeyValue, Value}; use rand::random; use std::collections::hash_map::DefaultHasher; @@ -695,6 +731,81 @@ mod tests { } } + #[test] + fn value_truncate() { + for test in [ + (0, Value::from(true), Value::from(true)), + ( + 0, + Value::Array(Array::Bool(vec![true, false])), + Value::Array(Array::Bool(vec![true, false])), + ), + (0, Value::from(42), Value::from(42)), + ( + 0, + Value::Array(Array::I64(vec![42, -1])), + Value::Array(Array::I64(vec![42, -1])), + ), + (0, Value::from(42.0), Value::from(42.0)), + ( + 0, + Value::Array(Array::F64(vec![42.0, -1.0])), + Value::Array(Array::F64(vec![42.0, -1.0])), + ), + (0, Value::from("value"), Value::from("")), + ( + 0, + Value::Array(Array::String(vec!["value-0".into(), "value-1".into()])), + Value::Array(Array::String(vec!["".into(), "".into()])), + ), + (1, Value::from("value"), Value::from("v")), + ( + 1, + Value::Array(Array::String(vec!["value-0".into(), "value-1".into()])), + Value::Array(Array::String(vec!["v".into(), "v".into()])), + ), + (5, Value::from("value"), Value::from("value")), + ( + 7, + Value::Array(Array::String(vec!["value-0".into(), "value-1".into()])), + Value::Array(Array::String(vec!["value-0".into(), "value-1".into()])), + ), + ( + 6, + Value::Array(Array::String(vec!["value".into(), "value-1".into()])), + Value::Array(Array::String(vec!["value".into(), "value-".into()])), + ), + (128, Value::from("value"), Value::from("value")), + ( + 128, + Value::Array(Array::String(vec!["value-0".into(), "value-1".into()])), + Value::Array(Array::String(vec!["value-0".into(), "value-1".into()])), + ), + ] + .iter_mut() + { + test.1.truncate(test.0); + assert_eq!(test.1, test.2) + } + } + + #[test] + fn truncate_str() { + for test in [ + (5, "", ""), + (0, "Zero", ""), + (10, "Short text", "Short text"), + (1, "Hello World!", "H"), + (6, "hello€€", "hello€"), + (2, "££££££", "££"), + (8, "hello€world", "hello€wo"), + ] + .iter() + { + assert_eq!(super::truncate_str(test.1, test.0), test.2); + } + } + fn hash_helper(item: &T) -> u64 { let mut hasher = DefaultHasher::new(); item.hash(&mut hasher);