Repository: open-telemetry/opentelemetry-python

Issue:
Data from identical instruments must be aggregated
From the [spec](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#resolving-duplicate-instrument-registration-conflicts):

> The implementation MUST aggregate data from identical Instruments together in its export pipeline.

Make sure this is implemented and that a specific test for this requirement exists.


Discussion:

Resolving patch:
diff --git a/opentelemetry-api/src/opentelemetry/_metrics/__init__.py b/opentelemetry-api/src/opentelemetry/_metrics/__init__.py
--- a/opentelemetry-api/src/opentelemetry/_metrics/__init__.py
+++ b/opentelemetry-api/src/opentelemetry/_metrics/__init__.py
@@ -46,7 +46,7 @@
 from logging import getLogger
 from os import environ
 from threading import Lock
-from typing import List, Optional, Set, cast
+from typing import List, Optional, Set, Tuple, cast
 
 from opentelemetry._metrics.instrument import (
     Counter,
@@ -197,27 +197,31 @@ def schema_url(self):
         """
         return self._schema_url
 
-    def _check_instrument_id(
+    def _is_instrument_registered(
         self, name: str, type_: type, unit: str, description: str
-    ) -> bool:
+    ) -> Tuple[bool, str]:
         """
         Check if an instrument with the same name, type, unit and description
         has been registered already.
-        """
 
-        result = False
+        Returns a tuple. The first value is `True` if the instrument has been
+        registered already, `False` otherwise. The second value is the
+        instrument id.
+        """
 
         instrument_id = ",".join(
             [name.strip().lower(), type_.__name__, unit, description]
         )
 
+        result = False
+
         with self._instrument_ids_lock:
             if instrument_id in self._instrument_ids:
                 result = True
             else:
                 self._instrument_ids.add(instrument_id)
 
-        return result
+        return (result, instrument_id)
 
     @abstractmethod
     def create_counter(
@@ -489,7 +493,9 @@ class NoOpMeter(Meter):
     def create_counter(self, name, unit="", description="") -> Counter:
         """Returns a no-op Counter."""
         super().create_counter(name, unit=unit, description=description)
-        if self._check_instrument_id(name, NoOpCounter, unit, description):
+        if self._is_instrument_registered(
+            name, NoOpCounter, unit, description
+        )[0]:
             _logger.warning(
                 "An instrument with name %s, type %s, unit %s and "
                 "description %s has been created already.",
@@ -507,9 +513,9 @@ def create_up_down_counter(
         super().create_up_down_counter(
             name, unit=unit, description=description
         )
-        if self._check_instrument_id(
+        if self._is_instrument_registered(
             name, NoOpUpDownCounter, unit, description
-        ):
+        )[0]:
             _logger.warning(
                 "An instrument with name %s, type %s, unit %s and "
                 "description %s has been created already.",
@@ -527,9 +533,9 @@ def create_observable_counter(
         super().create_observable_counter(
             name, callbacks, unit=unit, description=description
         )
-        if self._check_instrument_id(
+        if self._is_instrument_registered(
             name, NoOpObservableCounter, unit, description
-        ):
+        )[0]:
             _logger.warning(
                 "An instrument with name %s, type %s, unit %s and "
                 "description %s has been created already.",
@@ -548,7 +554,9 @@ def create_observable_counter(
     def create_histogram(self, name, unit="", description="") -> Histogram:
         """Returns a no-op Histogram."""
         super().create_histogram(name, unit=unit, description=description)
-        if self._check_instrument_id(name, NoOpHistogram, unit, description):
+        if self._is_instrument_registered(
+            name, NoOpHistogram, unit, description
+        )[0]:
             _logger.warning(
                 "An instrument with name %s, type %s, unit %s and "
                 "description %s has been created already.",
@@ -566,9 +574,9 @@ def create_observable_gauge(
         super().create_observable_gauge(
             name, callbacks, unit=unit, description=description
         )
-        if self._check_instrument_id(
+        if self._is_instrument_registered(
             name, NoOpObservableGauge, unit, description
-        ):
+        )[0]:
             _logger.warning(
                 "An instrument with name %s, type %s, unit %s and "
                 "description %s has been created already.",
@@ -591,9 +599,9 @@ def create_observable_up_down_counter(
         super().create_observable_up_down_counter(
             name, callbacks, unit=unit, description=description
         )
-        if self._check_instrument_id(
+        if self._is_instrument_registered(
             name, NoOpObservableUpDownCounter, unit, description
-        ):
+        )[0]:
             _logger.warning(
                 "An instrument with name %s, type %s, unit %s and "
                 "description %s has been created already.",
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/__init__.py
@@ -63,9 +63,17 @@ def __init__(
         super().__init__(instrumentation_scope)
         self._instrumentation_scope = instrumentation_scope
         self._measurement_consumer = measurement_consumer
+        self._instrument_id_instrument = {}
+        self._instrument_id_instrument_lock = Lock()
 
     def create_counter(self, name, unit="", description="") -> APICounter:
-        if self._check_instrument_id(name, Counter, unit, description):
+
+        (
+            is_instrument_registered,
+            instrument_id,
+        ) = self._is_instrument_registered(name, Counter, unit, description)
+
+        if is_instrument_registered:
             # FIXME #2558 go through all views here and check if this
             # instrument registration conflict can be fixed. If it can be, do
             # not log the following warning.
@@ -77,8 +85,10 @@ def create_counter(self, name, unit="", description="") -> APICounter:
                 unit,
                 description,
             )
+            with self._instrument_id_instrument_lock:
+                return self._instrument_id_instrument[instrument_id]
 
-        return Counter(
+        instrument = Counter(
             name,
             self._instrumentation_scope,
             self._measurement_consumer,
@@ -86,10 +96,22 @@ def create_counter(self, name, unit="", description="") -> APICounter:
             description,
         )
 
+        with self._instrument_id_instrument_lock:
+            self._instrument_id_instrument[instrument_id] = instrument
+            return instrument
+
     def create_up_down_counter(
         self, name, unit="", description=""
     ) -> APIUpDownCounter:
-        if self._check_instrument_id(name, UpDownCounter, unit, description):
+
+        (
+            is_instrument_registered,
+            instrument_id,
+        ) = self._is_instrument_registered(
+            name, UpDownCounter, unit, description
+        )
+
+        if is_instrument_registered:
             # FIXME #2558 go through all views here and check if this
             # instrument registration conflict can be fixed. If it can be, do
             # not log the following warning.
@@ -101,8 +123,10 @@ def create_up_down_counter(
                 unit,
                 description,
             )
+            with self._instrument_id_instrument_lock:
+                return self._instrument_id_instrument[instrument_id]
 
-        return UpDownCounter(
+        instrument = UpDownCounter(
             name,
             self._instrumentation_scope,
             self._measurement_consumer,
@@ -110,12 +134,22 @@ def create_up_down_counter(
             description,
         )
 
+        with self._instrument_id_instrument_lock:
+            self._instrument_id_instrument[instrument_id] = instrument
+            return instrument
+
     def create_observable_counter(
         self, name, callbacks=None, unit="", description=""
     ) -> APIObservableCounter:
-        if self._check_instrument_id(
+
+        (
+            is_instrument_registered,
+            instrument_id,
+        ) = self._is_instrument_registered(
             name, ObservableCounter, unit, description
-        ):
+        )
+
+        if is_instrument_registered:
             # FIXME #2558 go through all views here and check if this
             # instrument registration conflict can be fixed. If it can be, do
             # not log the following warning.
@@ -127,6 +161,9 @@ def create_observable_counter(
                 unit,
                 description,
             )
+            with self._instrument_id_instrument_lock:
+                return self._instrument_id_instrument[instrument_id]
+
         instrument = ObservableCounter(
             name,
             self._instrumentation_scope,
@@ -138,10 +175,18 @@ def create_observable_counter(
 
         self._measurement_consumer.register_asynchronous_instrument(instrument)
 
-        return instrument
+        with self._instrument_id_instrument_lock:
+            self._instrument_id_instrument[instrument_id] = instrument
+            return instrument
 
     def create_histogram(self, name, unit="", description="") -> APIHistogram:
-        if self._check_instrument_id(name, Histogram, unit, description):
+
+        (
+            is_instrument_registered,
+            instrument_id,
+        ) = self._is_instrument_registered(name, Histogram, unit, description)
+
+        if is_instrument_registered:
             # FIXME #2558 go through all views here and check if this
             # instrument registration conflict can be fixed. If it can be, do
             # not log the following warning.
@@ -153,18 +198,32 @@ def create_histogram(self, name, unit="", description="") -> APIHistogram:
                 unit,
                 description,
             )
-        return Histogram(
+            with self._instrument_id_instrument_lock:
+                return self._instrument_id_instrument[instrument_id]
+
+        instrument = Histogram(
             name,
             self._instrumentation_scope,
             self._measurement_consumer,
             unit,
             description,
         )
+        with self._instrument_id_instrument_lock:
+            self._instrument_id_instrument[instrument_id] = instrument
+            return instrument
 
     def create_observable_gauge(
         self, name, callbacks=None, unit="", description=""
     ) -> APIObservableGauge:
-        if self._check_instrument_id(name, ObservableGauge, unit, description):
+
+        (
+            is_instrument_registered,
+            instrument_id,
+        ) = self._is_instrument_registered(
+            name, ObservableGauge, unit, description
+        )
+
+        if is_instrument_registered:
             # FIXME #2558 go through all views here and check if this
             # instrument registration conflict can be fixed. If it can be, do
             # not log the following warning.
@@ -176,6 +235,8 @@ def create_observable_gauge(
                 unit,
                 description,
             )
+            with self._instrument_id_instrument_lock:
+                return self._instrument_id_instrument[instrument_id]
 
         instrument = ObservableGauge(
             name,
@@ -188,14 +249,20 @@ def create_observable_gauge(
 
         self._measurement_consumer.register_asynchronous_instrument(instrument)
 
-        return instrument
+        with self._instrument_id_instrument_lock:
+            self._instrument_id_instrument[instrument_id] = instrument
+            return instrument
 
     def create_observable_up_down_counter(
         self, name, callbacks=None, unit="", description=""
     ) -> APIObservableUpDownCounter:
-        if self._check_instrument_id(
-            name, ObservableUpDownCounter, unit, description
-        ):
+
+        (
+            is_instrument_registered,
+            instrument_id,
+        ) = self._is_instrument_registered(name, Counter, unit, description)
+
+        if is_instrument_registered:
             # FIXME #2558 go through all views here and check if this
             # instrument registration conflict can be fixed. If it can be, do
             # not log the following warning.
@@ -207,6 +274,8 @@ def create_observable_up_down_counter(
                 unit,
                 description,
             )
+            with self._instrument_id_instrument_lock:
+                return self._instrument_id_instrument[instrument_id]
 
         instrument = ObservableUpDownCounter(
             name,
@@ -219,7 +288,9 @@ def create_observable_up_down_counter(
 
         self._measurement_consumer.register_asynchronous_instrument(instrument)
 
-        return instrument
+        with self._instrument_id_instrument_lock:
+            self._instrument_id_instrument[instrument_id] = instrument
+            return instrument
 
 
 class MeterProvider(APIMeterProvider):


Test patch:
diff --git a/opentelemetry-sdk/tests/metrics/test_metrics.py b/opentelemetry-sdk/tests/metrics/test_metrics.py
--- a/opentelemetry-sdk/tests/metrics/test_metrics.py
+++ b/opentelemetry-sdk/tests/metrics/test_metrics.py
@@ -14,12 +14,19 @@
 
 
 from logging import WARNING
+from time import sleep
+from typing import Sequence
 from unittest import TestCase
 from unittest.mock import MagicMock, Mock, patch
 
 from opentelemetry._metrics import NoOpMeter
 from opentelemetry.sdk._metrics import Meter, MeterProvider
-from opentelemetry.sdk._metrics.export import PeriodicExportingMetricReader
+from opentelemetry.sdk._metrics.aggregation import SumAggregation
+from opentelemetry.sdk._metrics.export import (
+    MetricExporter,
+    MetricExportResult,
+    PeriodicExportingMetricReader,
+)
 from opentelemetry.sdk._metrics.instrument import (
     Counter,
     Histogram,
@@ -29,7 +36,8 @@
     UpDownCounter,
 )
 from opentelemetry.sdk._metrics.metric_reader import MetricReader
-from opentelemetry.sdk._metrics.point import AggregationTemporality
+from opentelemetry.sdk._metrics.point import AggregationTemporality, Metric
+from opentelemetry.sdk._metrics.view import View
 from opentelemetry.sdk.resources import Resource
 from opentelemetry.test.concurrency_test import ConcurrencyTestBase, MockFunc
 
@@ -400,3 +408,88 @@ def test_create_observable_up_down_counter(self):
             observable_up_down_counter, ObservableUpDownCounter
         )
         self.assertEqual(observable_up_down_counter.name, "name")
+
+
+class InMemoryMetricExporter(MetricExporter):
+    def __init__(self):
+        self.metrics = {}
+        self._counter = 0
+
+    def export(self, metrics: Sequence[Metric]) -> MetricExportResult:
+        self.metrics[self._counter] = metrics
+        self._counter += 1
+        return MetricExportResult.SUCCESS
+
+    def shutdown(self) -> None:
+        pass
+
+
+class TestDuplicateInstrumentAggregateData(TestCase):
+    def test_duplicate_instrument_aggregate_data(self):
+
+        exporter = InMemoryMetricExporter()
+        reader = PeriodicExportingMetricReader(
+            exporter, export_interval_millis=500
+        )
+        view = View(
+            instrument_type=Counter,
+            attribute_keys=[],
+            aggregation=SumAggregation(),
+        )
+        provider = MeterProvider(
+            metric_readers=[reader],
+            resource=Resource.create(),
+            views=[view],
+        )
+
+        meter_0 = provider.get_meter(
+            name="meter_0",
+            version="version",
+            schema_url="schema_url",
+        )
+        meter_1 = provider.get_meter(
+            name="meter_1",
+            version="version",
+            schema_url="schema_url",
+        )
+        counter_0_0 = meter_0.create_counter(
+            "counter", unit="unit", description="description"
+        )
+        counter_0_1 = meter_0.create_counter(
+            "counter", unit="unit", description="description"
+        )
+        counter_1_0 = meter_1.create_counter(
+            "counter", unit="unit", description="description"
+        )
+
+        self.assertIs(counter_0_0, counter_0_1)
+        self.assertIsNot(counter_0_0, counter_1_0)
+
+        counter_0_0.add(1, {})
+        counter_0_1.add(2, {})
+
+        counter_1_0.add(7, {})
+
+        sleep(1)
+
+        reader.shutdown()
+
+        sleep(1)
+
+        metrics = exporter.metrics[0]
+
+        self.assertEqual(len(metrics), 2)
+
+        metric_0 = metrics[0]
+
+        self.assertEqual(metric_0.name, "counter")
+        self.assertEqual(metric_0.unit, "unit")
+        self.assertEqual(metric_0.description, "description")
+        self.assertEqual(metric_0.point.value, 3)
+
+        metric_1 = metrics[1]
+
+        self.assertEqual(metric_1.name, "counter")
+        self.assertEqual(metric_1.unit, "unit")
+        self.assertEqual(metric_1.description, "description")
+        self.assertEqual(metric_1.point.value, 7)