From 20d388bbbf6f4f9426e9857b424a28f5ad0387bd Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 19 Jul 2026 22:38:26 -0400 Subject: [PATCH 1/2] opentelemetry-sdk: add support for exclude list for metric attribute keys on Views --- .../configuration/_meter_provider.py | 13 +++--- .../tests/test_meter_provider.py | 29 ++++++++---- .../_internal/_view_instrument_match.py | 14 ++++-- .../sdk/metrics/_internal/view.py | 36 +++++++++++++-- opentelemetry-sdk/tests/metrics/test_view.py | 26 +++++++++++ .../metrics/test_view_instrument_match.py | 45 +++++++++++++++++++ 6 files changed, 141 insertions(+), 22 deletions(-) diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py index 23db41f2078..de59219283c 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py @@ -238,15 +238,13 @@ def _create_view(config: ViewConfig) -> View: f"Unknown instrument type: {selector.instrument_type!r}" ) - attribute_keys: set[str] | None = None + attribute_keys: list[str] | None = None + excluded_attribute_keys: list[str] | None = None if stream.attribute_keys is not None: - if stream.attribute_keys.excluded: - _logger.warning( - "attribute_keys.excluded is not supported by the Python SDK View; " - "the exclusion list will be ignored." - ) if stream.attribute_keys.included is not None: - attribute_keys = set(stream.attribute_keys.included) + attribute_keys = stream.attribute_keys.included + if stream.attribute_keys.excluded is not None: + excluded_attribute_keys = stream.attribute_keys.excluded aggregation = None if stream.aggregation is not None: @@ -262,6 +260,7 @@ def _create_view(config: ViewConfig) -> View: name=stream.name, description=stream.description, attribute_keys=attribute_keys, + excluded_attribute_keys=excluded_attribute_keys, aggregation=aggregation, ) diff --git a/opentelemetry-configuration/tests/test_meter_provider.py b/opentelemetry-configuration/tests/test_meter_provider.py index 5e1d7e686a6..db7330c8cea 100644 --- a/opentelemetry-configuration/tests/test_meter_provider.py +++ b/opentelemetry-configuration/tests/test_meter_provider.py @@ -876,15 +876,28 @@ def test_stream_attribute_keys_included(self): ) self.assertEqual(view._attribute_keys, {"key1", "key2"}) - def test_stream_attribute_keys_excluded_logs_warning(self): - config = self._make_view_config( - stream_kwargs={"attribute_keys": IncludeExclude(excluded=["key1"])} + def test_stream_attribute_keys_excluded(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={ + "attribute_keys": IncludeExclude(excluded=["key1"]) + } + ) ) - with self.assertLogs( - "opentelemetry.configuration._meter_provider", level="WARNING" - ) as log: - create_meter_provider(config) - self.assertTrue(any("excluded" in msg for msg in log.output)) + self.assertEqual(view._excluded_attribute_keys, {"key1"}) + + def test_stream_attribute_keys_included_and_excluded(self): + view = self._get_view( + self._make_view_config( + stream_kwargs={ + "attribute_keys": IncludeExclude( + included=["key1"], excluded=["key2"] + ) + } + ) + ) + self.assertEqual(view._attribute_keys, {"key1"}) + self.assertEqual(view._excluded_attribute_keys, {"key2"}) def test_stream_aggregation_drop(self): view = self._get_view( 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 d9ba05363b1..fe12e2dfad4 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 @@ -87,12 +87,20 @@ def conflicts(self, other: "_ViewInstrumentMatch") -> bool: def consume_measurement( self, measurement: Measurement, should_sample_exemplar: bool = True ) -> None: - if self._view._attribute_keys is not None: + attribute_keys = self._view._attribute_keys + excluded_attribute_keys = self._view._excluded_attribute_keys + if attribute_keys is not None or excluded_attribute_keys is not None: attributes = {} for key, value in (measurement.attributes or {}).items(): - if key in self._view._attribute_keys: - attributes[key] = value + if attribute_keys is not None and key not in attribute_keys: + continue + if ( + excluded_attribute_keys is not None + and key in excluded_attribute_keys + ): + continue + attributes[key] = value elif measurement.attributes is not None: attributes = dict(measurement.attributes) else: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py index 7eb1fcc728e..dc15028a8d4 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 -from collections.abc import Callable +from collections.abc import Callable, Iterable from fnmatch import fnmatch from logging import getLogger @@ -77,6 +77,12 @@ class View: a set of attribute keys. If not `None` then only the measurement attributes that are in ``attribute_keys`` will be used to identify the metric stream. + excluded_attribute_keys: This is a metric stream customizing attribute: + this is a set of attribute keys. If not `None` then the measurement attributes + whose keys are in ``excluded_attribute_keys`` will be dropped and all other + attributes will be used to identify the metric stream. A key must not appear in + both ``attribute_keys`` and ``excluded_attribute_keys``. + aggregation: This is a metric stream customizing attribute: the aggregation instance to use when data is aggregated for the corresponding metrics stream. If `None` an instance of @@ -102,7 +108,8 @@ def __init__( meter_schema_url: str | None = None, name: str | None = None, description: str | None = None, - attribute_keys: set[str] | None = None, + attribute_keys: Iterable[str] | None = None, + excluded_attribute_keys: Iterable[str] | None = None, aggregation: Aggregation | None = None, exemplar_reservoir_factory: Callable[ [type[_Aggregation]], ExemplarReservoirBuilder @@ -136,8 +143,28 @@ def __init__( "characters in instrument_name" ) - # _name, _description, _aggregation, _exemplar_reservoir_factory and - # _attribute_keys will be accessed when instantiating a _ViewInstrumentMatch. + attribute_keys = ( + frozenset(attribute_keys) if attribute_keys is not None else None + ) + excluded_attribute_keys = ( + frozenset(excluded_attribute_keys) + if excluded_attribute_keys is not None + else None + ) + + if ( + attribute_keys is not None + and excluded_attribute_keys is not None + and (attribute_keys & excluded_attribute_keys) + ): + raise ValueError( + f"View {name} declared with attribute keys present in both " + "attribute_keys and excluded_attribute_keys" + ) + + # _name, _description, _aggregation, _exemplar_reservoir_factory, + # _attribute_keys and _excluded_attribute_keys will be accessed when + # instantiating a _ViewInstrumentMatch. self._name = name self._instrument_type = instrument_type self._instrument_name = instrument_name @@ -148,6 +175,7 @@ def __init__( self._description = description self._attribute_keys = attribute_keys + self._excluded_attribute_keys = excluded_attribute_keys self._aggregation = aggregation or self._default_aggregation self._exemplar_reservoir_factory = ( exemplar_reservoir_factory or _default_reservoir_factory diff --git a/opentelemetry-sdk/tests/metrics/test_view.py b/opentelemetry-sdk/tests/metrics/test_view.py index 03914c99c6f..f1f2284a9ac 100644 --- a/opentelemetry-sdk/tests/metrics/test_view.py +++ b/opentelemetry-sdk/tests/metrics/test_view.py @@ -105,3 +105,29 @@ def test_additive_criteria(self): def test_view_name(self): with self.assertRaises(Exception): View(name="name", instrument_name="instrument_name*") + + def test_overlapping_attribute_keys(self): + with self.assertRaises(ValueError): + View( + instrument_name="instrument_name", + attribute_keys={"a", "b"}, + excluded_attribute_keys={"b"}, + ) + + def test_disjoint_attribute_keys(self): + view = View( + instrument_name="instrument_name", + attribute_keys={"a", "b"}, + excluded_attribute_keys={"c"}, + ) + self.assertEqual(view._attribute_keys, {"a", "b"}) + self.assertEqual(view._excluded_attribute_keys, {"c"}) + + def test_attribute_keys_normalized(self): + view = View( + instrument_name="instrument_name", + attribute_keys=["a", "b", "a"], + excluded_attribute_keys=["c"], + ) + self.assertEqual(view._attribute_keys, {"a", "b"}) + self.assertEqual(view._excluded_attribute_keys, {"c"}) diff --git a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py index 73b0e6e24a5..00d33b8d7bf 100644 --- a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py +++ b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py @@ -211,6 +211,51 @@ def test_consume_measurement(self): _DropAggregation, ) + def test_consume_measurement_excluded_attribute_keys(self): + instrument1 = Mock(name="instrument1") + instrument1.instrumentation_scope = self.mock_instrumentation_scope + + for name, view_kwargs, attributes, expected_key in ( + ( + "exclude drops listed keys and keeps all others", + {"excluded_attribute_keys": {"f"}}, + {"c": "d", "f": "g"}, + frozenset([("c", "d")]), + ), + ( + "include and disjoint exclude combine", + {"attribute_keys": {"c"}, "excluded_attribute_keys": {"f"}}, + {"a": "b", "c": "d", "f": "g"}, + frozenset([("c", "d")]), + ), + ): + with self.subTest(name): + view_instrument_match = _ViewInstrumentMatch( + view=View( + instrument_name="instrument1", + name="name", + aggregation=self.mock_aggregation_factory, + **view_kwargs, + ), + instrument=instrument1, + instrument_class_aggregation=MagicMock( + **{"__getitem__.return_value": DefaultAggregation()} + ), + ) + view_instrument_match.consume_measurement( + Measurement( + value=0, + time_unix_nano=time_ns(), + instrument=instrument1, + context=Context(), + attributes=attributes, + ) + ) + self.assertEqual( + view_instrument_match._attributes_aggregation, + {expected_key: self.mock_created_aggregation}, + ) + def test_collect(self): instrument1 = _Counter( "instrument1", From 5998893b094109248b39de62fbf6a1ee21faf53c Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 19 Jul 2026 22:41:53 -0400 Subject: [PATCH 2/2] add changelog fragment --- .changelog/5447.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5447.added diff --git a/.changelog/5447.added b/.changelog/5447.added new file mode 100644 index 00000000000..fba22d6e38f --- /dev/null +++ b/.changelog/5447.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add support for exclude list for metric attribute keys on Views