Repository: open-telemetry/opentelemetry-python

Issue:
Refactor aggregations



Discussion:

Resolving patch:
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py
@@ -14,124 +14,194 @@
 
 from abc import ABC, abstractmethod
 from collections import OrderedDict
+from enum import IntEnum
 from logging import getLogger
 from math import inf
+from threading import Lock
+from typing import Generic, Optional, Sequence, TypeVar
 
-from opentelemetry._metrics.instrument import _Monotonic
+from opentelemetry.sdk._metrics.measurement import Measurement
+from opentelemetry.sdk._metrics.point import Gauge, Histogram, PointT, Sum
 from opentelemetry.util._time import _time_ns
 
+
+class AggregationTemporality(IntEnum):
+    UNSPECIFIED = 0
+    DELTA = 1
+    CUMULATIVE = 2
+
+
+_PointVarT = TypeVar("_PointVarT", bound=PointT)
+
 _logger = getLogger(__name__)
 
 
-class Aggregation(ABC):
-    @property
-    def value(self):
-        return self._value  # pylint: disable=no-member
+class _InstrumentMonotonicityAwareAggregation:
+    def __init__(self, instrument_is_monotonic: bool):
+        self._instrument_is_monotonic = instrument_is_monotonic
+        super().__init__()
+
+
+class Aggregation(ABC, Generic[_PointVarT]):
+    def __init__(self):
+        self._lock = Lock()
 
     @abstractmethod
-    def aggregate(self, value):
+    def aggregate(self, measurement: Measurement) -> None:
         pass
 
     @abstractmethod
-    def make_point_and_reset(self):
+    def collect(self) -> Optional[_PointVarT]:
+        pass
+
+
+class SynchronousSumAggregation(
+    _InstrumentMonotonicityAwareAggregation, Aggregation[Sum]
+):
+    def __init__(self, instrument_is_monotonic: bool):
+        super().__init__(instrument_is_monotonic)
+        self._value = 0
+        self._start_time_unix_nano = _time_ns()
+
+    def aggregate(self, measurement: Measurement) -> None:
+        with self._lock:
+            self._value = self._value + measurement.value
+
+    def collect(self) -> Optional[Sum]:
         """
-        Atomically return a point for the current value of the metric and reset the internal state.
+        Atomically return a point for the current value of the metric and
+        reset the aggregation value.
         """
+        now = _time_ns()
 
+        with self._lock:
+            value = self._value
+            start_time_unix_nano = self._start_time_unix_nano
 
-class SumAggregation(Aggregation):
-    """
-    This aggregation collects data for the SDK sum metric point.
-    """
+            self._value = 0
+            self._start_time_unix_nano = now + 1
 
-    def __init__(self, instrument):
-        self._value = 0
+        return Sum(
+            aggregation_temporality=AggregationTemporality.DELTA,
+            is_monotonic=self._instrument_is_monotonic,
+            start_time_unix_nano=start_time_unix_nano,
+            time_unix_nano=now,
+            value=value,
+        )
 
-    def aggregate(self, value):
-        self._value = self._value + value
 
-    def make_point_and_reset(self):
-        pass
+class AsynchronousSumAggregation(
+    _InstrumentMonotonicityAwareAggregation, Aggregation[Sum]
+):
+    def __init__(self, instrument_is_monotonic: bool):
+        super().__init__(instrument_is_monotonic)
+        self._value = None
+        self._start_time_unix_nano = _time_ns()
 
+    def aggregate(self, measurement: Measurement) -> None:
+        with self._lock:
+            self._value = measurement.value
 
-class LastValueAggregation(Aggregation):
+    def collect(self) -> Optional[Sum]:
+        """
+        Atomically return a point for the current value of the metric.
+        """
+        if self._value is None:
+            return None
+
+        return Sum(
+            start_time_unix_nano=self._start_time_unix_nano,
+            time_unix_nano=_time_ns(),
+            value=self._value,
+            aggregation_temporality=AggregationTemporality.CUMULATIVE,
+            is_monotonic=self._instrument_is_monotonic,
+        )
 
-    """
-    This aggregation collects data for the SDK sum metric point.
-    """
 
-    def __init__(self, instrument):
+class LastValueAggregation(Aggregation[Gauge]):
+    def __init__(self):
+        super().__init__()
         self._value = None
-        self._timestamp = _time_ns()
 
