diff --git a/.changelog/5332.added b/.changelog/5332.added new file mode 100644 index 00000000000..d8e9a2dd664 --- /dev/null +++ b/.changelog/5332.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add support for parameter `exclude_attribute_keys` in `View` diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py index 98eb1357835..19fa5b5fa3e 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py @@ -219,14 +219,13 @@ def _create_view(config: ViewConfig) -> View: if instrument_type is None: raise ConfigurationError(f"Unknown instrument type: {selector.instrument_type!r}") - attribute_keys: set[str] | None = None + attribute_keys: list[str] | None = None + exclude_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.excluded is not None: + exclude_attribute_keys = stream.attribute_keys.excluded if stream.attribute_keys.included is not None: - attribute_keys = set(stream.attribute_keys.included) + attribute_keys = stream.attribute_keys.included aggregation = None if stream.aggregation is not None: @@ -243,6 +242,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-configuration/tests/test_meter_provider.py b/opentelemetry-configuration/tests/test_meter_provider.py index 83c9a8868c1..6904062b41b 100644 --- a/opentelemetry-configuration/tests/test_meter_provider.py +++ b/opentelemetry-configuration/tests/test_meter_provider.py @@ -790,11 +790,14 @@ 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.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(stream_kwargs={"aggregation": AggregationConfig(drop={})})) 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 43240429c6f..6db3e1be811 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 @@ -105,14 +105,22 @@ def conflicts(self, other: "_ViewInstrumentMatch") -> bool: # pylint: disable=protected-access def consume_measurement(self, measurement: Measurement, should_sample_exemplar: bool = True) -> None: - attributes = {} - if measurement.attributes: - # Make a shallow copy since the user can mutate the dict after the fact. - # The user can still modify mutable attribute values (lists/dicts) - # leading to unexpected behavior, but deep copying is expensive. - attributes = dict(measurement.attributes) if self._view._attribute_keys is not None: - attributes = {k: v for k, v in attributes.items() if k in self._view._attribute_keys} + attributes = {} + + for key, value in (measurement.attributes or {}).items(): + if key in self._view._attribute_keys: + attributes[key] = value + elif self._view._exclude_attribute_keys: + attributes = { + key: value + for key, value in (measurement.attributes or {}).items() + if key not in self._view._exclude_attribute_keys + } + elif measurement.attributes is not None: + attributes = dict(measurement.attributes) + else: + attributes = {} aggr_key = _hash_attributes(attributes) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py index 31ac34c6645..05f8de2ae8f 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 fnmatchcase from logging import getLogger @@ -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. + + This class is not intended to be subclassed by the user. """ @@ -102,10 +108,11 @@ 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, aggregation: Aggregation | None = None, exemplar_reservoir_factory: Callable[[type[_Aggregation]], ExemplarReservoirBuilder] | None = None, instrument_unit: str | None = None, + exclude_attribute_keys: Iterable[str] | None = None, ): if ( instrument_type @@ -122,7 +129,16 @@ def __init__( if name is not None and instrument_name is not None and ("*" in instrument_name or "?" in instrument_name): # pylint: disable=broad-exception-raised raise Exception(f"View {name} declared with wildcard characters in instrument_name") - + attribute_keys = set(attribute_keys) if attribute_keys is not None else None + exclude_attribute_keys = set(exclude_attribute_keys) if exclude_attribute_keys is not None else None + if attribute_keys is not None and exclude_attribute_keys is not None: + overlap = attribute_keys.intersection(exclude_attribute_keys) + + if overlap: + # pylint: disable=broad-exception-raised + raise Exception( + f"attribute_keys and exclude_attribute_keys must be disjoint. Overlapping keys: {sorted(overlap)}" + ) # _name, _description, _aggregation, _exemplar_reservoir_factory and # _attribute_keys will be accessed when instantiating a _ViewInstrumentMatch. self._name = name @@ -134,9 +150,10 @@ def __init__( self._meter_schema_url = meter_schema_url self._description = description - self._attribute_keys = attribute_keys + self._attribute_keys = frozenset(attribute_keys) if attribute_keys is not None else None self._aggregation = aggregation or self._default_aggregation self._exemplar_reservoir_factory = exemplar_reservoir_factory or _default_reservoir_factory + self._exclude_attribute_keys = frozenset(exclude_attribute_keys) if exclude_attribute_keys is not None else None # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches diff --git a/opentelemetry-sdk/tests/metrics/test_view.py b/opentelemetry-sdk/tests/metrics/test_view.py index 6c65299843e..35eacb43a91 100644 --- a/opentelemetry-sdk/tests/metrics/test_view.py +++ b/opentelemetry-sdk/tests/metrics/test_view.py @@ -107,3 +107,20 @@ def test_additive_criteria(self): def test_view_name(self): with self.assertRaises(Exception): View(name="name", instrument_name="instrument_name*") + + def test_attribute_keys_and_exclude_attribute_keys_overlap(self): + with self.assertRaises(Exception): + View( + instrument_name="instrument_name", + attribute_keys=("method", "status_code"), + exclude_attribute_keys=("method", "user_id"), + ) + + def test_attribute_keys_and_exclude_attribute_keys_disjoint(self): + view = View( + instrument_name="instrument_name", + attribute_keys=("method", "status_code"), + exclude_attribute_keys=("user_id",), + ) + + self.assertIsNotNone(view) diff --git a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py index 8985a339d34..c66dacf6d52 100644 --- a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py +++ b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py @@ -73,6 +73,68 @@ def setUpClass(cls): views=[], ) + def test_consume_measurement_with_exclude_attribute_keys(self): + instrument1 = Mock(name="instrument1") + instrument1.instrumentation_scope = self.mock_instrumentation_scope + + # exclude_attribute_keys should remove excluded attributes + view_instrument_match = _ViewInstrumentMatch( + view=View( + instrument_name="instrument1", + name="name", + aggregation=self.mock_aggregation_factory, + exclude_attribute_keys={"f"}, + ), + 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={"c": "d", "f": "g"}, + ) + ) + + self.assertEqual( + view_instrument_match._attributes_aggregation, + { + frozenset([("c", "d")]): self.mock_created_aggregation, + }, + ) + + # None measurement attributes should result in empty attributes + view_instrument_match = _ViewInstrumentMatch( + view=View( + instrument_name="instrument1", + name="name", + aggregation=self.mock_aggregation_factory, + exclude_attribute_keys={"f"}, + ), + 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=None, + ) + ) + + self.assertEqual( + view_instrument_match._attributes_aggregation, + { + frozenset(): self.mock_created_aggregation, + }, + ) + def test_consume_measurement(self): instrument1 = Mock(name="instrument1") instrument1.instrumentation_scope = self.mock_instrumentation_scope