1# Copyright The OpenTelemetry Authors
2# SPDX-License-Identifier: Apache-2.0
3
4# pyright: reportUnnecessaryIsInstance=false
5
6import copy
7import logging
8import threading
9from collections.abc import Mapping, MutableMapping, Sequence
10from types import NoneType
11from typing import TYPE_CHECKING, Any, overload
12
13from typing_extensions import assert_never, deprecated
14
15from opentelemetry.util import types
16
17_logger = logging.getLogger(__name__)
18
19
20# Calling str(x) will use an object's `__str__` method if it exists, otherwise it will use it's `__repr__` method.
21# If neither is defined it uses the base class's `object.__repr__` method, which returns a string that is hard to understand.
22# So in that case we drop the key/value pair.
23def _is_non_custom_str(key: Any) -> bool:
24 return type(key).__str__ is not object.__str__ or type(key).__repr__ is not object.__repr__
25
26
27@overload
28def _clean_attribute_value(
29 value: Mapping[str, types.AnyValue],
30 max_string_value_length: int | None,
31) -> Mapping[str, types.AnyValue]: ...
32
33
34@overload
35def _clean_attribute_value(
36 value: types.AnyValue,
37 max_string_value_length: int | None,
38) -> types.AnyValue: ...
39
40
41def _clean_attribute_value(
42 value: types.AnyValue,
43 max_string_value_length: int | None,
44) -> types.AnyValue:
45 """Recursively checks if an attribute value is valid and cleans it if required.
46
47 String values are truncated to max_string_value_length if provided.
48 Anything that isn't of `types.AnyValue`, we attempt to cast to `str`.
49 If this fails, the value is replaced with None. Sequence's are converted to tuples and mappings
50 are copied into new dicts.
51
52 Returns:
53 The recursively cleaned AnyValue.
54 """
55 if isinstance(value, (NoneType, bool, int, float, bytes)):
56 return value
57 if isinstance(value, str):
58 if max_string_value_length is not None and len(value) > max_string_value_length:
59 _logger.warning(
60 "String attribute value exceeds max length of %d, truncating.",
61 max_string_value_length,
62 )
63 value = value[:max_string_value_length]
64 return value
65 if isinstance(value, Sequence):
66 return tuple(_clean_attribute_value(v, max_string_value_length) for v in value)
67 if isinstance(value, Mapping):
68 cleaned_mapping: dict[str, types.AnyValue] = {}
69 for key, val in value.items():
70 if not key:
71 _logger.warning(
72 "invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.",
73 key,
74 )
75 continue
76 # Spec says to convert unknown types to strings if possible (here and below too).
77 if not isinstance(key, str):
78 _logger.warning(
79 "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.",
80 type(key),
81 key,
82 )
83 if _is_non_custom_str(key):
84 key = str(key)
85 else:
86 continue
87 cleaned_mapping[key] = _clean_attribute_value(val, max_string_value_length)
88 return cleaned_mapping
89 if TYPE_CHECKING:
90 assert_never(value)
91 _logger.warning(
92 "Invalid type `%s` for attribute value. Expected one of bool, str, None, bytes, int, float or a "
93 "Mapping or Sequence of those types. Value's __str__ method will be called if it exists, otherwise the value will be replaced with None.",
94 type(value),
95 )
96 if _is_non_custom_str(value):
97 return str(value)
98 return None
99
100
101class BoundedAttributes(MutableMapping[str, types.AnyValue]):
102 """A dict with a fixed max capacity which cleans and potentially drops values to ensure they are valid attribute values.
103
104 Args:
105 maxlen: The maximum number of attributes to store, use None for no limit.
106 attributes: The initial attributes to store.
107 immutable: Defaults to true. Whether to allow adding/removing of attributes after the initialisation of the instance.
108 max_value_len: The maximum length of string values, use None for no limit.
109 extended_attributes: Deprecated. Kept for backwards compatibility. Extended attributes are now always used for attributes everywhere.
110
111 When the dict is full and a new element is added, the oldest element is dropped. Attributes are made to be immutable when set in this container.
112 So passing a mutable list as an attribute value, and then mutating it after will not change it's value in this container.
113 """
114
115 @overload
116 def __init__(
117 self,
118 maxlen: int | None = None,
119 attributes: types.Attributes = None,
120 immutable: bool = True,
121 max_value_len: int | None = None,
122 ) -> None: ...
123
124 @overload
125 @deprecated(
126 "Creating BoundedAttributes with `extended_attributes` set is deprecated. "
127 "The `extended_attributes` param is no longer used and will be removed "
128 "in a future release. Extended attributes are now always used for attributes everywhere."
129 )
130 def __init__(
131 self,
132 maxlen: int | None = None,
133 attributes: types.Attributes = None,
134 immutable: bool = True,
135 max_value_len: int | None = None,
136 extended_attributes: bool = False,
137 ) -> None: ...
138
139 def __init__(
140 self,
141 maxlen: int | None = None,
142 attributes: types.Attributes = None,
143 immutable: bool = True,
144 max_value_len: int | None = None,
145 extended_attributes: bool = False,
146 ) -> None:
147 if maxlen is not None and maxlen < 0:
148 raise ValueError("maxlen must be valid int greater or equal to 0")
149 if max_value_len is not None and max_value_len < 0:
150 raise ValueError("max_value_len must be valid int greater or equal to 0")
151 self._dict: dict[str, types.AnyValue] = {}
152 self.maxlen = maxlen
153 self.dropped = 0
154 self.max_value_len = max_value_len
155 self._lock = threading.Lock()
156 # setting False before adding items
157 self._immutable = False
158 if attributes:
159 self._set_items(attributes)
160 self._immutable = immutable
161
162 def __repr__(self) -> str:
163 return f"{dict(self._dict)}"
164
165 def __getitem__(self, key: str) -> types.AnyValue:
166 return self._dict[key]
167
168 def _raise_if_immutable(self) -> None:
169 if self._immutable:
170 raise TypeError("Cannot mutate immutable BoundedAttributes")
171
172 def __setitem__(self, key: str, value: types.AnyValue) -> None:
173 self._raise_if_immutable()
174 if self.maxlen is not None and self.maxlen == 0:
175 with self._lock:
176 self.dropped += 1
177 return
178 if not key or not isinstance(key, str):
179 _logger.warning(
180 "invalid key `%s`. must be non-empty string. Dropping key from attributes.",
181 key,
182 )
183 with self._lock:
184 self.dropped += 1
185 return
186 cleaned = _clean_attribute_value(value, self.max_value_len)
187 with self._lock:
188 self._setitem_locked(key, cleaned)
189
190 def _set_items(self, attributes: Mapping[str, types.AnyValue]) -> None:
191 self._raise_if_immutable()
192 if self.maxlen is not None and self.maxlen == 0:
193 with self._lock:
194 self.dropped += len(attributes)
195 return
196 cleaned_attributes: Mapping[str, types.AnyValue] = _clean_attribute_value(attributes, self.max_value_len)
197 with self._lock:
198 self.dropped += len(attributes) - len(cleaned_attributes)
199 for key, value in cleaned_attributes.items():
200 self._setitem_locked(key, value)
201
202 def _setitem_locked(self, key: str, value: types.AnyValue) -> None:
203 if key in self._dict:
204 del self._dict[key]
205 if self.maxlen is not None and len(self._dict) >= self.maxlen:
206 _logger.warning(
207 "Attributes dict is full. Dropping the oldest key-value pair from attributes to make space for the new key-value pair.",
208 )
209 # Dictionaries are insertion ordered in Python, this is the recommended way to get the oldest value.
210 del self._dict[next(iter(self._dict.keys()))]
211 self.dropped += 1
212
213 self._dict[key] = value
214
215 def __delitem__(self, key: str) -> None:
216 self._raise_if_immutable()
217 del self._dict[key]
218
219 def __iter__(self):
220 if self._immutable:
221 return iter(self._dict)
222 with self._lock:
223 return iter(list(self._dict))
224
225 def __len__(self) -> int:
226 return len(self._dict)
227
228 def __deepcopy__(self, memo: dict[int, Any]) -> "BoundedAttributes":
229 copy_ = BoundedAttributes(
230 maxlen=self.maxlen,
231 immutable=self._immutable,
232 max_value_len=self.max_value_len,
233 )
234 memo[id(self)] = copy_
235 with self._lock:
236 # Assign _dict directly to avoid re-cleaning already clean values
237 # and to bypass the immutability guard in __setitem__
238 copy_._dict = copy.deepcopy(self._dict, memo)
239 copy_.dropped = self.dropped
240 return copy_
241
242 def copy(self) -> MutableMapping[str, types.AnyValue]:
243 return self._dict.copy()