-    def aggregate(self, value):
-        self._value = value
-        self._timestamp = _time_ns()
-
-    def make_point_and_reset(self):
-        pass
+    def aggregate(self, measurement: Measurement):
+        with self._lock:
+            self._value = measurement.value
 
+    def collect(self) -> Optional[Gauge]:
+        """
+        Atomically return a point for the current value of the metric.
+        """
+        if self._value is None:
+            return None
 
-class ExplicitBucketHistogramAggregation(Aggregation):
+        return Gauge(
+            time_unix_nano=_time_ns(),
+            value=self._value,
+        )
 
-    """
-    This aggregation collects data for the SDK sum metric point.
-    """
 
+class ExplicitBucketHistogramAggregation(Aggregation[Histogram]):
     def __init__(
         self,
-        instrument,
-        *args,
-        boundaries=(0, 5, 10, 25, 50, 75, 100, 250, 500, 1000),
-        record_min_max=True,
+        boundaries: Sequence[int] = (
+            0,
+            5,
+            10,
+            25,
+            50,
+            75,
+            100,
+            250,
+            500,
+            1000,
+        ),
+        record_min_max: bool = True,
     ):
         super().__init__()
         self._value = OrderedDict([(key, 0) for key in (*boundaries, inf)])
         self._min = inf
         self._max = -inf
         self._sum = 0
-        self._instrument = instrument
         self._record_min_max = record_min_max
+        self._start_time_unix_nano = _time_ns()
+        self._boundaries = boundaries
 
-    @property
-    def min(self):
-        if not self._record_min_max:
-            _logger.warning("Min is not being recorded")
-
-        return self._min
+    def aggregate(self, measurement: Measurement) -> None:
 
-    @property
-    def max(self):
-        if not self._record_min_max:
-            _logger.warning("Max is not being recorded")
+        value = measurement.value
 
-        return self._max
-
-    @property
-    def sum(self):
-        if isinstance(self._instrument, _Monotonic):
-            return self._sum
-
-        _logger.warning(
-            "Sum is not filled out when the associated "
-            "instrument is not monotonic"
-        )
-        return None
-
-    def aggregate(self, value):
         if self._record_min_max:
             self._min = min(self._min, value)
             self._max = max(self._max, value)
 
-        if isinstance(self._instrument, _Monotonic):
-            self._sum += value
+        self._sum += value
 
         for key in self._value.keys():
 
             if value < key:
-                self._value[key] = self._value[key] + value
+                self._value[key] = self._value[key] + 1
 
                 break
 
-    def make_point_and_reset(self):
-        pass
+    def collect(self) -> Optional[Histogram]:
+        """
+        Atomically return a point for the current value of the metric.
+        """
+        now = _time_ns()
+
+        with self._lock:
+            value = self._value
+            start_time_unix_nano = self._start_time_unix_nano
+
+            self._value = OrderedDict(
+                [(key, 0) for key in (*self._boundaries, inf)]
+            )
+            self._start_time_unix_nano = now + 1
+
+        return Histogram(
+            start_time_unix_nano=start_time_unix_nano,
+            time_unix_nano=now,
+            bucket_counts=tuple(value.values()),
+            explicit_bounds=self._boundaries,
+            aggregation_temporality=AggregationTemporality.DELTA,
+        )
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/measurement.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/measurement.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/measurement.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/measurement.py
@@ -12,6 +12,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from dataclasses import dataclass
+from typing import Union
 
+from opentelemetry.util.types import Attributes
+
+
+@dataclass(frozen=True)
 class Measurement:
-    pass
+    value: Union[int, float]
+    attributes: Attributes = None
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/point.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/point.py
new file mode 100644
--- /dev/null
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/point.py
@@ -0,0 +1,43 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from dataclasses import dataclass
+from typing import Sequence, Union
+
+
+@dataclass(frozen=True)
+class Sum:
+    start_time_unix_nano: int
+    time_unix_nano: int
+    value: Union[int, float]
+    aggregation_temporality: int
+    is_monotonic: bool
+
+
+@dataclass(frozen=True)
+class Gauge:
+    time_unix_nano: int
+    value: Union[int, float]
+
+
+@dataclass(frozen=True)
+class Histogram:
+    start_time_unix_nano: int
+    time_unix_nano: int
+    bucket_counts: Sequence[int]
+    explicit_bounds: Sequence[float]
+    aggregation_temporality: int
+
+
+PointT = Union[Sum, Gauge, Histogram]


Test patch:
diff --git a/opentelemetry-sdk/tests/metrics/test_aggregation.py b/opentelemetry-sdk/tests/metrics/test_aggregation.py
--- a/opentelemetry-sdk/tests/metrics/test_aggregation.py
+++ b/opentelemetry-sdk/tests/metrics/test_aggregation.py
@@ -13,82 +13,227 @@
 # limitations under the License.
 
 
-from logging import WARNING
 from math import inf
+from time import sleep
 from unittest import TestCase
-from unittest.mock import Mock
 
 from opentelemetry.sdk._metrics.aggregation import (
+    AsynchronousSumAggregation,
     ExplicitBucketHistogramAggregation,
     LastValueAggregation,
-    SumAggregation,
+    SynchronousSumAggregation,
+    _InstrumentMonotonicityAwareAggregation,
 )
+from opentelemetry.sdk._metrics.measurement import Measurement
 
 
-class TestSumAggregation(TestCase):
+class TestSynchronousSumAggregation(TestCase):
+    def test_instrument_monotonicity_awareness(self):
+        """
+        `SynchronousSumAggregation` is aware of the instrument monotonicity
+        """
+
+        synchronous_sum_aggregation = SynchronousSumAggregation(True)
+        self.assertIsInstance(
+            synchronous_sum_aggregation,
+            _InstrumentMonotonicityAwareAggregation,
+        )
+        self.assertTrue(synchronous_sum_aggregation._instrument_is_monotonic)
+
+        synchronous_sum_aggregation = SynchronousSumAggregation(False)
+        self.assertFalse(synchronous_sum_aggregation._instrument_is_monotonic)
+
+    def test_aggregate(self):
+        """
+        `SynchronousSumAggregation` aggregates data for sum metric points
+        """
+
+        synchronous_sum_aggregation = SynchronousSumAggregation(True)
+
+        synchronous_sum_aggregation.aggregate(Measurement(1))
+        synchronous_sum_aggregation.aggregate(Measurement(2))
+        synchronous_sum_aggregation.aggregate(Measurement(3))
+
+        self.assertEqual(synchronous_sum_aggregation._value, 6)
+
+        synchronous_sum_aggregation = SynchronousSumAggregation(True)
+
+        synchronous_sum_aggregation.aggregate(Measurement(1))
+        synchronous_sum_aggregation.aggregate(Measurement(-2))
+        synchronous_sum_aggregation.aggregate(Measurement(3))
+
+        self.assertEqual(synchronous_sum_aggregation._value, 2)
+
+    def test_collect(self):
+        """
+        `SynchronousSumAggregation` collects sum metric points
+        """
+
+        synchronous_sum_aggregation = SynchronousSumAggregation(True)
+
+        synchronous_sum_aggregation.aggregate(Measurement(1))
+        first_sum = synchronous_sum_aggregation.collect()
+
+        self.assertEqual(first_sum.value, 1)
+        self.assertTrue(first_sum.is_monotonic)
+
+        synchronous_sum_aggregation.aggregate(Measurement(1))
+        second_sum = synchronous_sum_aggregation.collect()
+
+        self.assertEqual(second_sum.value, 1)
+        self.assertTrue(second_sum.is_monotonic)
+
+        self.assertGreater(
+            second_sum.start_time_unix_nano, first_sum.start_time_unix_nano
+        )
+
+
+class TestAsynchronousSumAggregation(TestCase):
+    def test_instrument_monotonicity_awareness(self):
+        """
+        `AsynchronousSumAggregation` is aware of the instrument monotonicity
+        """
+
+        asynchronous_sum_aggregation = AsynchronousSumAggregation(True)
+        self.assertIsInstance(
+            asynchronous_sum_aggregation,
+            _InstrumentMonotonicityAwareAggregation,
+        )
+        self.assertTrue(asynchronous_sum_aggregation._instrument_is_monotonic)
+
+        asynchronous_sum_aggregation = AsynchronousSumAggregation(False)
+        self.assertFalse(asynchronous_sum_aggregation._instrument_is_monotonic)
+
     def test_aggregate(self):
         """
-        `SumAggregation` collects data for sum metric points
+        `AsynchronousSumAggregation` aggregates data for sum metric points
         """
 
