From b2cb9d430d1edcf33887f28a3e31afeda9c9b163 Mon Sep 17 00:00:00 2001 From: Saurabh Saraswat <85618497+saurabh-saraswat@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:51:00 +0000 Subject: [PATCH 1/3] Added support for exclude list in attribute keys --- .../sdk/_configuration/_meter_provider.py | 8 +- .../_internal/_view_instrument_match.py | 8 ++ .../sdk/metrics/_internal/view.py | 8 ++ .../_configuration/test_meter_provider.py | 15 ++- .../metrics/test_view_instrument_match.py | 98 +++++++++++++++++++ 5 files changed, 124 insertions(+), 13 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index 1b8bd911de2..0a5a708ca64 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -226,11 +226,8 @@ def _create_view(config: ViewConfig) -> View: attribute_keys: set[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.excluded is not None: + exclude_attribute_keys = set(stream.attribute_keys.excluded) if stream.attribute_keys.included is not None: attribute_keys = set(stream.attribute_keys.included) @@ -249,6 +246,7 @@ def _create_view(config: ViewConfig) -> View: description=stream.description, attribute_keys=attribute_keys, aggregation=aggregation, + exclude_attribute_keys=exclude_attribute_keys, ) 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..209caac367b 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,6 +98,14 @@ def consume_measurement( else: attributes = {} + if self._view._exclude_attribute_keys is not None: + if attributes is not None: + attributes = { + key: value + for key, value in attributes.items() + if key not in self._view._exclude_attribute_keys + } + aggr_key = frozenset(attributes.items()) if aggr_key not in self._attributes_aggregation: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py index 7eb1fcc728e..3e332009fa6 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py @@ -88,6 +88,12 @@ class View: instrument_unit: This is an instrument matching attribute: the unit the instrument must have to match the view. + exclude_attribute_keys: This is a metric stream customizing attribute: this is + a set of attribute keys. If not `None` then measurement attributes whose + keys are in ``exclude_attribute_keys`` will be removed before identifying + the metric stream. Applied after ``attribute_keys`` if both are provided. + + This class is not intended to be subclassed by the user. """ @@ -109,6 +115,7 @@ def __init__( ] | None = None, instrument_unit: str | None = None, + exclude_attribute_keys: set[str] | None = None, ): if ( instrument_type @@ -152,6 +159,7 @@ def __init__( self._exemplar_reservoir_factory = ( exemplar_reservoir_factory or _default_reservoir_factory ) + self._exclude_attribute_keys = exclude_attribute_keys # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index 566329cf146..56b6e2e1878 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -775,17 +775,16 @@ def test_stream_attribute_keys_included(self): ) ) self.assertEqual(view._attribute_keys, {"key1", "key2"}) - - def test_stream_attribute_keys_excluded_logs_warning(self): + def test_stream_attribute_keys_excluded_is_applied(self): config = self._make_view_config( stream_kwargs={"attribute_keys": IncludeExclude(excluded=["key1"])} ) - with self.assertLogs( - "opentelemetry.sdk._configuration._meter_provider", level="WARNING" - ) as log: - create_meter_provider(config) - self.assertTrue(any("excluded" in msg for msg in log.output)) - + meter_provider = create_meter_provider(config) + views = meter_provider._sdk_config.views + self.assertEqual(len(views), 1) + view = views[0] + self.assertEqual(view._exclude_attribute_keys, frozenset({"key1"})) + self.assertIsNone(view._attribute_keys) def test_stream_aggregation_drop(self): view = self._get_view( self._make_view_config( diff --git a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py index 73b0e6e24a5..175e2836ce0 100644 --- a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py +++ b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py @@ -73,6 +73,104 @@ def setUpClass(cls): resource=cls.mock_resource, views=[], ) + def test_view_instrument_match_exclude_attribute_keys_affects_aggregation(self): + instrument1 = Mock(name="instrument1") + instrument1.instrumentation_scope = self.mock_instrumentation_scope + + mock_aggregation = MagicMock() + mock_aggregation._create_aggregation.return_value = MagicMock() + + instrument_class_aggregation = MagicMock() + instrument_class_aggregation.__getitem__.return_value = mock_aggregation + + view = View( + instrument_name="instrument1", + exclude_attribute_keys={"user_id"}, + ) + match = _ViewInstrumentMatch( + view, + instrument=instrument1, + instrument_class_aggregation= instrument_class_aggregation + ) + measurement1 = Measurement( + value=1, + time_unix_nano=time_ns(), + instrument=instrument1, + context=Context(), + attributes={"method": "GET", "user_id": "u1"}, + ) + measurement2 = Measurement( + value=2, + time_unix_nano=time_ns(), + instrument=instrument1, + context=Context(), + attributes={"method": "GET", "user_id": "u2"}, + ) + + match.consume_measurement(measurement1) + match.consume_measurement(measurement2) + assert len(match._attributes_aggregation) == 1 + aggr_key = list(match._attributes_aggregation.keys())[0] + assert dict(aggr_key) == {"method": "GET"} + + + def test_view_instrument_match_exclude_removes_attributes(self): + instrument1 = Mock(name="instrument1") + instrument1.instrumentation_scope = self.mock_instrumentation_scope + mock_aggregation = MagicMock() + mock_aggregation._create_aggregation.return_value = MagicMock() + + instrument_class_aggregation = MagicMock() + instrument_class_aggregation.__getitem__.return_value = mock_aggregation + view = View( + instrument_name="instrument1", + exclude_attribute_keys={"user_id"}, + ) + match = _ViewInstrumentMatch( + view, + instrument=instrument1, + instrument_class_aggregation=instrument_class_aggregation + ) + measurement = Measurement( + value=1, + time_unix_nano=time_ns(), + instrument=instrument1, + context=Context(), + attributes={"method": "GET", "user_id": "u1"}, + ) + match.consume_measurement(measurement) + aggr_key = list(match._attributes_aggregation.keys())[0] + assert "user_id" not in dict(aggr_key) + + def test_view_instrument_match_include_then_exclude(self): + instrument1 = Mock(name="instrument1") + instrument1.instrumentation_scope = self.mock_instrumentation_scope + mock_aggregation = MagicMock() + mock_aggregation._create_aggregation.return_value = MagicMock() + + instrument_class_aggregation = MagicMock() + instrument_class_aggregation.__getitem__.return_value = mock_aggregation + view = View( + instrument_name="instrument1", + attribute_keys={"method", "user_id"}, + exclude_attribute_keys={"user_id"}, + ) + match = _ViewInstrumentMatch( + view, + instrument=instrument1, + instrument_class_aggregation=instrument_class_aggregation + ) + measurement = Measurement( + value=1, + time_unix_nano=time_ns(), + instrument=instrument1, + context=Context(), + attributes={"method": "GET", "user_id": "u1", "x": "y"}, + ) + match.consume_measurement(measurement) + aggr_key = list(match._attributes_aggregation.keys())[0] + assert dict(aggr_key) == {"method": "GET"} + def test_consume_measurement(self): instrument1 = Mock(name="instrument1") From 2e5ffb54aabea0d7a79a949f88ebe08493c974eb Mon Sep 17 00:00:00 2001 From: Saurabh Saraswat <85618497+saurabh-saraswat@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:34:00 +0000 Subject: [PATCH 2/3] corrected lint issues --- .../_configuration/test_meter_provider.py | 2 ++ .../metrics/test_view_instrument_match.py | 25 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index 56b6e2e1878..1e7619b0fbb 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -775,6 +775,7 @@ def test_stream_attribute_keys_included(self): ) ) self.assertEqual(view._attribute_keys, {"key1", "key2"}) + def test_stream_attribute_keys_excluded_is_applied(self): config = self._make_view_config( stream_kwargs={"attribute_keys": IncludeExclude(excluded=["key1"])} @@ -785,6 +786,7 @@ def test_stream_attribute_keys_excluded_is_applied(self): view = views[0] self.assertEqual(view._exclude_attribute_keys, frozenset({"key1"})) self.assertIsNone(view._attribute_keys) + def test_stream_aggregation_drop(self): view = self._get_view( self._make_view_config( diff --git a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py index 175e2836ce0..e0481090e60 100644 --- a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py +++ b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py @@ -73,7 +73,10 @@ def setUpClass(cls): resource=cls.mock_resource, views=[], ) - def test_view_instrument_match_exclude_attribute_keys_affects_aggregation(self): + + def test_view_instrument_match_exclude_attribute_keys_affects_aggregation( + self, + ): instrument1 = Mock(name="instrument1") instrument1.instrumentation_scope = self.mock_instrumentation_scope @@ -81,7 +84,9 @@ def test_view_instrument_match_exclude_attribute_keys_affects_aggregation(self): mock_aggregation._create_aggregation.return_value = MagicMock() instrument_class_aggregation = MagicMock() - instrument_class_aggregation.__getitem__.return_value = mock_aggregation + instrument_class_aggregation.__getitem__.return_value = ( + mock_aggregation + ) view = View( instrument_name="instrument1", @@ -90,7 +95,7 @@ def test_view_instrument_match_exclude_attribute_keys_affects_aggregation(self): match = _ViewInstrumentMatch( view, instrument=instrument1, - instrument_class_aggregation= instrument_class_aggregation + instrument_class_aggregation=instrument_class_aggregation, ) measurement1 = Measurement( value=1, @@ -113,7 +118,6 @@ def test_view_instrument_match_exclude_attribute_keys_affects_aggregation(self): aggr_key = list(match._attributes_aggregation.keys())[0] assert dict(aggr_key) == {"method": "GET"} - def test_view_instrument_match_exclude_removes_attributes(self): instrument1 = Mock(name="instrument1") instrument1.instrumentation_scope = self.mock_instrumentation_scope @@ -121,7 +125,9 @@ def test_view_instrument_match_exclude_removes_attributes(self): mock_aggregation._create_aggregation.return_value = MagicMock() instrument_class_aggregation = MagicMock() - instrument_class_aggregation.__getitem__.return_value = mock_aggregation + instrument_class_aggregation.__getitem__.return_value = ( + mock_aggregation + ) view = View( instrument_name="instrument1", exclude_attribute_keys={"user_id"}, @@ -129,7 +135,7 @@ def test_view_instrument_match_exclude_removes_attributes(self): match = _ViewInstrumentMatch( view, instrument=instrument1, - instrument_class_aggregation=instrument_class_aggregation + instrument_class_aggregation=instrument_class_aggregation, ) measurement = Measurement( value=1, @@ -149,7 +155,9 @@ def test_view_instrument_match_include_then_exclude(self): mock_aggregation._create_aggregation.return_value = MagicMock() instrument_class_aggregation = MagicMock() - instrument_class_aggregation.__getitem__.return_value = mock_aggregation + instrument_class_aggregation.__getitem__.return_value = ( + mock_aggregation + ) view = View( instrument_name="instrument1", attribute_keys={"method", "user_id"}, @@ -158,7 +166,7 @@ def test_view_instrument_match_include_then_exclude(self): match = _ViewInstrumentMatch( view, instrument=instrument1, - instrument_class_aggregation=instrument_class_aggregation + instrument_class_aggregation=instrument_class_aggregation, ) measurement = Measurement( value=1, @@ -171,7 +179,6 @@ def test_view_instrument_match_include_then_exclude(self): aggr_key = list(match._attributes_aggregation.keys())[0] assert dict(aggr_key) == {"method": "GET"} - def test_consume_measurement(self): instrument1 = Mock(name="instrument1") instrument1.instrumentation_scope = self.mock_instrumentation_scope From 0bff2ebbafcabc3567cab5766d1628e793e3289d Mon Sep 17 00:00:00 2001 From: Saurabh Saraswat <85618497+saurabh-saraswat@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:01:33 +0000 Subject: [PATCH 3/3] Updated changelog fragment file --- .changelog/5332.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5332.fixed diff --git a/.changelog/5332.fixed b/.changelog/5332.fixed new file mode 100644 index 00000000000..52ddb1af26f --- /dev/null +++ b/.changelog/5332.fixed @@ -0,0 +1 @@ +Added support for parameter exclude_attribute_keys in View