From 9cf8a897529a38ee8660510729e95196fec56da0 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 24 Feb 2026 18:13:04 -0500 Subject: [PATCH 1/7] feat: add deepcopy implementation for BoundedAttributes --- .../src/opentelemetry/attributes/__init__.py | 14 +++++ .../tests/attributes/test_attributes.py | 27 +++++++++ opentelemetry-sdk/tests/trace/test_trace.py | 58 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 5116c2fdd8a..816a4ddb668 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import logging import threading from collections import OrderedDict @@ -318,5 +319,18 @@ def __iter__(self): # type: ignore def __len__(self) -> int: return len(self._dict) + def __deepcopy__(self, memo: dict) -> "BoundedAttributes": + with self._lock: + attributes = copy.deepcopy(self._dict, memo) + copy_ = BoundedAttributes( + self.maxlen, + attributes, + self._immutable, + self.max_value_len, + self._extended_attributes, + ) + memo[id(self)] = copy_ + return copy_ + def copy(self): # type: ignore return self._dict.copy() # type: ignore diff --git a/opentelemetry-api/tests/attributes/test_attributes.py b/opentelemetry-api/tests/attributes/test_attributes.py index 8cb6f35fbce..7c247ba3170 100644 --- a/opentelemetry-api/tests/attributes/test_attributes.py +++ b/opentelemetry-api/tests/attributes/test_attributes.py @@ -14,6 +14,7 @@ # type: ignore +import copy import unittest from typing import MutableSequence @@ -320,3 +321,29 @@ def __str__(self): self.assertEqual( "", cleaned_value ) + + def test_deepcopy(self): + bdict = BoundedAttributes(4, self.base, immutable=False) + bdict_copy = copy.deepcopy(bdict) + + for key in bdict_copy: + self.assertEqual(bdict_copy[key], bdict[key]) + + self.assertEqual(bdict_copy.dropped, bdict.dropped) + self.assertEqual(bdict_copy.maxlen, bdict.maxlen) + self.assertEqual(bdict_copy.max_value_len, bdict.max_value_len) + + bdict_copy["name"] = "Bob" + self.assertNotEqual(bdict_copy["name"], bdict["name"]) + + bdict["age"] = 99 + self.assertNotEqual(bdict["age"], bdict_copy["age"]) + + def test_deepcopy_preserves_immutability(self): + bdict = BoundedAttributes( + maxlen=4, attributes=self.base, immutable=True + ) + bdict_copy = copy.deepcopy(bdict) + + with self.assertRaises(TypeError): + bdict_copy["invalid"] = "invalid" diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index e9a59c6cde9..1a435d8ca5b 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -15,6 +15,7 @@ # pylint: disable=too-many-lines # pylint: disable=no-member +import copy import shutil import subprocess import unittest @@ -708,6 +709,63 @@ def test_link_dropped_attributes(self): ) self.assertEqual(link2.dropped_attributes, 0) + def test_deepcopy(self): + context = trace_api.SpanContext( + trace_id=0x000000000000000000000000DEADBEEF, + span_id=0x00000000DEADBEF0, + is_remote=False, + ) + attributes = BoundedAttributes( + 10, {"key1": "value1", "key2": 42}, immutable=False + ) + events = [ + trace.Event("event1", {"ekey": "evalue"}), + trace.Event("event2", {"ekey2": "evalue2"}), + ] + links = [ + trace_api.Link( + context=trace_api.INVALID_SPAN_CONTEXT, + attributes={"lkey": "lvalue"}, + ) + ] + + span = trace.ReadableSpan( + name="test-span", + context=context, + attributes=attributes, + events=events, + links=links, + status=Status(StatusCode.OK), + ) + + span_copy = copy.deepcopy(span) + + self.assertEqual(span_copy.name, span.name) + self.assertEqual(span_copy.status.status_code, span.status.status_code) + self.assertEqual(span_copy.context.trace_id, span.context.trace_id) + self.assertEqual(span_copy.context.span_id, span.context.span_id) + + self.assertEqual(dict(span_copy.attributes), dict(span.attributes)) + attributes["key1"] = "mutated" + self.assertNotEqual( + span_copy.attributes["key1"], span.attributes["key1"] + ) + + self.assertEqual(len(span_copy.events), len(span.events)) + events[0] = trace.Event("mutated-event", {"mutated": "value"}) + self.assertNotEqual(span_copy.events[0].name, events[0].name) + self.assertEqual(span_copy.events[0].name, "event1") + + self.assertEqual(len(span_copy.links), len(span.links)) + self.assertEqual( + span_copy.links[0].attributes, span.links[0].attributes + ) + links[0] = trace_api.Link( + context=trace_api.INVALID_SPAN_CONTEXT, + attributes={"mutated": "link"}, + ) + self.assertNotIn("mutated", span_copy.links[0].attributes) + class DummyError(Exception): pass From 12e94fad95959b72549deae61efc5b3a1f25af77 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 24 Feb 2026 18:17:36 -0500 Subject: [PATCH 2/7] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 540f7b9d347..30d984fb733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4973](https://github.com/open-telemetry/opentelemetry-python/pull/4973)) - `opentelemetry-exporter-prometheus`: Fix metric name prefix ([#4895](https://github.com/open-telemetry/opentelemetry-python/pull/4895)) +- `opentelemetry-api`: Add deepcopy support for `BoundedAttributes` + ([#4934](https://github.com/open-telemetry/opentelemetry-python/pull/4934)) ## Version 1.40.0/0.61b0 (2026-03-04) From 2faa1f8fee29ad79a38581875733df7a67bcfe94 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 8 Mar 2026 18:01:57 -0400 Subject: [PATCH 3/7] add implementation for BoundedList --- .../src/opentelemetry/attributes/__init__.py | 2 ++ .../tests/attributes/test_attributes.py | 1 + .../src/opentelemetry/sdk/util/__init__.py | 10 ++++++++- .../src/opentelemetry/sdk/util/__init__.pyi | 2 ++ opentelemetry-sdk/tests/test_util.py | 18 +++++++++++++++ opentelemetry-sdk/tests/trace/test_trace.py | 22 ++++++++++++------- 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 816a4ddb668..80a4721905c 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -322,6 +322,7 @@ def __len__(self) -> int: def __deepcopy__(self, memo: dict) -> "BoundedAttributes": with self._lock: attributes = copy.deepcopy(self._dict, memo) + dropped = self.dropped copy_ = BoundedAttributes( self.maxlen, attributes, @@ -329,6 +330,7 @@ def __deepcopy__(self, memo: dict) -> "BoundedAttributes": self.max_value_len, self._extended_attributes, ) + copy_.dropped = dropped memo[id(self)] = copy_ return copy_ diff --git a/opentelemetry-api/tests/attributes/test_attributes.py b/opentelemetry-api/tests/attributes/test_attributes.py index 7c247ba3170..40d04fbe7d4 100644 --- a/opentelemetry-api/tests/attributes/test_attributes.py +++ b/opentelemetry-api/tests/attributes/test_attributes.py @@ -324,6 +324,7 @@ def __str__(self): def test_deepcopy(self): bdict = BoundedAttributes(4, self.base, immutable=False) + bdict.dropped = 10 bdict_copy = copy.deepcopy(bdict) for key in bdict_copy: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py index 72f92fc25cc..030fd29252f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import datetime import threading from collections import deque from collections.abc import MutableMapping, Sequence -from typing import Optional +from typing import Any, Optional from typing_extensions import deprecated @@ -55,6 +56,13 @@ def __init__(self, maxlen: Optional[int]): self._dq = deque(maxlen=maxlen) # type: deque self._lock = threading.Lock() + def __deepcopy__(self, memo): + copy_ = BoundedList(0) + with self._lock: + copy_.dropped = self.dropped + copy_._dq = copy.deepcopy(self._dq, memo) + return copy_ + def __repr__(self): return f"{type(self).__name__}({list(self._dq)}, maxlen={self._dq.maxlen})" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi index b0ff32d473d..00d1e7cfd51 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi @@ -13,6 +13,7 @@ # limitations under the License. from typing import ( + Any, Iterable, Iterator, Mapping, @@ -44,6 +45,7 @@ class BoundedList(Sequence[_T]): dropped: int def __init__(self, maxlen: Optional[int]): ... + def __deepcopy__(self, memo: dict[int, Any]) -> BoundedList[_T]: ... def insert(self, index: int, value: _T) -> None: ... @overload def __getitem__(self, i: int) -> _T: ... diff --git a/opentelemetry-sdk/tests/test_util.py b/opentelemetry-sdk/tests/test_util.py index db6d3b57873..3400e1adeeb 100644 --- a/opentelemetry-sdk/tests/test_util.py +++ b/opentelemetry-sdk/tests/test_util.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import unittest from opentelemetry.sdk.util import BoundedList @@ -142,3 +143,20 @@ def test_no_limit(self): for num in range(100): self.assertEqual(blist[num], num) + + def test_deepcopy(self): + blist = BoundedList(maxlen=10) + blist.append(1) + blist.append([2, 3]) + blist.dropped = 5 + + blist_copy = copy.deepcopy(blist) + + self.assertIsNot(blist, blist_copy) + self.assertIsNot(blist._dq, blist_copy._dq) + self.assertIsNot(blist._lock, blist_copy._lock) + self.assertEqual(list(blist), list(blist_copy)) + self.assertEqual(blist.dropped, blist_copy.dropped) + self.assertEqual(blist._dq.maxlen, blist_copy._dq.maxlen) + self.assertIsNot(blist[1], blist_copy[1]) + self.assertEqual(blist[1], blist_copy[1]) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 1a435d8ca5b..d264c43c138 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -59,7 +59,7 @@ ParentBased, StaticSampler, ) -from opentelemetry.sdk.util import BoundedDict, ns_to_iso_str +from opentelemetry.sdk.util import BoundedDict, BoundedList, ns_to_iso_str from opentelemetry.sdk.util.instrumentation import InstrumentationInfo from opentelemetry.test.spantestutil import ( get_span_with_dropped_attributes_events_links, @@ -718,10 +718,14 @@ def test_deepcopy(self): attributes = BoundedAttributes( 10, {"key1": "value1", "key2": 42}, immutable=False ) - events = [ - trace.Event("event1", {"ekey": "evalue"}), - trace.Event("event2", {"ekey2": "evalue2"}), - ] + events = BoundedList(10) + events.extend( + ( + trace.Event("event1", {"ekey": "evalue"}), + trace.Event("event2", {"ekey2": "evalue2"}), + ) + ) + links = [ trace_api.Link( context=trace_api.INVALID_SPAN_CONTEXT, @@ -752,9 +756,11 @@ def test_deepcopy(self): ) self.assertEqual(len(span_copy.events), len(span.events)) - events[0] = trace.Event("mutated-event", {"mutated": "value"}) - self.assertNotEqual(span_copy.events[0].name, events[0].name) - self.assertEqual(span_copy.events[0].name, "event1") + self.assertIsNot(span_copy.events, span.events) + self.assertEqual(span_copy.events[0].name, span.events[0].name) + self.assertEqual( + span_copy.events[0].attributes, span.events[0].attributes + ) self.assertEqual(len(span_copy.links), len(span.links)) self.assertEqual( From efee37767874ccd0564e13f9f8f413ce030b722f Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 8 Mar 2026 18:07:31 -0400 Subject: [PATCH 4/7] fix lint errors --- opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py | 2 +- opentelemetry-sdk/tests/test_util.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py index 030fd29252f..8f884fdc951 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py @@ -17,7 +17,7 @@ import threading from collections import deque from collections.abc import MutableMapping, Sequence -from typing import Any, Optional +from typing import Optional from typing_extensions import deprecated diff --git a/opentelemetry-sdk/tests/test_util.py b/opentelemetry-sdk/tests/test_util.py index 3400e1adeeb..cd255d5992b 100644 --- a/opentelemetry-sdk/tests/test_util.py +++ b/opentelemetry-sdk/tests/test_util.py @@ -144,6 +144,7 @@ def test_no_limit(self): for num in range(100): self.assertEqual(blist[num], num) + # pylint: disable=protected-access def test_deepcopy(self): blist = BoundedList(maxlen=10) blist.append(1) From c537e6b26811309deacb5c571a23ffba7de62820 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 10 Mar 2026 14:54:26 -0400 Subject: [PATCH 5/7] fix usage of 'memo' in '__deepcopy__' implementation --- .../src/opentelemetry/attributes/__init__.py | 16 +++++++--------- .../src/opentelemetry/sdk/util/__init__.py | 1 + 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 80a4721905c..4975db27b10 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -320,18 +320,16 @@ def __len__(self) -> int: return len(self._dict) def __deepcopy__(self, memo: dict) -> "BoundedAttributes": - with self._lock: - attributes = copy.deepcopy(self._dict, memo) - dropped = self.dropped copy_ = BoundedAttributes( - self.maxlen, - attributes, - self._immutable, - self.max_value_len, - self._extended_attributes, + maxlen=self.maxlen, + immutable=self._immutable, + max_value_len=self.max_value_len, + extended_attributes=self._extended_attributes, ) - copy_.dropped = dropped memo[id(self)] = copy_ + with self._lock: + copy_._dict = copy.deepcopy(self._dict, memo) + copy_.dropped = self.dropped return copy_ def copy(self): # type: ignore diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py index 8f884fdc951..4adf4ed4599 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py @@ -58,6 +58,7 @@ def __init__(self, maxlen: Optional[int]): def __deepcopy__(self, memo): copy_ = BoundedList(0) + memo[id(self)] = copy_ with self._lock: copy_.dropped = self.dropped copy_._dq = copy.deepcopy(self._dq, memo) From 9451b892ba16021b9ecf4339f56a38c63b614bc2 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 10 Mar 2026 15:00:51 -0400 Subject: [PATCH 6/7] update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d984fb733..b79d9a1e156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4973](https://github.com/open-telemetry/opentelemetry-python/pull/4973)) - `opentelemetry-exporter-prometheus`: Fix metric name prefix ([#4895](https://github.com/open-telemetry/opentelemetry-python/pull/4895)) -- `opentelemetry-api`: Add deepcopy support for `BoundedAttributes` +- `opentelemetry-api`, `opentelemetry-sdk`: Add deepcopy support for `BoundedAttributes` ([#4934](https://github.com/open-telemetry/opentelemetry-python/pull/4934)) ## Version 1.40.0/0.61b0 (2026-03-04) From 1ca84f7d483dfb5a22dd580d2fd04f2ccc7355cb Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 11 Mar 2026 21:33:14 -0400 Subject: [PATCH 7/7] update CHANGELOG.md and add comment to BoundedAttributes __deepcopy__ implementation --- CHANGELOG.md | 2 +- opentelemetry-api/src/opentelemetry/attributes/__init__.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b79d9a1e156..ccde93fb14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4973](https://github.com/open-telemetry/opentelemetry-python/pull/4973)) - `opentelemetry-exporter-prometheus`: Fix metric name prefix ([#4895](https://github.com/open-telemetry/opentelemetry-python/pull/4895)) -- `opentelemetry-api`, `opentelemetry-sdk`: Add deepcopy support for `BoundedAttributes` +- `opentelemetry-api`, `opentelemetry-sdk`: Add deepcopy support for `BoundedAttributes` and `BoundedList` ([#4934](https://github.com/open-telemetry/opentelemetry-python/pull/4934)) ## Version 1.40.0/0.61b0 (2026-03-04) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 4975db27b10..ad6dab59350 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -328,6 +328,8 @@ def __deepcopy__(self, memo: dict) -> "BoundedAttributes": ) memo[id(self)] = copy_ with self._lock: + # Assign _dict directly to avoid re-cleaning already clean values + # and to bypass the immutability guard in __setitem__ copy_._dict = copy.deepcopy(self._dict, memo) copy_.dropped = self.dropped return copy_