-        sum_aggregation = SumAggregation(Mock())
+        asynchronous_sum_aggregation = AsynchronousSumAggregation(True)
+
+        asynchronous_sum_aggregation.aggregate(Measurement(1))
+        self.assertEqual(asynchronous_sum_aggregation._value, 1)
+
+        asynchronous_sum_aggregation.aggregate(Measurement(2))
+        self.assertEqual(asynchronous_sum_aggregation._value, 2)
+
+        asynchronous_sum_aggregation.aggregate(Measurement(3))
+        self.assertEqual(asynchronous_sum_aggregation._value, 3)
+
+        asynchronous_sum_aggregation = AsynchronousSumAggregation(True)
 
-        sum_aggregation.aggregate(1)
-        sum_aggregation.aggregate(2)
-        sum_aggregation.aggregate(3)
+        asynchronous_sum_aggregation.aggregate(Measurement(1))
+        self.assertEqual(asynchronous_sum_aggregation._value, 1)
+
+        asynchronous_sum_aggregation.aggregate(Measurement(-2))
+        self.assertEqual(asynchronous_sum_aggregation._value, -2)
+
+        asynchronous_sum_aggregation.aggregate(Measurement(3))
+        self.assertEqual(asynchronous_sum_aggregation._value, 3)
+
+    def test_collect(self):
+        """
+        `AsynchronousSumAggregation` collects sum metric points
+        """
 
-        self.assertEqual(sum_aggregation.value, 6)
+        asynchronous_sum_aggregation = AsynchronousSumAggregation(True)
 
-        sum_aggregation = SumAggregation(Mock())
+        self.assertIsNone(asynchronous_sum_aggregation.collect())
 
-        sum_aggregation.aggregate(1)
-        sum_aggregation.aggregate(-2)
-        sum_aggregation.aggregate(3)
+        asynchronous_sum_aggregation.aggregate(Measurement(1))
+        first_sum = asynchronous_sum_aggregation.collect()
 
-        self.assertEqual(sum_aggregation.value, 2)
+        self.assertEqual(first_sum.value, 1)
+        self.assertTrue(first_sum.is_monotonic)
+
+        asynchronous_sum_aggregation.aggregate(Measurement(1))
+        second_sum = asynchronous_sum_aggregation.collect()
+
+        self.assertEqual(second_sum.value, 1)
+        self.assertTrue(second_sum.is_monotonic)
+
+        self.assertEqual(
+            second_sum.start_time_unix_nano, first_sum.start_time_unix_nano
+        )
 
 
 class TestLastValueAggregation(TestCase):
+    def test_instrument_monotonicity_awareness(self):
+        """
+        `LastValueAggregation` is not aware of the instrument monotonicity
+        """
+
+        sum_aggregation = LastValueAggregation()
+        self.assertNotIsInstance(
+            sum_aggregation, _InstrumentMonotonicityAwareAggregation
+        )
+
     def test_aggregate(self):
         """
         `LastValueAggregation` collects data for gauge metric points with delta
         temporality
         """
 
-        last_value_aggregation = LastValueAggregation(Mock())
+        last_value_aggregation = LastValueAggregation()
 
-        last_value_aggregation.aggregate(1)
-        self.assertEqual(last_value_aggregation.value, 1)
+        last_value_aggregation.aggregate(Measurement(1))
+        self.assertEqual(last_value_aggregation._value, 1)
 
-        last_value_aggregation.aggregate(2)
-        self.assertEqual(last_value_aggregation.value, 2)
+        last_value_aggregation.aggregate(Measurement(2))
+        self.assertEqual(last_value_aggregation._value, 2)
 
-        last_value_aggregation.aggregate(3)
-        self.assertEqual(last_value_aggregation.value, 3)
+        last_value_aggregation.aggregate(Measurement(3))
+        self.assertEqual(last_value_aggregation._value, 3)
+
+    def test_collect(self):
+        """
+        `LastValueAggregation` collects sum metric points
+        """
+
+        last_value_aggregation = LastValueAggregation()
+
+        self.assertIsNone(last_value_aggregation.collect())
+
+        last_value_aggregation.aggregate(Measurement(1))
+        first_gauge = last_value_aggregation.collect()
+
+        self.assertEqual(first_gauge.value, 1)
+
+        last_value_aggregation.aggregate(Measurement(1))
+
+        # CI fails the last assertion without this
+        sleep(0.1)
+
+        second_gauge = last_value_aggregation.collect()
+
+        self.assertEqual(second_gauge.value, 1)
+
+        self.assertGreater(
+            second_gauge.time_unix_nano, first_gauge.time_unix_nano
+        )
 
 
 class TestExplicitBucketHistogramAggregation(TestCase):
     def test_aggregate(self):
         """
-        `ExplicitBucketHistogramAggregation` collects data for explicit_bucket_histogram metric points
+        `ExplicitBucketHistogramAggregation` collects data for
+        explicit bucket histogram metric points
         """
 
         explicit_bucket_histogram_aggregation = (
-            ExplicitBucketHistogramAggregation(Mock())
+            ExplicitBucketHistogramAggregation()
         )
 
-        explicit_bucket_histogram_aggregation.aggregate(-1)
-        explicit_bucket_histogram_aggregation.aggregate(2)
-        explicit_bucket_histogram_aggregation.aggregate(7)
-        explicit_bucket_histogram_aggregation.aggregate(8)
-        explicit_bucket_histogram_aggregation.aggregate(9999)
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(-1))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(2))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(7))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(8))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(9999))
 
-        self.assertEqual(explicit_bucket_histogram_aggregation.value[0], -1)
-        self.assertEqual(explicit_bucket_histogram_aggregation.value[5], 2)
-        self.assertEqual(explicit_bucket_histogram_aggregation.value[10], 15)
-        self.assertEqual(
-            explicit_bucket_histogram_aggregation.value[inf], 9999
-        )
+        self.assertEqual(explicit_bucket_histogram_aggregation._value[0], 1)
+        self.assertEqual(explicit_bucket_histogram_aggregation._value[5], 1)
+        self.assertEqual(explicit_bucket_histogram_aggregation._value[10], 2)
+        self.assertEqual(explicit_bucket_histogram_aggregation._value[inf], 1)
 
     def test_min_max(self):
         """
@@ -97,30 +242,57 @@ def test_min_max(self):
         """
 
         explicit_bucket_histogram_aggregation = (
-            ExplicitBucketHistogramAggregation(Mock())
+            ExplicitBucketHistogramAggregation()
+        )
+
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(-1))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(2))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(7))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(8))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(9999))
+
+        self.assertEqual(explicit_bucket_histogram_aggregation._min, -1)
+        self.assertEqual(explicit_bucket_histogram_aggregation._max, 9999)
+
+        explicit_bucket_histogram_aggregation = (
+            ExplicitBucketHistogramAggregation(record_min_max=False)
         )
 
-        explicit_bucket_histogram_aggregation.aggregate(-1)
-        explicit_bucket_histogram_aggregation.aggregate(2)
-        explicit_bucket_histogram_aggregation.aggregate(7)
-        explicit_bucket_histogram_aggregation.aggregate(8)
-        explicit_bucket_histogram_aggregation.aggregate(9999)
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(-1))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(2))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(7))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(8))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(9999))
+
+        self.assertEqual(explicit_bucket_histogram_aggregation._min, inf)
+        self.assertEqual(explicit_bucket_histogram_aggregation._max, -inf)
 
-        self.assertEqual(explicit_bucket_histogram_aggregation.min, -1)
-        self.assertEqual(explicit_bucket_histogram_aggregation.max, 9999)
+    def test_collect(self):
+        """
+        `ExplicitBucketHistogramAggregation` collects sum metric points
+        """
 
         explicit_bucket_histogram_aggregation = (
-            ExplicitBucketHistogramAggregation(Mock(), record_min_max=False)
+            ExplicitBucketHistogramAggregation()
+        )
+
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(1))
+        first_histogram = explicit_bucket_histogram_aggregation.collect()
+
+        self.assertEqual(
+            first_histogram.bucket_counts, (0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0)
         )
 
-        explicit_bucket_histogram_aggregation.aggregate(-1)
-        explicit_bucket_histogram_aggregation.aggregate(2)
-        explicit_bucket_histogram_aggregation.aggregate(7)
-        explicit_bucket_histogram_aggregation.aggregate(8)
-        explicit_bucket_histogram_aggregation.aggregate(9999)
+        # CI fails the last assertion without this
+        sleep(0.1)
 
-        with self.assertLogs(level=WARNING):
-            self.assertEqual(explicit_bucket_histogram_aggregation.min, inf)
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(1))
+        second_histogram = explicit_bucket_histogram_aggregation.collect()
 
-        with self.assertLogs(level=WARNING):
-            self.assertEqual(explicit_bucket_histogram_aggregation.max, -inf)
+        self.assertEqual(
+            second_histogram.bucket_counts, (0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0)
+        )
+
+        self.assertGreater(
+            second_histogram.time_unix_nano, first_histogram.time_unix_nano
+        )