Skip to content

Commit 857165f

Browse files
committed
fix: Set the measurements attributes to a copy
Set the attributes on the measurement as a copy of the attributes passed in. This makes sure that they cannot be changed, by using a reference from outside the scope of the measurement instance.
1 parent c51244f commit 857165f

File tree

4 files changed

+138
-1
lines changed

4 files changed

+138
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
([#4637](https://github.com/open-telemetry/opentelemetry-python/pull/4637))
2727
- Logging API accepts optional `context`; deprecates `trace_id`, `span_id`, `trace_flags`.
2828
([#4597](https://github.com/open-telemetry/opentelemetry-python/pull/4597))
29+
- opentelemetry-sdk: `Measurement`s `Attributes` are now copied when instantiating a `Measurement`. This stops the accidental modification of `Attibutes` after the `Measurement` is created.
30+
([#4627](https://github.com/open-telemetry/opentelemetry-python/pull/4627))
2931

3032
## Version 1.34.0/0.55b0 (2025-06-04)
3133

opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
14+
from copy import deepcopy
1515
from dataclasses import dataclass
1616
from typing import Union
1717

@@ -43,3 +43,10 @@ class Measurement:
4343
instrument: Instrument
4444
context: Context
4545
attributes: Attributes = None
46+
47+
def __post_init__(self) -> None:
48+
if self.attributes is not None:
49+
super().__setattr__(
50+
"attributes",
51+
deepcopy(self.attributes),
52+
)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
3+
from opentelemetry.sdk.metrics import Meter, MeterProvider
4+
from opentelemetry.sdk.metrics.export import (
5+
InMemoryMetricReader,
6+
)
7+
from opentelemetry.util.types import Attributes
8+
9+
10+
@pytest.fixture
11+
def attributes() -> Attributes:
12+
return {
13+
"key": "value",
14+
}
15+
16+
17+
@pytest.fixture
18+
def meter_name() -> str:
19+
return "test_meter"
20+
21+
22+
@pytest.fixture
23+
def reader() -> InMemoryMetricReader:
24+
return InMemoryMetricReader()
25+
26+
27+
@pytest.fixture
28+
def meter_provider(reader: InMemoryMetricReader) -> MeterProvider:
29+
return MeterProvider(metric_readers=[reader])
30+
31+
32+
@pytest.fixture
33+
def meter(meter_provider: MeterProvider, meter_name: str) -> Meter:
34+
return meter_provider.get_meter("test_meter")
35+
36+
37+
def test_measurement_collection(
38+
reader: InMemoryMetricReader,
39+
meter: Meter,
40+
attributes: Attributes,
41+
) -> None:
42+
"""
43+
Validate that adjusting attributes after a data point is created does not affect
44+
the already collected measurement.
45+
"""
46+
counter = meter.create_counter("test_counter")
47+
counter.add(1, attributes)
48+
attributes["key"] = "new_value"
49+
counter.add(1, attributes)
50+
51+
reader.collect()
52+
53+
metrics_data = reader.get_metrics_data()
54+
resource_metric, *_ = metrics_data.resource_metrics
55+
scope_metric, *_ = resource_metric.scope_metrics
56+
metrics, *_ = scope_metric.metrics
57+
data = metrics.data
58+
data_point_1, data_point_2 = data.data_points
59+
60+
assert data_point_1.attributes == {"key": "value"}
61+
assert data_point_2.attributes == {"key": "new_value"}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from time import time_ns
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
6+
from opentelemetry.context import Context
7+
from opentelemetry.metrics import Instrument
8+
from opentelemetry.sdk.metrics._internal.measurement import (
9+
Measurement,
10+
)
11+
from opentelemetry.util.types import Attributes
12+
13+
14+
@pytest.fixture
15+
def attributes() -> Attributes:
16+
return {
17+
"key": "value",
18+
}
19+
20+
21+
@pytest.fixture
22+
def unix_time() -> int:
23+
return time_ns()
24+
25+
26+
@pytest.fixture
27+
def context() -> Context:
28+
return Context()
29+
30+
31+
@pytest.fixture
32+
def instrument():
33+
return Mock(spec=Instrument)
34+
35+
36+
@pytest.fixture
37+
def measurement(
38+
unix_time: int,
39+
instrument: Instrument,
40+
context: Context,
41+
attributes: Attributes,
42+
) -> Measurement:
43+
return Measurement(
44+
value=1.0,
45+
time_unix_nano=unix_time,
46+
instrument=instrument,
47+
context=context,
48+
attributes=attributes,
49+
)
50+
51+
52+
def test_measurement_attribute_is_a_different_object(
53+
measurement: Measurement,
54+
attributes: Attributes,
55+
):
56+
assert measurement.attributes is not attributes
57+
58+
59+
def test_measurement_attribute_uneffected_by_change(
60+
measurement: Measurement,
61+
attributes: Attributes,
62+
) -> None:
63+
attributes["new_key"] = "new_value"
64+
65+
assert measurement.attributes == {
66+
"key": "value",
67+
}

0 commit comments

Comments
 (0)