Skip to content

BoundedAttributes: _clean_extended_attribute returns None for both invalid input and valid AnyValue(None), causing invalid attributes to be written instead of dropped #5432

Description

@tsushanth

Bug Description

_clean_extended_attribute() in opentelemetry-api/src/opentelemetry/attributes/__init__.py uses None as a dual-purpose return value:

  1. Sentinel for invalid input — returned when the key is empty/non-string, or the value cannot be serialized as an AnyValue.
  2. Valid attribute valueAnyValue(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-apiopentelemetry/attributes/__init__.py

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions