Skip to content
Open
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
64 changes: 42 additions & 22 deletions opentelemetry-api/src/opentelemetry/attributes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import copy
import logging
import sys
import threading
from collections.abc import Mapping, MutableMapping, Sequence
from types import NoneType
Expand All @@ -27,41 +28,41 @@ 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_string_value_length: int,
) -> Mapping[str, types.AnyValue]: ...


@overload
def _clean_attribute_value(
value: types.AnyValue,
max_string_value_length: int | None,
max_string_value_length: int,
) -> types.AnyValue: ...


def _clean_attribute_value(
value: types.AnyValue,
max_string_value_length: int | None,
max_string_value_length: int,
) -> 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 values are truncated to max_string_value_length.
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.

Returns:
The recursively cleaned AnyValue.
"""
if isinstance(value, (NoneType, bool, int, float, bytes)):
return value
if isinstance(value, str):
if max_string_value_length is not None and len(value) > max_string_value_length:
if len(value) > max_string_value_length:
_logger.warning(
"String attribute value exceeds max length of %d, truncating.",
max_string_value_length,
)
value = value[:max_string_value_length]
return value
if isinstance(value, (NoneType, bool, int, float, bytes)):
return value
if isinstance(value, Sequence):
return tuple(_clean_attribute_value(v, max_string_value_length) for v in value)
if isinstance(value, Mapping):
Expand Down Expand Up @@ -149,9 +150,9 @@ def __init__(
if max_value_len is not None and max_value_len < 0:
raise ValueError("max_value_len must be valid int greater or equal to 0")
self._dict: dict[str, types.AnyValue] = {}
self.maxlen = maxlen
self.maxlen: int = sys.maxsize if maxlen is None else maxlen
self.dropped = 0
self.max_value_len = max_value_len
self.max_value_len: int = sys.maxsize if max_value_len is None else max_value_len
self._lock = threading.Lock()
# The spec allows the limit log to be emitted at most once per record, so track
# it separately from ``dropped``, which also counts other kinds of drops.
Expand All @@ -168,13 +169,10 @@ def __repr__(self) -> str:
def __getitem__(self, key: str) -> types.AnyValue:
return self._dict[key]

def _raise_if_immutable(self) -> None:
def __setitem__(self, key: str, value: types.AnyValue) -> None:
if self._immutable:
raise TypeError("Cannot mutate immutable BoundedAttributes")

def __setitem__(self, key: str, value: types.AnyValue) -> None:
self._raise_if_immutable()
if self.maxlen is not None and self.maxlen == 0:
if self.maxlen == 0:
with self._lock:
self.dropped += 1
return
Expand All @@ -191,21 +189,42 @@ def __setitem__(self, key: str, value: types.AnyValue) -> None:
self._setitem_locked(key, cleaned)

def _set_items(self, attributes: Mapping[str, types.AnyValue]) -> None:
self._raise_if_immutable()
if self.maxlen is not None and self.maxlen == 0:
if self._immutable:
raise TypeError("Cannot mutate immutable BoundedAttributes")
if self.maxlen == 0:
with self._lock:
self.dropped += len(attributes)
return
cleaned_attributes: Mapping[str, types.AnyValue] = _clean_attribute_value(attributes, self.max_value_len)
with self._lock:
self.dropped += len(attributes) - len(cleaned_attributes)
for key, value in cleaned_attributes.items():
self._setitem_locked(key, value)
for key, value in attributes.items():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep input iteration and cleanup outside this lock? For a span with existing attributes, span.set_attributes(span.attributes) tries to acquire the same lock twice and hangs.

if not key:
_logger.warning(
"invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.",
key,
)
self.dropped += 1
continue
# Spec says to convert unknown types to strings if possible.
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:
self.dropped += 1
continue
self._setitem_locked(key, _clean_attribute_value(value, self.max_value_len))

def _setitem_locked(self, key: str, value: types.AnyValue) -> None:
if len(self._dict) < self.maxlen:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating an existing attribute below capacity no longer moves it to the end, so a recently updated attribute can now be dropped instead of an older one. Could we preserve the previous eviction behavior?

self._dict[key] = value
return
if key in self._dict:
del self._dict[key]
if self.maxlen is not None and len(self._dict) >= self.maxlen:
if len(self._dict) >= self.maxlen:
if not self._dropped_logged:
self._dropped_logged = True
_logger.warning(
Expand All @@ -218,7 +237,8 @@ def _setitem_locked(self, key: str, value: types.AnyValue) -> None:
self._dict[key] = value

def __delitem__(self, key: str) -> None:
self._raise_if_immutable()
if self._immutable:
raise TypeError("Cannot mutate immutable BoundedAttributes")
del self._dict[key]

def __iter__(self):
Expand Down
4 changes: 4 additions & 0 deletions opentelemetry-api/src/opentelemetry/trace/span.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ def _is_valid_pair(key: str, value: str) -> bool:
class Span(abc.ABC):
"""A span represents a single operation within a trace."""

__slots__ = ()

@abc.abstractmethod
def end(self, end_time: int | None = None) -> None:
"""Sets the current time as the span's end time.
Expand Down Expand Up @@ -450,6 +452,8 @@ class SpanContext(tuple[int, int, bool, "TraceFlags", "TraceState", bool]):
trace_state: Tracing-system-specific info to propagate.
"""

__slots__ = ()

def __new__(
cls,
trace_id: int,
Expand Down
15 changes: 8 additions & 7 deletions opentelemetry-api/tests/attributes/test_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import copy
import logging
import sys
import threading
import unittest
import unittest.mock
Expand Down Expand Up @@ -33,19 +34,19 @@ def test_clean_attribute_value_with_various_params(self):
# A python object that isn't a primitive and has no string/repr method is converted to None.
with self.assertLogs("opentelemetry", level="WARNING") as cm:
self.assertEqual(
_clean_attribute_value(_NoStrNoReprObject(), None),
_clean_attribute_value(_NoStrNoReprObject(), sys.maxsize),
None,
)
self.assertEqual(len(cm.output), 1)
self.assertIn("Expected one of bool, str, None, bytes, int, float", cm.output[0])

valid_primitive_sequence = [1, 2.2, None, "cookie"]
self.assertEqual(
_clean_attribute_value(valid_primitive_sequence, None),
_clean_attribute_value(valid_primitive_sequence, sys.maxsize),
tuple(valid_primitive_sequence),
)
for valid_primitive in valid_primitive_sequence:
self.assertEqual(_clean_attribute_value(valid_primitive, None), valid_primitive)
self.assertEqual(_clean_attribute_value(valid_primitive, sys.maxsize), valid_primitive)

self.assertEqual(_clean_attribute_value(b"hello", 4), b"hello")

Expand All @@ -57,13 +58,13 @@ def test_clean_attribute_value_with_various_params(self):

# Sequence of different types of values. List converted to tuple.
self.assertEqual(
_clean_attribute_value(["a", 2, _NoStrNoReprObject(), None, b"\xff"], None),
_clean_attribute_value(["a", 2, _NoStrNoReprObject(), None, b"\xff"], sys.maxsize),
("a", 2, None, None, b"\xff"),
)

# non-str key in map... will be converted to string
with self.assertLogs("opentelemetry", level="WARNING") as cm:
self.assertEqual(_clean_attribute_value({2.2: 4.4}, None), {"2.2": 4.4})
self.assertEqual(_clean_attribute_value({2.2: 4.4}, sys.maxsize), {"2.2": 4.4})
self.assertEqual(len(cm.output), 1)
self.assertIn(
"Invalid type",
Expand All @@ -81,7 +82,7 @@ def test_clean_attribute_value_with_various_params(self):
"d": [2, 3],
"bytes": b"\xff",
},
None,
sys.maxsize,
),
{"a": 1, "c": 3, "d": (2, 3), "bytes": b"\xff"},
)
Expand Down Expand Up @@ -257,7 +258,7 @@ def __str__(self):

self.assertEqual(
"<DummyWSGIRequest method=GET path=/example/>",
_clean_attribute_value(DummyWSGIRequest(), None),
_clean_attribute_value(DummyWSGIRequest(), sys.maxsize),
)

def test_deepcopy(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import sys
from collections.abc import Mapping
from dataclasses import dataclass
from logging import getLogger
Expand Down Expand Up @@ -44,7 +45,7 @@ def __post_init__(self) -> None:
object.__setattr__(
self,
"attributes",
_clean_attribute_value(self.attributes, None),
_clean_attribute_value(self.attributes, sys.maxsize),
)
else:
_logger.warning(
Expand Down
Loading
Loading