Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5447.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-sdk`: add support for exclude list for metric attribute keys on Views
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Comment on lines 260 to 264
)

Expand Down
29 changes: 21 additions & 8 deletions opentelemetry-configuration/tests/test_meter_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Comment on lines +149 to +153

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
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions opentelemetry-sdk/tests/metrics/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
45 changes: 45 additions & 0 deletions opentelemetry-sdk/tests/metrics/test_view_instrument_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading