Skip to content
Open
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
3 changes: 3 additions & 0 deletions .changelog/49.added
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand All @@ -109,6 +118,7 @@ def __init__(
]
| None = None,
instrument_unit: str | None = None,
aggregation_cardinality_limit: int | None = None,
):
if (
instrument_type
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
_OVERFLOW_ATTRIBUTES,
)
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.metrics.view import View


class TestCardinalityLimit(TestCase):
Expand Down Expand Up @@ -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)
Loading