Bug Description
_clean_extended_attribute() in opentelemetry-api/src/opentelemetry/attributes/__init__.py uses None as a dual-purpose return value:
- Sentinel for invalid input — returned when the key is empty/non-string, or the value cannot be serialized as an
AnyValue.
- Valid attribute value —
AnyValue(None) is a legitimate attribute value per the OpenTelemetry spec, and _clean_extended_attribute_value can legitimately return None when the input is None.
The non-extended path (_clean_attribute) correctly guards against this with if value is None: return immediately after the call. The extended path (extended_attributes=True) has no such guard in either __setitem__ or _set_items. As a result:
- An invalid attribute (e.g. empty-string key
"") is written into the backing dict with value None under the invalid key, instead of being silently dropped.
self.dropped is never incremented for the rejected attribute.
Affected Code
opentelemetry-api/src/opentelemetry/attributes/__init__.py
_clean_extended_attribute (lines ~212–229) returns None for invalid input:
if not (key and isinstance(key, str)):
_logger.warning("invalid key `%s`. must be non-empty string.", key)
return None
__setitem__ (lines ~284–291) — missing guard for the extended path:
if self._extended_attributes:
value = _clean_extended_attribute(key, value, self.max_value_len)
# ← no `if value is None: return` here
else:
value = _clean_attribute(key, value, self.max_value_len)
if value is None: # ← guard exists only for non-extended path
return
Same omission in _set_items (lines ~302–307).
Reproduction
from opentelemetry.attributes import BoundedAttributes
ba = BoundedAttributes(maxlen=10, extended_attributes=True, immutable=False)
ba[""] = "hello"
# Expect: empty-string key rejected, dropped counter incremented
assert "" not in ba, f"Invalid key '' was written into dict: {dict(ba._dict)}"
assert ba.dropped == 1, f"dropped counter not incremented: {ba.dropped}"
Actual: "" is present in ba._dict with value None, and ba.dropped == 0.
Root Cause
None is an ambiguous return value: it cannot distinguish "this input was invalid, drop it" from "this is a valid AnyValue whose value happens to be None". The non-extended path sidesteps this accidentally because _clean_attribute never accepts None as a valid attribute value, so the if value is None guard works there. The extended path does not have the same assumption.
Suggested Fix
Introduce a private sentinel:
_INVALID_ATTRIBUTE = object()
Return _INVALID_ATTRIBUTE (instead of None) from _clean_extended_attribute on invalid key or unserializable value. Then guard both __setitem__ and _set_items:
if self._extended_attributes:
value = _clean_extended_attribute(key, value, self.max_value_len)
if value is _INVALID_ATTRIBUTE:
with self._lock:
self.dropped += 1
return
And in _set_items:
cv = _clean_extended_attribute(key, value, self.max_value_len)
if cv is _INVALID_ATTRIBUTE:
continue
This keeps None available as a valid AnyValue while giving the caller an unambiguous signal for rejection.
Component
opentelemetry-api — opentelemetry/attributes/__init__.py
Bug Description
_clean_extended_attribute()inopentelemetry-api/src/opentelemetry/attributes/__init__.pyusesNoneas a dual-purpose return value:AnyValue.AnyValue(None)is a legitimate attribute value per the OpenTelemetry spec, and_clean_extended_attribute_valuecan legitimately returnNonewhen the input isNone.The non-extended path (
_clean_attribute) correctly guards against this withif value is None: returnimmediately after the call. The extended path (extended_attributes=True) has no such guard in either__setitem__or_set_items. As a result:"") is written into the backing dict with valueNoneunder the invalid key, instead of being silently dropped.self.droppedis never incremented for the rejected attribute.Affected Code
opentelemetry-api/src/opentelemetry/attributes/__init__.py_clean_extended_attribute(lines ~212–229) returnsNonefor invalid input:__setitem__(lines ~284–291) — missing guard for the extended path:Same omission in
_set_items(lines ~302–307).Reproduction
Actual:
""is present inba._dictwith valueNone, andba.dropped == 0.Root Cause
Noneis an ambiguous return value: it cannot distinguish "this input was invalid, drop it" from "this is a validAnyValuewhose value happens to beNone". The non-extended path sidesteps this accidentally because_clean_attributenever acceptsNoneas a valid attribute value, so theif value is Noneguard works there. The extended path does not have the same assumption.Suggested Fix
Introduce a private sentinel:
Return
_INVALID_ATTRIBUTE(instead ofNone) from_clean_extended_attributeon invalid key or unserializable value. Then guard both__setitem__and_set_items:And in
_set_items:This keeps
Noneavailable as a validAnyValuewhile giving the caller an unambiguous signal for rejection.Component
opentelemetry-api—opentelemetry/attributes/__init__.py