Repository: open-telemetry/opentelemetry-python

Issue:
Let the user know if there is a view name conflict
From the [spec](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#view):

> Try to apply the View configuration. If there is an error or a conflict (e.g. the View requires to export the metrics using a certain name, but the name is already used by another View), provide a way to let the user know (e.g. expose self-diagnostics logs).

This was updated yesterday to:

> Try to apply the View configuration. If applying the View results in [conflicting metric identities](./datamodel.md#opentelemetry-protocol-data-model-producer-recommendations) the implementation SHOULD apply the View and emit a warning. If it is not possible to apply the View without producing semantic errors (e.g. the View sets an asynchronous instrument to use the [Explicit bucket histogram aggregation](#explicit-bucket-histogram-aggregation)) the implementation SHOULD emit a warning and proceed as if the View did not exist.


Discussion:

Resolving patch:
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
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/_view_instrument_match.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/_view_instrument_match.py
@@ -22,6 +22,7 @@
     _Aggregation,
     _convert_aggregation_temporality,
     _PointVarT,
+    _SumAggregation,
 )
 from opentelemetry.sdk._metrics._internal.sdk_configuration import (
     SdkConfiguration,
@@ -52,6 +53,39 @@ def __init__(
         self._attributes_previous_point: Dict[frozenset, _PointVarT] = {}
         self._lock = Lock()
         self._instrument_class_aggregation = instrument_class_aggregation
+        self._name = self._view._name or self._instrument.name
+        self._description = (
+            self._view._description or self._instrument.description
+        )
+        if not isinstance(self._view._aggregation, DefaultAggregation):
+            self._aggregation = self._view._aggregation._create_aggregation(
+                self._instrument
+            )
+        else:
+            self._aggregation = self._instrument_class_aggregation[
+                self._instrument.__class__
+            ]._create_aggregation(self._instrument)
+
+    def conflicts(self, other: "_ViewInstrumentMatch") -> bool:
+        # pylint: disable=protected-access
+
+        result = (
+            self._name == other._name
+            and self._instrument.unit == other._instrument.unit
+            # The aggregation class is being used here instead of data point
+            # type since they are functionally equivalent.
+            and self._aggregation.__class__ == other._aggregation.__class__
+        )
+        if isinstance(self._aggregation, _SumAggregation):
+            result = (
+                result
+                and self._aggregation._instrument_is_monotonic
+                == other._aggregation._instrument_is_monotonic
+                and self._aggregation._instrument_temporality
+                == other._aggregation._instrument_temporality
+            )
+
+        return result
 
     # pylint: disable=protected-access
     def consume_measurement(self, measurement: Measurement) -> None:
@@ -118,14 +152,11 @@ def collect(
 
                     yield Metric(
                         attributes=dict(attributes),
-                        description=(
-                            self._view._description
-                            or self._instrument.description
-                        ),
+                        description=self._description,
                         instrumentation_scope=(
                             self._instrument.instrumentation_scope
                         ),
-                        name=self._view._name or self._instrument.name,
+                        name=self._name,
                         resource=self._sdk_config.resource,
                         unit=self._instrument.unit,
                         point=_convert_aggregation_temporality(
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/metric_reader_storage.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/metric_reader_storage.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/metric_reader_storage.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/metric_reader_storage.py
@@ -12,21 +12,27 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from logging import getLogger
 from threading import RLock
 from typing import Dict, Iterable, List
 
-from opentelemetry._metrics import Instrument
+from opentelemetry._metrics import Asynchronous, Instrument
 from opentelemetry.sdk._metrics._internal._view_instrument_match import (
     _ViewInstrumentMatch,
 )
-from opentelemetry.sdk._metrics._internal.aggregation import Aggregation
 from opentelemetry.sdk._metrics._internal.sdk_configuration import (
     SdkConfiguration,
 )
 from opentelemetry.sdk._metrics._internal.view import View
+from opentelemetry.sdk._metrics.aggregation import (
+    Aggregation,
+    ExplicitBucketHistogramAggregation,
+)
 from opentelemetry.sdk._metrics.measurement import Measurement
 from opentelemetry.sdk._metrics.point import AggregationTemporality, Metric
 
+_logger = getLogger(__name__)
+
 _DEFAULT_VIEW = View(instrument_name="")
 
 
@@ -40,7 +46,7 @@ def __init__(
     ) -> None:
         self._lock = RLock()
         self._sdk_config = sdk_config
-        self._view_instrument_match: Dict[
+        self._instrument_view_instrument_matches: Dict[
             Instrument, List[_ViewInstrumentMatch]
         ] = {}
         self._instrument_class_aggregation = instrument_class_aggregation
@@ -51,29 +57,20 @@ def _get_or_init_view_instrument_match(
         # Optimistically get the relevant views for the given instrument. Once set for a given
         # instrument, the mapping will never change
 
-        if instrument in self._view_instrument_match:
-            return self._view_instrument_match[instrument]
+        if instrument in self._instrument_view_instrument_matches:
+            return self._instrument_view_instrument_matches[instrument]
 
         with self._lock:
             # double check if it was set before we held the lock
-            if instrument in self._view_instrument_match:
-                return self._view_instrument_match[instrument]
+            if instrument in self._instrument_view_instrument_matches:
+                return self._instrument_view_instrument_matches[instrument]
 
             # not present, hold the lock and add a new mapping
             view_instrument_matches = []
-            for view in self._sdk_config.views:
-                # pylint: disable=protected-access
-                if view._match(instrument):
-                    view_instrument_matches.append(
-                        _ViewInstrumentMatch(
-                            view=view,
-                            instrument=instrument,
-                            sdk_config=self._sdk_config,
-                            instrument_class_aggregation=(
-                                self._instrument_class_aggregation
-                            ),
-                        )
-                    )
+
+            self._handle_view_instrument_match(
+                instrument, view_instrument_matches
+            )
 
             # if no view targeted the instrument, use the default
             if not view_instrument_matches:
@@ -87,7 +84,10 @@ def _get_or_init_view_instrument_match(
                         ),
                     )
                 )
-            self._view_instrument_match[instrument] = view_instrument_matches
+            self._instrument_view_instrument_matches[
+                instrument
+            ] = view_instrument_matches
+
             return view_instrument_matches
 
     def consume_measurement(self, measurement: Measurement) -> None:
@@ -114,7 +114,7 @@ def collect(
         with self._lock:
             for (
                 view_instrument_matches
-            ) in self._view_instrument_match.values():
+            ) in self._instrument_view_instrument_matches.values():
                 for view_instrument_match in view_instrument_matches:
                     metrics.extend(
                         view_instrument_match.collect(
@@ -123,3 +123,72 @@ def collect(
                     )
 
         return metrics
+
+    def _handle_view_instrument_match(
+        self,
+        instrument: Instrument,
+        view_instrument_matches: List["_ViewInstrumentMatch"],
+    ) -> None:
+        for view in self._sdk_config.views:
+            # pylint: disable=protected-access
+            if not view._match(instrument):
+                continue
+
+            if not self._check_view_instrument_compatibility(view, instrument):
+                continue
+
+            new_view_instrument_match = _ViewInstrumentMatch(
+                view=view,
+                instrument=instrument,
+                sdk_config=self._sdk_config,
+                instrument_class_aggregation=(
+                    self._instrument_class_aggregation
+                ),
+            )
+
+            for (
+                existing_view_instrument_matches
+            ) in self._instrument_view_instrument_matches.values():
+                for (
+                    existing_view_instrument_match
+                ) in existing_view_instrument_matches:
+                    if existing_view_instrument_match.conflicts(
+                        new_view_instrument_match
+                    ):
+
+                        _logger.warning(
+                            "Views %s and %s will cause conflicting "
+                            "metrics identities",
+                            existing_view_instrument_match._view,
+                            new_view_instrument_match._view,
+                        )
+
+            view_instrument_matches.append(new_view_instrument_match)
+
+    @staticmethod
+    def _check_view_instrument_compatibility(
+        view: View, instrument: Instrument
+    ) -> bool:
+        """
+        Checks if a view and an instrument are compatible.
+
+        Returns `true` if they are compatible and a `_ViewInstrumentMatch`
+        object should be created, `false` otherwise.
+        """
+
+        result = True
+
+        # pylint: disable=protected-access
+        if isinstance(instrument, Asynchronous) and isinstance(
+            view._aggregation, ExplicitBucketHistogramAggregation
+        ):
+            _logger.warning(
+                "View %s and instrument %s will produce "
+                "semantic errors when matched, the view "
+                "has not been applied.",
+                view,
+                instrument,
+            )
+            result = False
+
+        return result


Test patch:
diff --git a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py
--- a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py
+++ b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py
@@ -12,9 +12,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from logging import WARNING
 from unittest.mock import MagicMock, Mock, patch
 
 from opentelemetry.sdk._metrics._internal.metric_reader_storage import (
+    _DEFAULT_VIEW,
     MetricReaderStorage,
 )
 from opentelemetry.sdk._metrics._internal.sdk_configuration import (
@@ -23,8 +25,15 @@
 from opentelemetry.sdk._metrics.aggregation import (
     DefaultAggregation,
     DropAggregation,
+    ExplicitBucketHistogramAggregation,
+    SumAggregation,
+)
+from opentelemetry.sdk._metrics.instrument import (
+    Counter,
+    Histogram,
+    ObservableCounter,
+    UpDownCounter,
 )
-from opentelemetry.sdk._metrics.instrument import Counter
 from opentelemetry.sdk._metrics.measurement import Measurement
 from opentelemetry.sdk._metrics.point import AggregationTemporality
 from opentelemetry.sdk._metrics.view import View
@@ -221,3 +230,462 @@ def test_drop_aggregation(self):
         self.assertEqual(
             [], metric_reader_storage.collect(AggregationTemporality.DELTA)
         )
+
+    def test_conflicting_view_configuration(self):
+
+        observable_counter = ObservableCounter(
+            "observable_counter",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(
+                        instrument_name="observable_counter",
+                        aggregation=ExplicitBucketHistogramAggregation(),
+                    ),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertLogs(level=WARNING):
+            metric_reader_storage.consume_measurement(
+                Measurement(1, observable_counter)
+            )
+
+        self.assertIs(
+            metric_reader_storage._instrument_view_instrument_matches[
+                observable_counter
+            ][0]._view,
+            _DEFAULT_VIEW,
+        )
+
+    def test_view_instrument_match_conflict_0(self):
+        # There is a conflict between views and instruments.
+
+        observable_counter_0 = ObservableCounter(
+            "observable_counter_0",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        observable_counter_1 = ObservableCounter(
+            "observable_counter_1",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="observable_counter_0", name="foo"),
+                    View(instrument_name="observable_counter_1", name="foo"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_0)
+                )
+
+        with self.assertLogs(level=WARNING) as log:
+            metric_reader_storage.consume_measurement(
+                Measurement(1, observable_counter_1)
+            )
+
+        self.assertIn(
+            "will cause conflicting metrics",
+            log.records[0].message,
+        )
+
+    def test_view_instrument_match_conflict_1(self):
+        # There is a conflict between views and instruments.
+
+        observable_counter_foo = ObservableCounter(
+            "foo",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        observable_counter_bar = ObservableCounter(
+            "bar",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        observable_counter_baz = ObservableCounter(
+            "baz",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="bar", name="foo"),
+                    View(instrument_name="baz", name="foo"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_foo)
+                )
+
+        with self.assertLogs(level=WARNING) as log:
+            metric_reader_storage.consume_measurement(
+                Measurement(1, observable_counter_bar)
+            )
+
+        self.assertIn(
+            "will cause conflicting metrics",
+            log.records[0].message,
+        )
+
+        with self.assertLogs(level=WARNING) as log:
+            metric_reader_storage.consume_measurement(
+                Measurement(1, observable_counter_baz)
+            )
+
+        self.assertIn(
+            "will cause conflicting metrics",
+            log.records[0].message,
+        )
+
+        for (
+            view_instrument_matches
+        ) in (
+            metric_reader_storage._instrument_view_instrument_matches.values()
+        ):
+            for view_instrument_match in view_instrument_matches:
+                self.assertEqual(view_instrument_match._name, "foo")
+
+    def test_view_instrument_match_conflict_2(self):
+        # There is no conflict because the metric streams names are different.
+        observable_counter_foo = ObservableCounter(
+            "foo",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        observable_counter_bar = ObservableCounter(
+            "bar",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="foo"),
+                    View(instrument_name="bar"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_foo)
+                )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_bar)
+                )
+
+    def test_view_instrument_match_conflict_3(self):
+        # There is no conflict because the aggregation temporality of the
+        # instruments is different.
+
+        counter_bar = Counter(
+            "bar",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        observable_counter_baz = ObservableCounter(
+            "baz",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="bar", name="foo"),
+                    View(instrument_name="baz", name="foo"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, counter_bar)
+                )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_baz)
+                )
+
+    def test_view_instrument_match_conflict_4(self):
+        # There is no conflict because the monotonicity of the instruments is
+        # different.
+
+        counter_bar = Counter(
+            "bar",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        up_down_counter_baz = UpDownCounter(
+            "baz",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="bar", name="foo"),
+                    View(instrument_name="baz", name="foo"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, counter_bar)
+                )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, up_down_counter_baz)
+                )
+
+    def test_view_instrument_match_conflict_5(self):
+        # There is no conflict because the instrument units are different.
+
+        observable_counter_0 = ObservableCounter(
+            "observable_counter_0",
+            Mock(),
+            [Mock()],
+            unit="unit_0",
+            description="description",
+        )
+        observable_counter_1 = ObservableCounter(
+            "observable_counter_1",
+            Mock(),
+            [Mock()],
+            unit="unit_1",
+            description="description",
+        )
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="observable_counter_0", name="foo"),
+                    View(instrument_name="observable_counter_1", name="foo"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_0)
+                )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_1)
+                )
+
+    def test_view_instrument_match_conflict_6(self):
+        # There is no conflict because the instrument data points are
+        # different.
+
+        observable_counter = ObservableCounter(
+            "observable_counter",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        histogram = Histogram(
+            "histogram",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="observable_counter", name="foo"),
+                    View(instrument_name="histogram", name="foo"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter)
+                )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, histogram)
+                )
+
+    def test_view_instrument_match_conflict_7(self):
+        # There is a conflict between views and instruments because the
+        # description being different does not avoid a conflict.
+
+        observable_counter_0 = ObservableCounter(
+            "observable_counter_0",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description_0",
+        )
+        observable_counter_1 = ObservableCounter(
+            "observable_counter_1",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description_1",
+        )
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="observable_counter_0", name="foo"),
+                    View(instrument_name="observable_counter_1", name="foo"),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter_0)
+                )
+
+        with self.assertLogs(level=WARNING) as log:
+            metric_reader_storage.consume_measurement(
+                Measurement(1, observable_counter_1)
+            )
+
+        self.assertIn(
+            "will cause conflicting metrics",
+            log.records[0].message,
+        )
+
+    def test_view_instrument_match_conflict_8(self):
+        # There is a conflict because the histogram-matching view changes the
+        # default aggregation of the histogram to Sum aggregation which is the
+        # same aggregation as the default aggregation of the up down counter
+        # and also the temporality and monotonicity of the up down counter and
+        # the histogram are the same.
+
+        observable_counter = UpDownCounter(
+            "up_down_counter",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        histogram = Histogram(
+            "histogram",
+            Mock(),
+            [Mock()],
+            unit="unit",
+            description="description",
+        )
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(
+                    View(instrument_name="up_down_counter", name="foo"),
+                    View(
+                        instrument_name="histogram",
+                        name="foo",
+                        aggregation=SumAggregation(),
+                    ),
+                ),
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        with self.assertRaises(AssertionError):
+            with self.assertLogs(level=WARNING):
+                metric_reader_storage.consume_measurement(
+                    Measurement(1, observable_counter)
+                )
+
+        with self.assertLogs(level=WARNING) as log:
+            metric_reader_storage.consume_measurement(
+                Measurement(1, histogram)
+            )
+
+        self.assertIn(
+            "will cause conflicting metrics",
+            log.records[0].message,
+        )