diff --git a/.changelog/49.added b/.changelog/49.added new file mode 100644 index 0000000000..68255c731d --- /dev/null +++ b/.changelog/49.added @@ -0,0 +1,3 @@ +Add a per-`View` `aggregation_cardinality_limit` option that overrides both the +per-`MetricReader` default and the base default cardinality limit for the metric +streams matched by that 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 85f31f7e33..3f61184765 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 @@ -46,7 +46,13 @@ def __init__( self._attributes_aggregation: dict[frozenset, _Aggregation] = {} self._lock = Lock() self._instrument_class_aggregation = instrument_class_aggregation - if cardinality_limit is None: + # Cardinality limit precedence (finest wins): + # 1. per-View override (View.aggregation_cardinality_limit) + # 2. per-reader default (cardinality_limit argument) + # 3. SDK base default (_DEFAULT_CARDINALITY_LIMIT) + if self._view._aggregation_cardinality_limit is not None: + cardinality_limit = self._view._aggregation_cardinality_limit + elif cardinality_limit is None: cardinality_limit = _DEFAULT_CARDINALITY_LIMIT self._cardinality_limit = cardinality_limit self._name = self._view._name or self._instrument.name diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py index 7eb1fcc728..f286b7f0e8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py @@ -88,6 +88,15 @@ class View: instrument_unit: This is an instrument matching attribute: the unit the instrument must have to match the view. + aggregation_cardinality_limit: This is a metric stream customizing + attribute: the maximum number of distinct attribute sets (metric + points) that will be tracked for streams matched by this view. Once + reached, additional attribute sets are aggregated under a single + overflow attribute set. When set, this value overrides both the + per-reader default cardinality limit and the SDK base default + (2000). If `None`, the per-reader default (and then the base + default) is used instead. + This class is not intended to be subclassed by the user. """ @@ -109,6 +118,7 @@ def __init__( ] | None = None, instrument_unit: str | None = None, + aggregation_cardinality_limit: int | None = None, ): if ( instrument_type @@ -152,6 +162,7 @@ def __init__( self._exemplar_reservoir_factory = ( exemplar_reservoir_factory or _default_reservoir_factory ) + self._aggregation_cardinality_limit = aggregation_cardinality_limit # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches diff --git a/opentelemetry-sdk/tests/metrics/integration_test/test_metric_cardinality_limit.py b/opentelemetry-sdk/tests/metrics/integration_test/test_metric_cardinality_limit.py index 2e688302fa..22950ae105 100644 --- a/opentelemetry-sdk/tests/metrics/integration_test/test_metric_cardinality_limit.py +++ b/opentelemetry-sdk/tests/metrics/integration_test/test_metric_cardinality_limit.py @@ -9,6 +9,7 @@ _OVERFLOW_ATTRIBUTES, ) from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.metrics.view import View class TestCardinalityLimit(TestCase): @@ -121,3 +122,90 @@ def test_reader_cardinality_limit_rejects_non_positive(self): with self.subTest(cardinality_limit=invalid): with self.assertRaises(ValueError): InMemoryMetricReader(cardinality_limit=invalid) + + @staticmethod + def _record_distinct_attribute_sets_with_view( + count, view_limit=None, reader_limit=None + ): + if reader_limit is None: + reader = InMemoryMetricReader() + else: + reader = InMemoryMetricReader(cardinality_limit=reader_limit) + view = View( + instrument_name="testcounter", + aggregation_cardinality_limit=view_limit, + ) + meter_provider = MeterProvider( + metric_readers=[reader], views=[view] + ) + meter = meter_provider.get_meter("testmeter") + counter = meter.create_counter("testcounter") + + for index in range(count): + counter.add(1, {"index": index}) + + metrics_data = reader.get_metrics_data() + return ( + metrics_data.resource_metrics[0] + .scope_metrics[0] + .metrics[0] + .data.data_points + ) + + def test_view_cardinality_limit_overflows_at_view_limit(self): + # A view configured with a small cardinality limit overflows at that + # limit, overriding the base default. + view_limit = 10 + data_points = self._record_distinct_attribute_sets_with_view( + view_limit + 20, view_limit=view_limit + ) + + self.assertEqual(len(data_points), view_limit) + + overflow_points = [ + data_point + for data_point in data_points + if dict(data_point.attributes) == _OVERFLOW_ATTRIBUTES + ] + self.assertEqual(len(overflow_points), 1) + + def test_view_cardinality_limit_overrides_larger_reader_limit(self): + # The view override wins over a larger per-reader default: streams + # matched by the view overflow at the (smaller) view limit. + view_limit = 10 + reader_limit = 100 + data_points = self._record_distinct_attribute_sets_with_view( + view_limit + 20, view_limit=view_limit, reader_limit=reader_limit + ) + + self.assertEqual(len(data_points), view_limit) + + def test_view_cardinality_limit_overrides_unset_reader(self): + # The view override wins over the base default even when the reader + # sets no cardinality limit of its own. + view_limit = 10 + data_points = self._record_distinct_attribute_sets_with_view( + view_limit + 20, view_limit=view_limit, reader_limit=None + ) + + self.assertEqual(len(data_points), view_limit) + + def test_view_cardinality_limit_unset_falls_back_to_reader(self): + # An unset view override falls through to the per-reader default. + reader_limit = 10 + data_points = self._record_distinct_attribute_sets_with_view( + reader_limit + 20, view_limit=None, reader_limit=reader_limit + ) + + self.assertEqual(len(data_points), reader_limit) + + def test_view_cardinality_limit_unset_falls_back_to_base_default(self): + # An unset view override with an unset reader falls through to the base + # default (no regression). + data_points = self._record_distinct_attribute_sets_with_view( + _DEFAULT_CARDINALITY_LIMIT + 100, + view_limit=None, + reader_limit=None, + ) + + self.assertEqual(len(data_points), _DEFAULT_CARDINALITY_LIMIT)