From 70dd357a8b1c9fd3cf77ee7402d4fa521e235146 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 28 Oct 2026 20:42:39 +0000 Subject: [PATCH 1/4] opentelemetry-api: truncate byte array attribute values according to spec Truncate bytes and bytearray attribute values to max_value_length as mandated by the OpenTelemetry Attribute Limits specification. Assisted-by: Gemini --- .../src/opentelemetry/attributes/__init__.py | 86 +++++++++++-------- .../tests/attributes/test_attributes.py | 45 +++++++++- 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index f589b6b982e..f1b0e332d36 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -27,24 +27,53 @@ def _is_non_custom_str(key: Any) -> bool: @overload def _clean_attribute_value( value: Mapping[str, types.AnyValue], - max_string_value_length: int | None, + max_value_length: int | None = ..., + max_string_value_length: int | None = ..., ) -> Mapping[str, types.AnyValue]: ... @overload def _clean_attribute_value( value: types.AnyValue, - max_string_value_length: int | None, + max_value_length: int | None = ..., + max_string_value_length: int | None = ..., ) -> types.AnyValue: ... +def _clean_mapping( + mapping: Mapping[Any, types.AnyValue], + max_value_length: int | None, +) -> dict[str, types.AnyValue]: + cleaned_mapping: dict[str, types.AnyValue] = {} + for key, val in mapping.items(): + if not key: + _logger.warning( + "invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.", + key, + ) + continue + # Spec says to convert unknown types to strings if possible (here and below too). + if not isinstance(key, str): + _logger.warning( + "Invalid type `%s` for attribute key `%s`, must be a str. Key's `__str__/__repr__` method will be called if it exists, otherwise the key/value pair will be dropped.", + type(key).__name__, + key, + ) + if not _is_non_custom_str(key): + continue + key = str(key) + cleaned_mapping[key] = _clean_attribute_value(val, max_value_length) + return cleaned_mapping + + def _clean_attribute_value( value: types.AnyValue, - max_string_value_length: int | None, + max_value_length: int | None = None, + max_string_value_length: int | None = None, ) -> types.AnyValue: """Recursively checks if an attribute value is valid and cleans it if required. - String values are truncated to max_string_value_length if provided. + String and byte array values are truncated to max_value_length if provided. Anything that isn't of `types.AnyValue`, we attempt to cast to `str`. If this fails, the value is replaced with None. Sequence's are converted to tuples and mappings are copied into new dicts. @@ -52,40 +81,30 @@ def _clean_attribute_value( Returns: The recursively cleaned AnyValue. """ - if isinstance(value, (NoneType, bool, int, float, bytes)): + if max_value_length is None and max_string_value_length is not None: + max_value_length = max_string_value_length + if isinstance(value, (NoneType, bool, int, float)): return value + if isinstance(value, (bytes, bytearray)): + if max_value_length is not None and len(value) > max_value_length: + _logger.warning( + "Byte array attribute value exceeds max length of %d, truncating.", + max_value_length, + ) + value = value[:max_value_length] + return bytes(value) if isinstance(value, str): - if max_string_value_length is not None and len(value) > max_string_value_length: + if max_value_length is not None and len(value) > max_value_length: _logger.warning( "String attribute value exceeds max length of %d, truncating.", - max_string_value_length, + max_value_length, ) - value = value[:max_string_value_length] + value = value[:max_value_length] return value if isinstance(value, Sequence): - return tuple(_clean_attribute_value(v, max_string_value_length) for v in value) + return tuple(_clean_attribute_value(v, max_value_length) for v in value) if isinstance(value, Mapping): - cleaned_mapping: dict[str, types.AnyValue] = {} - for key, val in value.items(): - if not key: - _logger.warning( - "invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.", - key, - ) - continue - # Spec says to convert unknown types to strings if possible (here and below too). - if not isinstance(key, str): - _logger.warning( - "Invalid type `%s` for attribute key `%s`, must be a str. Key's `__str__/__repr__` method will be called if it exists, otherwise the key/value pair will be dropped.", - type(key), - key, - ) - if _is_non_custom_str(key): - key = str(key) - else: - continue - cleaned_mapping[key] = _clean_attribute_value(val, max_string_value_length) - return cleaned_mapping + return _clean_mapping(value, max_value_length) if TYPE_CHECKING: assert_never(value) _logger.warning( @@ -93,9 +112,8 @@ def _clean_attribute_value( "Mapping or Sequence of those types. Value's __str__ method will be called if it exists, otherwise the value will be replaced with None.", type(value), ) - if _is_non_custom_str(value): - return str(value) - return None + fallback: types.AnyValue = str(value) if _is_non_custom_str(value) else None + return fallback class BoundedAttributes(MutableMapping[str, types.AnyValue]): @@ -105,7 +123,7 @@ class BoundedAttributes(MutableMapping[str, types.AnyValue]): maxlen: The maximum number of attributes to store, use None for no limit. attributes: The initial attributes to store. immutable: Defaults to true. Whether to allow adding/removing of attributes after the initialisation of the instance. - max_value_len: The maximum length of string values, use None for no limit. + max_value_len: The maximum length of string and byte array values, use None for no limit. extended_attributes: Deprecated. Kept for backwards compatibility. Extended attributes are now always used for attributes everywhere. When the dict is full, a new key is dropped; existing keys can still be updated. Attributes are made to be immutable when set in this container. diff --git a/opentelemetry-api/tests/attributes/test_attributes.py b/opentelemetry-api/tests/attributes/test_attributes.py index e9fd681bcbf..95a90fa8e03 100644 --- a/opentelemetry-api/tests/attributes/test_attributes.py +++ b/opentelemetry-api/tests/attributes/test_attributes.py @@ -47,7 +47,23 @@ def test_clean_attribute_value_with_various_params(self): for valid_primitive in valid_primitive_sequence: self.assertEqual(_clean_attribute_value(valid_primitive, None), valid_primitive) - self.assertEqual(_clean_attribute_value(b"hello", 4), b"hello") + # Byte array is not truncated when length <= limit. + self.assertEqual(_clean_attribute_value(b"hello", 5), b"hello") + self.assertEqual(_clean_attribute_value(b"hello", None), b"hello") + + with self.assertLogs("opentelemetry", level="WARNING") as cm: + # Byte array is truncated. + self.assertEqual(_clean_attribute_value(b"hello", 4), b"hell") + self.assertEqual(len(cm.output), 1) + self.assertIn("Byte array attribute value exceeds max length", cm.output[0]) + + with self.assertLogs("opentelemetry", level="WARNING") as cm: + # Mutable bytearray is truncated and converted to immutable bytes. + cleaned_bytearray = _clean_attribute_value(bytearray(b"hello"), 4) + self.assertEqual(cleaned_bytearray, b"hell") + self.assertIsInstance(cleaned_bytearray, bytes) + self.assertEqual(len(cm.output), 1) + self.assertIn("Byte array attribute value exceeds max length", cm.output[0]) with self.assertLogs("opentelemetry", level="WARNING") as cm: # String is truncated. @@ -285,3 +301,30 @@ def test_deepcopy_preserves_immutability(self): with self.assertRaises(TypeError): bdict_copy["invalid"] = "invalid" + + def test_byte_array_truncation_in_bounded_attributes(self): + bdict = BoundedAttributes(immutable=False, max_value_len=4) + with self.assertLogs("opentelemetry", level="WARNING") as cm: + bdict["bytes_val"] = b"1234567" + self.assertEqual(bdict["bytes_val"], b"1234") + self.assertEqual(len(cm.output), 1) + self.assertIn("Byte array attribute value exceeds max length of 4, truncating.", cm.output[0]) + + with self.assertLogs("opentelemetry", level="WARNING") as cm: + bdict["bytearray_val"] = bytearray(b"abcdefg") + self.assertEqual(bdict["bytearray_val"], b"abcd") + self.assertIsInstance(bdict["bytearray_val"], bytes) + + def test_byte_array_in_nested_structures_truncated(self): + # In sequences + with self.assertLogs("opentelemetry", level="WARNING") as cm: + cleaned_seq = _clean_attribute_value([b"abcdef", b"xy", bytearray(b"123456")], 3) + self.assertEqual(cleaned_seq, (b"abc", b"xy", b"123")) + self.assertIsInstance(cleaned_seq[2], bytes) + self.assertEqual(len(cm.output), 2) + + # In mappings + with self.assertLogs("opentelemetry", level="WARNING") as cm: + cleaned_map = _clean_attribute_value({"payload": b"abcdef", "ok": b"12"}, 3) + self.assertEqual(cleaned_map, {"payload": b"abc", "ok": b"12"}) + self.assertEqual(len(cm.output), 1) From 397582e236f68eac3ceeac743a0444eed10388ca Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 28 Oct 2026 20:43:15 +0000 Subject: [PATCH 2/4] Add changelog fragment for PR 5855 Assisted-by: Gemini --- .changelog/5855.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5855.fixed diff --git a/.changelog/5855.fixed b/.changelog/5855.fixed new file mode 100644 index 00000000000..7277f0b97a0 --- /dev/null +++ b/.changelog/5855.fixed @@ -0,0 +1 @@ +`opentelemetry-api`: truncate byte array attribute values when attribute value length limit is set From f992d7e23d416a7386d8def0ab8973df9ffa9e58 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 28 Oct 2026 20:44:26 +0000 Subject: [PATCH 3/4] opentelemetry-api: simplify _clean_attribute_value signature Remove redundant max_string_value_length parameter. Assisted-by: Gemini --- opentelemetry-api/src/opentelemetry/attributes/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index f1b0e332d36..2d1ccf1babb 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -28,7 +28,6 @@ def _is_non_custom_str(key: Any) -> bool: def _clean_attribute_value( value: Mapping[str, types.AnyValue], max_value_length: int | None = ..., - max_string_value_length: int | None = ..., ) -> Mapping[str, types.AnyValue]: ... @@ -36,7 +35,6 @@ def _clean_attribute_value( def _clean_attribute_value( value: types.AnyValue, max_value_length: int | None = ..., - max_string_value_length: int | None = ..., ) -> types.AnyValue: ... @@ -69,7 +67,6 @@ def _clean_mapping( def _clean_attribute_value( value: types.AnyValue, max_value_length: int | None = None, - max_string_value_length: int | None = None, ) -> types.AnyValue: """Recursively checks if an attribute value is valid and cleans it if required. @@ -81,8 +78,6 @@ def _clean_attribute_value( Returns: The recursively cleaned AnyValue. """ - if max_value_length is None and max_string_value_length is not None: - max_value_length = max_string_value_length if isinstance(value, (NoneType, bool, int, float)): return value if isinstance(value, (bytes, bytearray)): From c0fe619e50e5c1ad52abdcfdd2f4c5d7dc3c8a2e Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 28 Oct 2026 20:46:50 +0000 Subject: [PATCH 4/4] opentelemetry-api: inline mapping cleaning and simplify truncation diff Assisted-by: Gemini --- .../src/opentelemetry/attributes/__init__.py | 74 ++++++++----------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 2d1ccf1babb..a40d8302dc3 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -27,46 +27,20 @@ def _is_non_custom_str(key: Any) -> bool: @overload def _clean_attribute_value( value: Mapping[str, types.AnyValue], - max_value_length: int | None = ..., + max_value_length: int | None, ) -> Mapping[str, types.AnyValue]: ... @overload def _clean_attribute_value( value: types.AnyValue, - max_value_length: int | None = ..., -) -> types.AnyValue: ... - - -def _clean_mapping( - mapping: Mapping[Any, types.AnyValue], max_value_length: int | None, -) -> dict[str, types.AnyValue]: - cleaned_mapping: dict[str, types.AnyValue] = {} - for key, val in mapping.items(): - if not key: - _logger.warning( - "invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.", - key, - ) - continue - # Spec says to convert unknown types to strings if possible (here and below too). - if not isinstance(key, str): - _logger.warning( - "Invalid type `%s` for attribute key `%s`, must be a str. Key's `__str__/__repr__` method will be called if it exists, otherwise the key/value pair will be dropped.", - type(key).__name__, - key, - ) - if not _is_non_custom_str(key): - continue - key = str(key) - cleaned_mapping[key] = _clean_attribute_value(val, max_value_length) - return cleaned_mapping +) -> types.AnyValue: ... def _clean_attribute_value( value: types.AnyValue, - max_value_length: int | None = None, + max_value_length: int | None, ) -> types.AnyValue: """Recursively checks if an attribute value is valid and cleans it if required. @@ -80,26 +54,39 @@ def _clean_attribute_value( """ if isinstance(value, (NoneType, bool, int, float)): return value - if isinstance(value, (bytes, bytearray)): - if max_value_length is not None and len(value) > max_value_length: - _logger.warning( - "Byte array attribute value exceeds max length of %d, truncating.", - max_value_length, - ) - value = value[:max_value_length] - return bytes(value) - if isinstance(value, str): + if isinstance(value, (str, bytes, bytearray)): if max_value_length is not None and len(value) > max_value_length: _logger.warning( - "String attribute value exceeds max length of %d, truncating.", + "%s attribute value exceeds max length of %d, truncating.", + "Byte array" if isinstance(value, (bytes, bytearray)) else "String", max_value_length, ) value = value[:max_value_length] - return value + return bytes(value) if isinstance(value, bytearray) else value if isinstance(value, Sequence): return tuple(_clean_attribute_value(v, max_value_length) for v in value) if isinstance(value, Mapping): - return _clean_mapping(value, max_value_length) + cleaned_mapping: dict[str, types.AnyValue] = {} + for key, val in value.items(): + if not key: + _logger.warning( + "invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.", + key, + ) + continue + # Spec says to convert unknown types to strings if possible (here and below too). + if not isinstance(key, str): + _logger.warning( + "Invalid type `%s` for attribute key `%s`, must be a str. Key's `__str__/__repr__` method will be called if it exists, otherwise the key/value pair will be dropped.", + type(key), + key, + ) + if _is_non_custom_str(key): + key = str(key) + else: + continue + cleaned_mapping[key] = _clean_attribute_value(val, max_value_length) + return cleaned_mapping if TYPE_CHECKING: assert_never(value) _logger.warning( @@ -107,8 +94,9 @@ def _clean_attribute_value( "Mapping or Sequence of those types. Value's __str__ method will be called if it exists, otherwise the value will be replaced with None.", type(value), ) - fallback: types.AnyValue = str(value) if _is_non_custom_str(value) else None - return fallback + if _is_non_custom_str(value): + return str(value) + return None class BoundedAttributes(MutableMapping[str, types.AnyValue]):