Skip to content

Commit 1184b32

Browse files
committed
Handle ExtendedAttributes in BoundedAttributes
1 parent 65603ac commit 1184b32

File tree

2 files changed

+225
-15
lines changed

2 files changed

+225
-15
lines changed

opentelemetry-api/src/opentelemetry/attributes/__init__.py

Lines changed: 122 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,24 @@
1616
import threading
1717
from collections import OrderedDict
1818
from collections.abc import MutableMapping
19-
from typing import Optional, Sequence, Tuple, Union
19+
from typing import Mapping, Optional, Sequence, Tuple, Union
2020

2121
from opentelemetry.util import types
2222

2323
# bytes are accepted as a user supplied value for attributes but
2424
# decoded to strings internally.
2525
_VALID_ATTR_VALUE_TYPES = (bool, str, bytes, int, float)
26+
# AnyValue possible values
27+
_VALID_ANY_VALUE_TYPES = (
28+
type(None),
29+
bool,
30+
bytes,
31+
int,
32+
float,
33+
str,
34+
Sequence,
35+
Mapping,
36+
)
2637

2738

2839
_logger = logging.getLogger(__name__)
@@ -107,6 +118,96 @@ def _clean_attribute(
107118
return None
108119

109120

121+
def _clean_extended_attribute_value(
122+
value: types.AttributeValue, max_len: Optional[int]
123+
) -> Optional[Union[types.AttributeValue, Tuple[Union[str, int, float], ...]]]:
124+
# for primitive types just return the value and eventually shorten the string length
125+
if value is None or isinstance(value, _VALID_ATTR_VALUE_TYPES):
126+
if max_len is not None and isinstance(value, str):
127+
value = value[:max_len]
128+
return value
129+
130+
if isinstance(value, Mapping):
131+
cleaned_dict = {}
132+
for key, element in value.items():
133+
# skip invalid keys
134+
if not (key and isinstance(key, str)):
135+
_logger.warning(
136+
"invalid key `%s`. must be non-empty string.", key
137+
)
138+
continue
139+
140+
cleaned_dict[key] = _clean_extended_attribute(
141+
key=key, value=element, max_len=max_len
142+
)
143+
144+
return cleaned_dict
145+
146+
if isinstance(value, Sequence):
147+
sequence_first_valid_type = None
148+
cleaned_seq = []
149+
150+
for element in value:
151+
if element is None:
152+
cleaned_seq.append(element)
153+
continue
154+
155+
if max_len is not None and isinstance(element, str):
156+
element = element[:max_len]
157+
158+
element_type = type(element)
159+
if element_type not in _VALID_ATTR_VALUE_TYPES:
160+
return _clean_extended_attribute(element, max_len=max_len)
161+
162+
# The type of the sequence must be homogeneous. The first non-None
163+
# element determines the type of the sequence
164+
if sequence_first_valid_type is None:
165+
sequence_first_valid_type = element_type
166+
# use equality instead of isinstance as isinstance(True, int) evaluates to True
167+
elif element_type != sequence_first_valid_type:
168+
_logger.warning(
169+
"Mixed types %s and %s in attribute value sequence",
170+
sequence_first_valid_type.__name__,
171+
type(element).__name__,
172+
)
173+
return None
174+
175+
cleaned_seq.append(element)
176+
177+
# Freeze mutable sequences defensively
178+
return tuple(cleaned_seq)
179+
180+
raise TypeError(
181+
"Invalid type %s for attribute value. Expected one of %s or a "
182+
"sequence of those types",
183+
type(value).__name__,
184+
[valid_type.__name__ for valid_type in _VALID_ANY_VALUE_TYPES],
185+
)
186+
187+
188+
def _clean_extended_attribute(
189+
key: str, value: types.AttributeValue, max_len: Optional[int]
190+
) -> Optional[Union[types.AttributeValue, Tuple[Union[str, int, float], ...]]]:
191+
"""Checks if attribute value is valid and cleans it if required.
192+
193+
The function returns the cleaned value or None if the value is not valid.
194+
195+
An attribute value is valid if it is an AnyValue.
196+
An attribute needs cleansing if:
197+
- Its length is greater than the maximum allowed length.
198+
"""
199+
200+
if not (key and isinstance(key, str)):
201+
_logger.warning("invalid key `%s`. must be non-empty string.", key)
202+
return None
203+
204+
try:
205+
return _clean_extended_attribute_value(value, max_len=max_len)
206+
except TypeError as exception:
207+
_logger.warning(f"Attribute {key}: {exception}")
208+
return None
209+
210+
110211
def _clean_attribute_value(
111212
value: types.AttributeValue, limit: Optional[int]
112213
) -> Optional[types.AttributeValue]:
@@ -138,6 +239,7 @@ def __init__(
138239
attributes: types.Attributes = None,
139240
immutable: bool = True,
140241
max_value_len: Optional[int] = None,
242+
extended_attributes: bool = False,
141243
):
142244
if maxlen is not None:
143245
if not isinstance(maxlen, int) or maxlen < 0:
@@ -147,6 +249,7 @@ def __init__(
147249
self.maxlen = maxlen
148250
self.dropped = 0
149251
self.max_value_len = max_value_len
252+
self._extended_attributes = extended_attributes
150253
# OrderedDict is not used until the maxlen is reached for efficiency.
151254

152255
self._dict: Union[
@@ -173,19 +276,24 @@ def __setitem__(self, key: str, value: types.AttributeValue) -> None:
173276
self.dropped += 1
174277
return
175278

176-
value = _clean_attribute(key, value, self.max_value_len) # type: ignore
177-
if value is not None:
178-
if key in self._dict:
179-
del self._dict[key]
180-
elif (
181-
self.maxlen is not None and len(self._dict) == self.maxlen
182-
):
183-
if not isinstance(self._dict, OrderedDict):
184-
self._dict = OrderedDict(self._dict)
185-
self._dict.popitem(last=False) # type: ignore
186-
self.dropped += 1
187-
188-
self._dict[key] = value # type: ignore
279+
if self._extended_attributes:
280+
value = _clean_extended_attribute(
281+
key, value, self.max_value_len
282+
) # type: ignore
283+
else:
284+
value = _clean_attribute(key, value, self.max_value_len) # type: ignore
285+
if value is None:
286+
return
287+
288+
if key in self._dict:
289+
del self._dict[key]
290+
elif self.maxlen is not None and len(self._dict) == self.maxlen:
291+
if not isinstance(self._dict, OrderedDict):
292+
self._dict = OrderedDict(self._dict)
293+
self._dict.popitem(last=False) # type: ignore
294+
self.dropped += 1
295+
296+
self._dict[key] = value # type: ignore
189297

190298
def __delitem__(self, key: str) -> None:
191299
if getattr(self, "_immutable", False): # type: ignore

opentelemetry-api/tests/attributes/test_attributes.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
import unittest
1818
from typing import MutableSequence
1919

20-
from opentelemetry.attributes import BoundedAttributes, _clean_attribute
20+
from opentelemetry.attributes import (
21+
BoundedAttributes,
22+
_clean_attribute,
23+
_clean_extended_attribute,
24+
)
2125

2226

2327
class TestAttributes(unittest.TestCase):
@@ -89,6 +93,94 @@ def test_sequence_attr_decode(self):
8993
)
9094

9195

96+
class TestExtendedAttributes(unittest.TestCase):
97+
# pylint: disable=invalid-name
98+
def assertValid(self, value, key="k"):
99+
expected = value
100+
if isinstance(value, MutableSequence):
101+
expected = tuple(value)
102+
self.assertEqual(_clean_extended_attribute(key, value, None), expected)
103+
104+
def assertInvalid(self, value, key="k"):
105+
self.assertIsNone(_clean_extended_attribute(key, value, None))
106+
107+
def test_attribute_key_validation(self):
108+
# only non-empty strings are valid keys
109+
self.assertInvalid(1, "")
110+
self.assertInvalid(1, 1)
111+
self.assertInvalid(1, {})
112+
self.assertInvalid(1, [])
113+
self.assertInvalid(1, b"1")
114+
self.assertValid(1, "k")
115+
self.assertValid(1, "1")
116+
117+
def test_clean_extended_attribute(self):
118+
self.assertInvalid([1, 2, 3.4, "ss", 4])
119+
self.assertInvalid([{}, 1, 2, 3.4, 4])
120+
self.assertInvalid(["sw", "lf", 3.4, "ss"])
121+
self.assertInvalid([1, 2, 3.4, 5])
122+
self.assertInvalid([1, True])
123+
self.assertValid(None)
124+
self.assertValid(True)
125+
self.assertValid("hi")
126+
self.assertValid(3.4)
127+
self.assertValid(15)
128+
self.assertValid([1, 2, 3, 5])
129+
self.assertValid([1.2, 2.3, 3.4, 4.5])
130+
self.assertValid([True, False])
131+
self.assertValid(["ss", "dw", "fw"])
132+
self.assertValid([])
133+
# None in sequences are valid
134+
self.assertValid(["A", None, None])
135+
self.assertValid(["A", None, None, "B"])
136+
self.assertValid([None, None])
137+
self.assertInvalid(["A", None, 1])
138+
self.assertInvalid([None, "A", None, 1])
139+
# mappings
140+
self.assertValid({})
141+
self.assertValid({"k": "v"})
142+
143+
# test keys
144+
self.assertValid("value", "key")
145+
self.assertInvalid("value", "")
146+
self.assertInvalid("value", None)
147+
148+
def test_sequence_attr_decode(self):
149+
seq = [
150+
None,
151+
b"Content-Disposition",
152+
b"Content-Type",
153+
b"\x81",
154+
b"Keep-Alive",
155+
]
156+
self.assertEqual(
157+
_clean_extended_attribute("headers", seq, None), tuple(seq)
158+
)
159+
160+
def test_mapping(self):
161+
mapping = {
162+
"": "invalid",
163+
b"bytes": "invalid",
164+
"none": {"": "invalid"},
165+
"valid_primitive": "str",
166+
"valid_sequence": ["str"],
167+
"invalid_sequence": ["str", 1],
168+
"valid_mapping": {"str": 1},
169+
"invalid_mapping": {"": 1},
170+
}
171+
expected = {
172+
"none": {},
173+
"valid_primitive": "str",
174+
"valid_sequence": ("str",),
175+
"invalid_sequence": None,
176+
"valid_mapping": {"str": 1},
177+
"invalid_mapping": {},
178+
}
179+
self.assertEqual(
180+
_clean_extended_attribute("headers", mapping, None), expected
181+
)
182+
183+
92184
class TestBoundedAttributes(unittest.TestCase):
93185
# pylint: disable=consider-using-dict-items
94186
base = {
@@ -196,3 +288,13 @@ def test_locking(self):
196288

197289
for num in range(100):
198290
self.assertEqual(bdict[str(num)], num)
291+
292+
def test_extended_attributes(self):
293+
bdict = BoundedAttributes(extended_attributes=True, immutable=False)
294+
with unittest.mock.patch(
295+
"opentelemetry.attributes._clean_extended_attribute",
296+
return_value="mock_value",
297+
) as clean_extended_attribute_mock:
298+
bdict["key"] = "value"
299+
300+
clean_extended_attribute_mock.assert_called_once()

0 commit comments

Comments
 (0)