1# Copyright The OpenTelemetry Authors
2# SPDX-License-Identifier: Apache-2.0
3
4import copy
5import logging
6import threading
7from collections import OrderedDict
8from collections.abc import Mapping, MutableMapping, Sequence
9
10from opentelemetry.util import types
11
12# bytes are accepted as a user supplied value for attributes but
13# decoded to strings internally.
14_VALID_ATTR_VALUE_TYPES = (bool, str, bytes, int, float)
15# AnyValue possible values
16_VALID_ANY_VALUE_TYPES = (
17 type(None),
18 bool,
19 bytes,
20 int,
21 float,
22 str,
23 Sequence,
24 Mapping,
25)
26
27
28# TODO: Remove this workaround and revert to the simpler implementation
29# once Python 3.9 support is dropped (planned around May 2026).
30# This exists only to avoid issues caused by deprecated behavior in 3.9.
31def _type_name(t):
32 return getattr(t, "__name__", getattr(t, "_name", repr(t)))
33
34
35_logger = logging.getLogger(__name__)
36
37
38# pylint: disable=too-many-return-statements
39# pylint: disable=too-many-branches
40def _clean_attribute(
41 key: str, value: types.AttributeValue, max_len: int | None
42) -> types.AttributeValue | tuple[str | int | float, ...] | None:
43 """Checks if attribute value is valid and cleans it if required.
44
45 The function returns the cleaned value or None if the value is not valid.
46
47 An attribute value is valid if it is either:
48 - A primitive type: string, boolean, double precision floating
49 point (IEEE 754-1985) or integer.
50 - An array of primitive type values. The array MUST be homogeneous,
51 i.e. it MUST NOT contain values of different types.
52
53 An attribute needs cleansing if:
54 - Its length is greater than the maximum allowed length.
55 - It needs to be encoded/decoded e.g, bytes to strings.
56 """
57
58 if not (key and isinstance(key, str)):
59 _logger.warning("invalid key `%s`. must be non-empty string.", key)
60 return None
61
62 if isinstance(value, _VALID_ATTR_VALUE_TYPES):
63 if isinstance(value, bytes):
64 try:
65 value = value.decode()
66 except UnicodeDecodeError:
67 _logger.warning("Byte attribute could not be decoded.")
68 return None
69 if max_len is not None and isinstance(value, str):
70 value = value[:max_len]
71 return value
72
73 if isinstance(value, Sequence):
74 sequence_first_valid_type = None
75 cleaned_seq = []
76
77 for element in value:
78 if isinstance(element, bytes):
79 try:
80 element = element.decode()
81 except UnicodeDecodeError:
82 _logger.warning("Byte attribute could not be decoded.")
83 cleaned_seq.append(None)
84 continue
85 if max_len is not None and isinstance(element, str):
86 element = element[:max_len]
87 elif element is None:
88 cleaned_seq.append(None)
89 continue
90
91 element_type = type(element)
92 # Reject attribute value if sequence contains a value with an incompatible type.
93 if element_type not in _VALID_ATTR_VALUE_TYPES:
94 _logger.warning(
95 "Invalid type %s in attribute '%s' value sequence. Expected one of "
96 "%s or None",
97 element_type.__name__,
98 key,
99 [
100 valid_type.__name__
101 for valid_type in _VALID_ATTR_VALUE_TYPES
102 ],
103 )
104 return None
105
106 # The type of the sequence must be homogeneous. The first non-None
107 # element determines the type of the sequence
108 if sequence_first_valid_type is None:
109 sequence_first_valid_type = element_type
110 # use equality instead of isinstance as isinstance(True, int) evaluates to True
111 elif element_type != sequence_first_valid_type:
112 _logger.warning(
113 "Attribute %r mixes types %s and %s in attribute value sequence",
114 key,
115 sequence_first_valid_type.__name__,
116 type(element).__name__,
117 )
118 return None
119
120 cleaned_seq.append(element)
121
122 # Freeze mutable sequences defensively
123 return tuple(cleaned_seq)
124
125 _logger.warning(
126 "Invalid type %s for attribute '%s' value. Expected one of %s or a "
127 "sequence of those types",
128 type(value).__name__,
129 key,
130 [valid_type.__name__ for valid_type in _VALID_ATTR_VALUE_TYPES],
131 )
132 return None
133
134
135def _clean_extended_attribute_value( # pylint: disable=too-many-branches
136 value: types.AnyValue, max_len: int | None
137) -> types.AnyValue:
138 # for primitive types just return the value and eventually shorten the string length
139 if value is None or isinstance(value, _VALID_ATTR_VALUE_TYPES):
140 if max_len is not None and isinstance(value, str):
141 value = value[:max_len]
142 return value
143
144 if isinstance(value, Mapping):
145 cleaned_dict: dict[str, types.AnyValue] = {}
146 for key, element in value.items():
147 # skip invalid keys
148 if not (key and isinstance(key, str)):
149 _logger.warning(
150 "invalid key `%s`. must be non-empty string.", key
151 )
152 continue
153
154 cleaned_dict[key] = _clean_extended_attribute(
155 key=key, value=element, max_len=max_len
156 )
157
158 return cleaned_dict
159
160 if isinstance(value, Sequence):
161 sequence_first_valid_type = None
162 cleaned_seq: list[types.AnyValue] = []
163
164 for element in value:
165 if element is None:
166 cleaned_seq.append(element)
167 continue
168
169 if max_len is not None and isinstance(element, str):
170 element = element[:max_len]
171
172 element_type = type(element)
173 if element_type not in _VALID_ATTR_VALUE_TYPES:
174 element = _clean_extended_attribute_value(
175 element, max_len=max_len
176 )
177 element_type = type(element) # type: ignore
178
179 # The type of the sequence must be homogeneous. The first non-None
180 # element determines the type of the sequence
181 if sequence_first_valid_type is None:
182 sequence_first_valid_type = element_type
183 # use equality instead of isinstance as isinstance(True, int) evaluates to True
184 elif element_type != sequence_first_valid_type:
185 _logger.warning(
186 "Mixed types %s and %s in attribute value sequence",
187 sequence_first_valid_type.__name__,
188 type(element).__name__,
189 )
190 return None
191
192 cleaned_seq.append(element)
193
194 # Freeze mutable sequences defensively
195 return tuple(cleaned_seq)
196
197 # Some applications such as Django add values to log records whose types fall outside the
198 # primitive types and `_VALID_ANY_VALUE_TYPES`, i.e., they are not of type `AnyValue`.
199 # Rather than attempt to whitelist every possible instrumentation, we stringify those values here
200 # so they can still be represented as attributes, falling back to the original TypeError only if
201 # converting to string raises.
202 try:
203 return str(value)
204 except Exception:
205 raise TypeError(
206 f"Invalid type {type(value).__name__} for attribute value. "
207 f"Expected one of {[_type_name(valid_type) for valid_type in _VALID_ANY_VALUE_TYPES]} or a "
208 "sequence of those types",
209 )
210
211
212def _clean_extended_attribute(
213 key: str, value: types.AnyValue, max_len: int | None
214) -> types.AnyValue:
215 """Checks if attribute value is valid and cleans it if required.
216
217 The function returns the cleaned value or None if the value is not valid.
218
219 An attribute value is valid if it is an AnyValue.
220 An attribute needs cleansing if:
221 - Its length is greater than the maximum allowed length.
222 """
223
224 if not (key and isinstance(key, str)):
225 _logger.warning("invalid key `%s`. must be non-empty string.", key)
226 return None
227
228 try:
229 return _clean_extended_attribute_value(value, max_len=max_len)
230 except TypeError as exception:
231 _logger.warning("Attribute %s: %s", key, exception)
232 return None
233
234
235class BoundedAttributes(MutableMapping): # type: ignore
236 """An ordered dict with a fixed max capacity.
237
238 Oldest elements are dropped when the dict is full and a new element is
239 added.
240 """
241
242 def __init__(
243 self,
244 maxlen: int | None = None,
245 attributes: types._ExtendedAttributes | None = None,
246 immutable: bool = True,
247 max_value_len: int | None = None,
248 extended_attributes: bool = False,
249 ):
250 if maxlen is not None:
251 if not isinstance(maxlen, int) or maxlen < 0:
252 raise ValueError(
253 "maxlen must be valid int greater or equal to 0"
254 )
255 self.maxlen = maxlen
256 self.dropped = 0
257 self.max_value_len = max_value_len
258 self._extended_attributes = extended_attributes
259 # OrderedDict is not used until the maxlen is reached for efficiency.
260
261 self._dict: (
262 MutableMapping[str, types.AnyValue]
263 | OrderedDict[str, types.AnyValue]
264 ) = {}
265 self._lock = threading.Lock()
266 if attributes:
267 for key, value in attributes.items():
268 self[key] = value
269 self._immutable = immutable
270
271 def __repr__(self) -> str:
272 return f"{dict(self._dict)}"
273
274 def __getitem__(self, key: str) -> types.AnyValue:
275 return self._dict[key]
276
277 def __setitem__(self, key: str, value: types.AnyValue) -> None:
278 if getattr(self, "_immutable", False): # type: ignore
279 raise TypeError
280 if self.maxlen is not None and self.maxlen == 0:
281 with self._lock:
282 self.dropped += 1
283 return
284 if self._extended_attributes:
285 value = _clean_extended_attribute(key, value, self.max_value_len)
286 else:
287 value = _clean_attribute(key, value, self.max_value_len) # type: ignore
288 if value is None:
289 return
290 with self._lock:
291 self._setitem_locked(key, value)
292
293 def _set_items(self, attributes: "types._ExtendedAttributes") -> None:
294 if getattr(self, "_immutable", False): # type: ignore
295 raise TypeError
296 if self.maxlen is not None and self.maxlen == 0:
297 with self._lock:
298 self.dropped += len(attributes)
299 return
300 cleaned = []
301 for key, value in attributes.items():
302 if self._extended_attributes:
303 cv = _clean_extended_attribute(key, value, self.max_value_len)
304 else:
305 cv = _clean_attribute(key, value, self.max_value_len) # type: ignore
306 if cv is None:
307 continue
308 cleaned.append((key, cv))
309 with self._lock:
310 for key, cv in cleaned:
311 self._setitem_locked(key, cv)
312
313 def _setitem_locked(self, key: str, value: types.AnyValue) -> None:
314 if key in self._dict:
315 del self._dict[key]
316 elif self.maxlen is not None and len(self._dict) == self.maxlen:
317 if not isinstance(self._dict, OrderedDict):
318 self._dict = OrderedDict(self._dict)
319 self._dict.popitem(last=False) # type: ignore
320 self.dropped += 1
321
322 self._dict[key] = value # type: ignore
323
324 def __delitem__(self, key: str) -> None:
325 if getattr(self, "_immutable", False): # type: ignore
326 raise TypeError
327 with self._lock:
328 del self._dict[key]
329
330 def __iter__(self):
331 if self._immutable:
332 return iter(self._dict)
333 with self._lock:
334 return iter(list(self._dict))
335
336 def __len__(self) -> int:
337 return len(self._dict)
338
339 def __deepcopy__(self, memo: dict) -> "BoundedAttributes":
340 copy_ = BoundedAttributes(
341 maxlen=self.maxlen,
342 immutable=self._immutable,
343 max_value_len=self.max_value_len,
344 extended_attributes=self._extended_attributes,
345 )
346 memo[id(self)] = copy_
347 with self._lock:
348 # Assign _dict directly to avoid re-cleaning already clean values
349 # and to bypass the immutability guard in __setitem__
350 copy_._dict = copy.deepcopy(self._dict, memo)
351 copy_.dropped = self.dropped
352 return copy_
353
354 def copy(self): # type: ignore
355 return self._dict.copy() # type: ignore