From ead9de3534bd3d7b4f5f971e4521c953d735c4a7 Mon Sep 17 00:00:00 2001 From: tejasae-afk Date: Wed, 15 Apr 2026 08:49:39 -0400 Subject: [PATCH] sdk/metrics: copy attributes dict in consume_measurement to prevent mutation When a caller retains a reference to the attributes dict passed to counter.add() (or any instrument record/add call), mutating that dict after the call would silently corrupt the attributes stored on the aggregation and subsequently on exported data points. The fix copies the dict at the point where it is first stored as the canonical attributes for a new aggregation bucket, so downstream mutations by the caller have no effect. Fixes #4610 --- CHANGELOG.md | 2 + .../_internal/_view_instrument_match.py | 2 +- .../metrics/test_view_instrument_match.py | 45 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 721db3b6127..d67f36aa894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: Fix mutable attributes reference in metrics: attributes passed to instrument `add`/`record` are now copied so that subsequent mutations to the caller's dict do not affect recorded data points + ([#4610](https://github.com/open-telemetry/opentelemetry-python/issues/4610)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py index 96a77fa6b15..3ee4c451199 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/_view_instrument_match.py @@ -98,7 +98,7 @@ def consume_measurement( if key in self._view._attribute_keys: attributes[key] = value elif measurement.attributes is not None: - attributes = measurement.attributes + attributes = dict(measurement.attributes) else: attributes = {} diff --git a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py index 38d36758f39..8ef1bc4d06d 100644 --- a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py +++ b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py @@ -266,6 +266,51 @@ def test_collect(self): self.assertEqual(number_data_point.attributes, {"c": "d"}) self.assertEqual(number_data_point.value, 0) + def test_consume_measurement_attributes_are_copied(self): + """Mutating the attributes dict after recording must not affect stored data points.""" + instrument1 = _Counter( + "instrument1", + Mock(), + Mock(), + description="description", + unit="unit", + ) + instrument1.instrumentation_scope = self.mock_instrumentation_scope + view_instrument_match = _ViewInstrumentMatch( + view=View( + instrument_name="instrument1", + name="name", + aggregation=DefaultAggregation(), + ), + instrument=instrument1, + instrument_class_aggregation=MagicMock( + **{"__getitem__.return_value": DefaultAggregation()} + ), + ) + + attributes = {"key": "original"} + view_instrument_match.consume_measurement( + Measurement( + value=1, + time_unix_nano=time_ns(), + instrument=instrument1, + context=Context(), + attributes=attributes, + ) + ) + + # Mutate the original dict after recording + attributes["key"] = "mutated" + + number_data_points = view_instrument_match.collect( + AggregationTemporality.CUMULATIVE, 0 + ) + number_data_points = list(number_data_points) + self.assertEqual(len(number_data_points), 1) + self.assertEqual( + number_data_points[0].attributes, {"key": "original"} + ) + @patch( "opentelemetry.sdk.metrics._internal._view_instrument_match.time_ns", side_effect=[0, 1, 2],