Repository: open-telemetry/opentelemetry-python

Issue:
Metric points from the SDK should be temporally aligned
When a MetricReader starts a collection, the SDK should generate a timestamp to use for all points in that collection round. All of the underlying aggregations should have this time provided to them, instead of using `_time_ns()`.

Nice to have: log a warning when the collection interval skewed significantly.

### Why is this needed

- Consumers are expecting points to be aligned to indicate that semantically the points came from the same collection and that there is no single-writer violation. See the [data model](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/datamodel.md#sums-detecting-alignment-issues).
- For delta points, consumers can tell that there are no missing points (no gaps) ([link](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/datamodel.md#:~:text=and%20so%20on.-,Delta%20temporality,-means%20that%20successive))
  > Delta temporality means that successive data points advance the starting timestamp. For example, from start time T0, delta data points cover time ranges (T0, T1], (T1, T2], (T2, T3], and so on.
  
  Notice adjacent interval `start_time_unix_nano` share the previous point's `time_unix_nano`.



Discussion:
@aabmass When does the skew happen? If the next collection didn't start at the expected time for some reason?

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
@@ -28,6 +28,7 @@
 from opentelemetry.sdk._metrics._internal.measurement import Measurement
 from opentelemetry.sdk._metrics._internal.point import DataPointT
 from opentelemetry.sdk._metrics._internal.view import View
+from opentelemetry.util._time import _time_ns
 
 _logger = getLogger(__name__)
 
@@ -39,6 +40,7 @@ def __init__(
         instrument: Instrument,
         instrument_class_aggregation: Dict[type, Aggregation],
     ):
+        self._start_time_unix_nano = _time_ns()
         self._view = view
         self._instrument = instrument
         self._attributes_aggregation: Dict[frozenset, _Aggregation] = {}
@@ -50,12 +52,12 @@ def __init__(
         )
         if not isinstance(self._view._aggregation, DefaultAggregation):
             self._aggregation = self._view._aggregation._create_aggregation(
-                self._instrument, None
+                self._instrument, None, 0
             )
         else:
             self._aggregation = self._instrument_class_aggregation[
                 self._instrument.__class__
-            ]._create_aggregation(self._instrument, None)
+            ]._create_aggregation(self._instrument, None, 0)
 
     def conflicts(self, other: "_ViewInstrumentMatch") -> bool:
         # pylint: disable=protected-access
@@ -103,21 +105,31 @@ def consume_measurement(self, measurement: Measurement) -> None:
                     ):
                         aggregation = (
                             self._view._aggregation._create_aggregation(
-                                self._instrument, attributes
+                                self._instrument,
+                                attributes,
+                                self._start_time_unix_nano,
                             )
                         )
                     else:
                         aggregation = self._instrument_class_aggregation[
                             self._instrument.__class__
-                        ]._create_aggregation(self._instrument, attributes)
+                        ]._create_aggregation(
+                            self._instrument,
+                            attributes,
+                            self._start_time_unix_nano,
+                        )
                     self._attributes_aggregation[attributes] = aggregation
 
         self._attributes_aggregation[attributes].aggregate(measurement)
 
     def collect(
-        self, aggregation_temporality: AggregationTemporality
+        self,
+        aggregation_temporality: AggregationTemporality,
+        collection_start_nanos: int,
     ) -> Iterable[DataPointT]:
 
         with self._lock:
             for aggregation in self._attributes_aggregation.values():
-                yield aggregation.collect(aggregation_temporality)
+                yield aggregation.collect(
+                    aggregation_temporality, collection_start_nanos
+                )
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/aggregation.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/aggregation.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/aggregation.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/_internal/aggregation.py
@@ -41,7 +41,6 @@
     NumberDataPoint,
     Sum,
 )
-from opentelemetry.util._time import _time_ns
 from opentelemetry.util.types import Attributes
 
 _DataPointVarT = TypeVar("_DataPointVarT", NumberDataPoint, HistogramDataPoint)
@@ -75,6 +74,7 @@ def aggregate(self, measurement: Measurement) -> None:
     def collect(
         self,
         aggregation_temporality: AggregationTemporality,
+        collection_start_nano: int,
     ) -> Optional[_DataPointVarT]:
         pass
 
@@ -86,92 +86,22 @@ def aggregate(self, measurement: Measurement) -> None:
     def collect(
         self,
         aggregation_temporality: AggregationTemporality,
+        collection_start_nano: int,
     ) -> Optional[_DataPointVarT]:
         pass
 
 
-class Aggregation(ABC):
-    """
-    Base class for all aggregation types.
-    """
-
-    @abstractmethod
-    def _create_aggregation(
-        self, instrument: Instrument, attributes: Attributes
-    ) -> _Aggregation:
-        """Creates an aggregation"""
-
-
-class DefaultAggregation(Aggregation):
-    """
-    The default aggregation to be used in a `View`.
-
-    This aggregation will create an actual aggregation depending on the
-    instrument type, as specified next:
-
-    ==================================================== ====================================
-    Instrument                                           Aggregation
-    ==================================================== ====================================
-    `opentelemetry.sdk._metrics.Counter`                 `SumAggregation`
-    `opentelemetry.sdk._metrics.UpDownCounter`           `SumAggregation`
-    `opentelemetry.sdk._metrics.ObservableCounter`       `SumAggregation`
-    `opentelemetry.sdk._metrics.ObservableUpDownCounter` `SumAggregation`
-    `opentelemetry.sdk._metrics.Histogram`               `ExplicitBucketHistogramAggregation`
-    `opentelemetry.sdk._metrics.ObservableGauge`         `LastValueAggregation`
-    ==================================================== ====================================
-    """
-
-    def _create_aggregation(
-        self, instrument: Instrument, attributes: Attributes
-    ) -> _Aggregation:
-
-        # pylint: disable=too-many-return-statements
-        if isinstance(instrument, Counter):
-            return _SumAggregation(
-                attributes,
-                instrument_is_monotonic=True,
-                instrument_temporality=AggregationTemporality.DELTA,
-            )
-        if isinstance(instrument, UpDownCounter):
-            return _SumAggregation(
-                attributes,
-                instrument_is_monotonic=False,
-                instrument_temporality=AggregationTemporality.DELTA,
-            )
-
-        if isinstance(instrument, ObservableCounter):
-            return _SumAggregation(
-                attributes,
-                instrument_is_monotonic=True,
-                instrument_temporality=AggregationTemporality.CUMULATIVE,
-            )
-
-        if isinstance(instrument, ObservableUpDownCounter):
-            return _SumAggregation(
-                attributes,
-                instrument_is_monotonic=False,
-                instrument_temporality=AggregationTemporality.CUMULATIVE,
-            )
-
-        if isinstance(instrument, Histogram):
-            return _ExplicitBucketHistogramAggregation(attributes)
-
-        if isinstance(instrument, ObservableGauge):
-            return _LastValueAggregation(attributes)
-
-        raise Exception(f"Invalid instrument type {type(instrument)} found")
-
-
 class _SumAggregation(_Aggregation[Sum]):
     def __init__(
         self,
         attributes: Attributes,
         instrument_is_monotonic: bool,
         instrument_temporality: AggregationTemporality,
+        start_time_unix_nano: int,
     ):
         super().__init__(attributes)
 
-        self._start_time_unix_nano = _time_ns()
+        self._start_time_unix_nano = start_time_unix_nano
         self._instrument_temporality = instrument_temporality
         self._instrument_is_monotonic = instrument_is_monotonic
 
@@ -187,14 +117,14 @@ def aggregate(self, measurement: Measurement) -> None:
             self._value = self._value + measurement.value
 
     def collect(
-        self, aggregation_temporality: AggregationTemporality
+        self,
+        aggregation_temporality: AggregationTemporality,
+        collection_start_nano: int,
     ) -> Optional[NumberDataPoint]:
         """
         Atomically return a point for the current value of the metric and
         reset the aggregation value.
         """
-        now = _time_ns()
-
         if self._instrument_temporality is AggregationTemporality.DELTA:
 
             with self._lock:
@@ -202,7 +132,7 @@ def collect(
                 start_time_unix_nano = self._start_time_unix_nano
 
                 self._value = 0
-                self._start_time_unix_nano = now + 1
+                self._start_time_unix_nano = collection_start_nano
 
         else:
 
@@ -216,7 +146,7 @@ def collect(
         current_point = NumberDataPoint(
             attributes=self._attributes,
             start_time_unix_nano=start_time_unix_nano,
-            time_unix_nano=now,
+            time_unix_nano=collection_start_nano,
             value=value,
         )
 
@@ -263,6 +193,7 @@ def aggregate(self, measurement: Measurement):
     def collect(
         self,
         aggregation_temporality: AggregationTemporality,
+        collection_start_nano: int,
     ) -> Optional[_DataPointVarT]:
         """
         Atomically return a point for the current value of the metric.
@@ -276,7 +207,7 @@ def collect(
         return NumberDataPoint(
             attributes=self._attributes,
             start_time_unix_nano=0,
-            time_unix_nano=_time_ns(),
+            time_unix_nano=collection_start_nano,
             value=value,
         )
 
@@ -285,6 +216,7 @@ class _ExplicitBucketHistogramAggregation(_Aggregation[HistogramPoint]):
     def __init__(
         self,
         attributes: Attributes,
+        start_time_unix_nano: int,
         boundaries: Sequence[float] = (
             0.0,
             5.0,
@@ -306,7 +238,7 @@ def __init__(
         self._max = -inf
         self._sum = 0
         self._record_min_max = record_min_max
-        self._start_time_unix_nano = _time_ns()
+        self._start_time_unix_nano = start_time_unix_nano
         # It is assumed that the "natural" aggregation temporality for a
         # Histogram instrument is DELTA, like the "natural" aggregation
         # temporality for a Counter is DELTA and the "natural" aggregation
@@ -331,12 +263,11 @@ def aggregate(self, measurement: Measurement) -> None:
     def collect(
         self,
         aggregation_temporality: AggregationTemporality,
+        collection_start_nano: int,
     ) -> Optional[_DataPointVarT]:
         """
         Atomically return a point for the current value of the metric.
         """
-        now = _time_ns()
-
         with self._lock:
             if not any(self._bucket_counts):
                 return None
@@ -348,7 +279,7 @@ def collect(
             min_ = self._min
 
             self._bucket_counts = self._get_empty_bucket_counts()
-            self._start_time_unix_nano = now + 1
+            self._start_time_unix_nano = collection_start_nano
             self._sum = 0
             self._min = inf
             self._max = -inf
@@ -356,7 +287,7 @@ def collect(
         current_point = HistogramDataPoint(
             attributes=self._attributes,
             start_time_unix_nano=start_time_unix_nano,
-            time_unix_nano=now,
+            time_unix_nano=collection_start_nano,
             count=sum(bucket_counts),
             sum=sum_,
             bucket_counts=tuple(bucket_counts),
@@ -413,6 +344,90 @@ def collect(
         return current_point
 
 
+class Aggregation(ABC):
+    """
+    Base class for all aggregation types.
+    """
+
+    @abstractmethod
+    def _create_aggregation(
+        self,
+        instrument: Instrument,
+        attributes: Attributes,
+        start_time_unix_nano: int,
+    ) -> _Aggregation:
+        """Creates an aggregation"""
+
+
+class DefaultAggregation(Aggregation):
+    """
+    The default aggregation to be used in a `View`.
+
+    This aggregation will create an actual aggregation depending on the
+    instrument type, as specified next:
+
+    ==================================================== ====================================
+    Instrument                                           Aggregation
+    ==================================================== ====================================
+    `opentelemetry.sdk._metrics.Counter`                 `SumAggregation`
+    `opentelemetry.sdk._metrics.UpDownCounter`           `SumAggregation`
+    `opentelemetry.sdk._metrics.ObservableCounter`       `SumAggregation`
+    `opentelemetry.sdk._metrics.ObservableUpDownCounter` `SumAggregation`
+    `opentelemetry.sdk._metrics.Histogram`               `ExplicitBucketHistogramAggregation`
+    `opentelemetry.sdk._metrics.ObservableGauge`         `LastValueAggregation`
+    ==================================================== ====================================
+    """
+
+    def _create_aggregation(
+        self,
+        instrument: Instrument,
+        attributes: Attributes,
+        start_time_unix_nano: int,
+    ) -> _Aggregation:
+
+        # pylint: disable=too-many-return-statements
+        if isinstance(instrument, Counter):
+            return _SumAggregation(
+                attributes,
+                instrument_is_monotonic=True,
+                instrument_temporality=AggregationTemporality.DELTA,
+                start_time_unix_nano=start_time_unix_nano,
+            )
+        if isinstance(instrument, UpDownCounter):
+            return _SumAggregation(
+                attributes,
+                instrument_is_monotonic=False,
+                instrument_temporality=AggregationTemporality.DELTA,
+                start_time_unix_nano=start_time_unix_nano,
+            )
+
+        if isinstance(instrument, ObservableCounter):
+            return _SumAggregation(
+                attributes,
+                instrument_is_monotonic=True,
+                instrument_temporality=AggregationTemporality.CUMULATIVE,
+                start_time_unix_nano=start_time_unix_nano,
+            )
+
+        if isinstance(instrument, ObservableUpDownCounter):
+            return _SumAggregation(
+                attributes,
+                instrument_is_monotonic=False,
+                instrument_temporality=AggregationTemporality.CUMULATIVE,
+                start_time_unix_nano=start_time_unix_nano,
+            )
+
+        if isinstance(instrument, Histogram):
+            return _ExplicitBucketHistogramAggregation(
+                attributes, start_time_unix_nano
+            )
+
+        if isinstance(instrument, ObservableGauge):
+            return _LastValueAggregation(attributes)
+
+        raise Exception(f"Invalid instrument type {type(instrument)} found")
+
+
 class ExplicitBucketHistogramAggregation(Aggregation):
     """This aggregation informs the SDK to collect:
 
@@ -447,10 +462,14 @@ def __init__(
         self._record_min_max = record_min_max
 
     def _create_aggregation(
-        self, instrument: Instrument, attributes: Attributes
+        self,
+        instrument: Instrument,
+        attributes: Attributes,
+        start_time_unix_nano: int,
     ) -> _Aggregation:
         return _ExplicitBucketHistogramAggregation(
             attributes,
+            start_time_unix_nano,
             self._boundaries,
             self._record_min_max,
         )
@@ -463,7 +482,10 @@ class SumAggregation(Aggregation):
     """
 
     def _create_aggregation(
-        self, instrument: Instrument, attributes: Attributes
+        self,
+        instrument: Instrument,
+        attributes: Attributes,
+        start_time_unix_nano: int,
     ) -> _Aggregation:
 
         temporality = AggregationTemporality.UNSPECIFIED
@@ -476,6 +498,7 @@ def _create_aggregation(
             attributes,
             isinstance(instrument, (Counter, ObservableCounter)),
             temporality,
+            start_time_unix_nano,
         )
 
 
@@ -488,7 +511,10 @@ class LastValueAggregation(Aggregation):
     """
 
     def _create_aggregation(
-        self, instrument: Instrument, attributes: Attributes
+        self,
+        instrument: Instrument,
+        attributes: Attributes,
+        start_time_unix_nano: int,
     ) -> _Aggregation:
         return _LastValueAggregation(attributes)
 
@@ -497,6 +523,9 @@ class DropAggregation(Aggregation):
     """Using this aggregation will make all measurements be ignored."""
 
     def _create_aggregation(
-        self, instrument: Instrument, attributes: Attributes
+        self,
+        instrument: Instrument,
+        attributes: Attributes,
+        start_time_unix_nano: int,
     ) -> _Aggregation:
         return _DropAggregation(attributes)
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
@@ -49,6 +49,7 @@
 )
 from opentelemetry.sdk._metrics._internal.view import View
 from opentelemetry.sdk.util.instrumentation import InstrumentationScope
+from opentelemetry.util._time import _time_ns
 
 _logger = getLogger(__name__)
 
@@ -128,6 +129,9 @@ def collect(self) -> MetricsData:
         # effect is that end times can be slightly skewed among the metric
         # streams produced by the SDK, but we still align the output timestamps
         # for a single instrument.
+
+        collection_start_nanos = _time_ns()
+
         with self._lock:
 
             instrumentation_scope_scope_metrics: (
@@ -154,7 +158,7 @@ def collect(self) -> MetricsData:
                         data = Sum(
                             aggregation_temporality=aggregation_temporality,
                             data_points=view_instrument_match.collect(
-                                aggregation_temporality
+                                aggregation_temporality, collection_start_nanos
                             ),
                             is_monotonic=isinstance(
                                 instrument, (Counter, ObservableCounter)
@@ -167,7 +171,7 @@ def collect(self) -> MetricsData:
                     ):
                         data = Gauge(
                             data_points=view_instrument_match.collect(
-                                aggregation_temporality
+                                aggregation_temporality, collection_start_nanos
                             )
                         )
                     elif isinstance(
@@ -177,7 +181,7 @@ def collect(self) -> MetricsData:
                     ):
                         data = Histogram(
                             data_points=view_instrument_match.collect(
-                                aggregation_temporality
+                                aggregation_temporality, collection_start_nanos
                             ),
                             aggregation_temporality=aggregation_temporality,
                         )


Test patch:
diff --git a/opentelemetry-sdk/tests/metrics/integration_test/test_time_align.py b/opentelemetry-sdk/tests/metrics/integration_test/test_time_align.py
new file mode 100644
--- /dev/null
+++ b/opentelemetry-sdk/tests/metrics/integration_test/test_time_align.py
@@ -0,0 +1,289 @@
+# 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 platform import system
+from time import sleep
+from unittest import TestCase
+
+from pytest import mark
+
+from opentelemetry.sdk._metrics import Counter, MeterProvider
+from opentelemetry.sdk._metrics.export import (
+    AggregationTemporality,
+    InMemoryMetricReader,
+)
+
+
+class TestTimeAlign(TestCase):
+    def test_time_align_cumulative(self):
+        reader = InMemoryMetricReader()
+        meter_provider = MeterProvider(metric_readers=[reader])
+
+        meter = meter_provider.get_meter("testmeter")
+
+        counter_0 = meter.create_counter("counter_0")
+        counter_1 = meter.create_counter("counter_1")
+
+        counter_0.add(10, {"label": "value1"})
+        counter_0.add(10, {"label": "value2"})
+        sleep(0.5)
+        counter_1.add(10, {"label": "value1"})
+        counter_1.add(10, {"label": "value2"})
+
+        metrics = reader.get_metrics_data()
+
+        data_points_0_0 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[0]
+            .data.data_points
+        )
+        data_points_0_1 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[1]
+            .data.data_points
+        )
+
+        self.assertEqual(
+            data_points_0_0[0].start_time_unix_nano,
+            data_points_0_0[1].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[0].start_time_unix_nano,
+            data_points_0_1[1].start_time_unix_nano,
+        )
+        self.assertNotEqual(
+            data_points_0_0[1].start_time_unix_nano,
+            data_points_0_1[0].start_time_unix_nano,
+        )
+
+        self.assertEqual(
+            data_points_0_0[0].time_unix_nano,
+            data_points_0_0[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[0].time_unix_nano,
+            data_points_0_1[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_0[1].time_unix_nano,
+            data_points_0_1[0].time_unix_nano,
+        )
+
+        counter_0.add(10, {"label": "value1"})
+        counter_0.add(10, {"label": "value2"})
+        sleep(0.5)
+        counter_1.add(10, {"label": "value1"})
+        counter_1.add(10, {"label": "value2"})
+
+        metrics = reader.get_metrics_data()
+
+        data_points_1_0 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[0]
+            .data.data_points
+        )
+        data_points_1_1 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[1]
+            .data.data_points
+        )
+
+        self.assertEqual(
+            data_points_1_0[0].start_time_unix_nano,
+            data_points_1_0[1].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_1_1[0].start_time_unix_nano,
+            data_points_1_1[1].start_time_unix_nano,
+        )
+        self.assertNotEqual(
+            data_points_1_0[1].start_time_unix_nano,
+            data_points_1_1[0].start_time_unix_nano,
+        )
+
+        self.assertEqual(
+            data_points_1_0[0].time_unix_nano,
+            data_points_1_0[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_1_1[0].time_unix_nano,
+            data_points_1_1[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_1_0[1].time_unix_nano,
+            data_points_1_1[0].time_unix_nano,
+        )
+
+        self.assertEqual(
+            data_points_0_0[0].start_time_unix_nano,
+            data_points_1_0[0].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_0[1].start_time_unix_nano,
+            data_points_1_0[1].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[0].start_time_unix_nano,
+            data_points_1_1[0].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[1].start_time_unix_nano,
+            data_points_1_1[1].start_time_unix_nano,
+        )
+
+    @mark.skipif(
+        system() != "Linux", reason="test failing in CI when run in Windows"
+    )
+    def test_time_align_delta(self):
+        reader = InMemoryMetricReader(
+            preferred_temporality={Counter: AggregationTemporality.DELTA}
+        )
+        meter_provider = MeterProvider(metric_readers=[reader])
+
+        meter = meter_provider.get_meter("testmeter")
+
+        counter_0 = meter.create_counter("counter_0")
+        counter_1 = meter.create_counter("counter_1")
+
+        counter_0.add(10, {"label": "value1"})
+        counter_0.add(10, {"label": "value2"})
+        sleep(0.5)
+        counter_1.add(10, {"label": "value1"})
+        counter_1.add(10, {"label": "value2"})
+
+        metrics = reader.get_metrics_data()
+
+        data_points_0_0 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[0]
+            .data.data_points
+        )
+        data_points_0_1 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[1]
+            .data.data_points
+        )
+
+        self.assertEqual(
+            data_points_0_0[0].start_time_unix_nano,
+            data_points_0_0[1].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[0].start_time_unix_nano,
+            data_points_0_1[1].start_time_unix_nano,
+        )
+        self.assertNotEqual(
+            data_points_0_0[1].start_time_unix_nano,
+            data_points_0_1[0].start_time_unix_nano,
+        )
+
+        self.assertEqual(
+            data_points_0_0[0].time_unix_nano,
+            data_points_0_0[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[0].time_unix_nano,
+            data_points_0_1[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_0[1].time_unix_nano,
+            data_points_0_1[0].time_unix_nano,
+        )
+
+        counter_0.add(10, {"label": "value1"})
+        counter_0.add(10, {"label": "value2"})
+        sleep(0.5)
+        counter_1.add(10, {"label": "value1"})
+        counter_1.add(10, {"label": "value2"})
+
+        metrics = reader.get_metrics_data()
+
+        data_points_1_0 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[0]
+            .data.data_points
+        )
+        data_points_1_1 = list(
+            metrics.resource_metrics[0]
+            .scope_metrics[0]
+            .metrics[1]
+            .data.data_points
+        )
+
+        self.assertEqual(
+            data_points_1_0[0].start_time_unix_nano,
+            data_points_1_0[1].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_1_1[0].start_time_unix_nano,
+            data_points_1_1[1].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_1_0[1].start_time_unix_nano,
+            data_points_1_1[0].start_time_unix_nano,
+        )
+
+        self.assertEqual(
+            data_points_1_0[0].time_unix_nano,
+            data_points_1_0[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_1_1[0].time_unix_nano,
+            data_points_1_1[1].time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_1_0[1].time_unix_nano,
+            data_points_1_1[0].time_unix_nano,
+        )
+
+        self.assertNotEqual(
+            data_points_0_0[0].start_time_unix_nano,
+            data_points_1_0[0].start_time_unix_nano,
+        )
+        self.assertNotEqual(
+            data_points_0_0[1].start_time_unix_nano,
+            data_points_1_0[1].start_time_unix_nano,
+        )
+        self.assertNotEqual(
+            data_points_0_1[0].start_time_unix_nano,
+            data_points_1_1[0].start_time_unix_nano,
+        )
+        self.assertNotEqual(
+            data_points_0_1[1].start_time_unix_nano,
+            data_points_1_1[1].start_time_unix_nano,
+        )
+
+        self.assertEqual(
+            data_points_0_0[0].time_unix_nano,
+            data_points_1_0[0].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_0[1].time_unix_nano,
+            data_points_1_0[1].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[0].time_unix_nano,
+            data_points_1_1[0].start_time_unix_nano,
+        )
+        self.assertEqual(
+            data_points_0_1[1].time_unix_nano,
+            data_points_1_1[1].start_time_unix_nano,
+        )
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
@@ -58,7 +58,7 @@ def test_aggregate_delta(self):
         """
 
         synchronous_sum_aggregation = _SumAggregation(
-            True, AggregationTemporality.DELTA, Mock()
+            Mock(), True, AggregationTemporality.DELTA, 0
         )
 
         synchronous_sum_aggregation.aggregate(measurement(1))
@@ -68,7 +68,7 @@ def test_aggregate_delta(self):
         self.assertEqual(synchronous_sum_aggregation._value, 6)
 
         synchronous_sum_aggregation = _SumAggregation(
-            Mock(), True, AggregationTemporality.DELTA
+            Mock(), True, AggregationTemporality.DELTA, 0
         )
 
         synchronous_sum_aggregation.aggregate(measurement(1))
@@ -83,7 +83,7 @@ def test_aggregate_cumulative(self):
         """
 
         synchronous_sum_aggregation = _SumAggregation(
-            True, AggregationTemporality.CUMULATIVE, Mock()
+            Mock(), True, AggregationTemporality.CUMULATIVE, 0
         )
 
         synchronous_sum_aggregation.aggregate(measurement(1))
@@ -93,7 +93,7 @@ def test_aggregate_cumulative(self):
         self.assertEqual(synchronous_sum_aggregation._value, 6)
 
         synchronous_sum_aggregation = _SumAggregation(
-            Mock(), True, AggregationTemporality.CUMULATIVE
+            Mock(), True, AggregationTemporality.CUMULATIVE, 0
         )
 
         synchronous_sum_aggregation.aggregate(measurement(1))
@@ -108,19 +108,23 @@ def test_collect_delta(self):
         """
 
         synchronous_sum_aggregation = _SumAggregation(
-            {}, True, AggregationTemporality.DELTA
+            Mock(), True, AggregationTemporality.DELTA, 0
         )
 
         synchronous_sum_aggregation.aggregate(measurement(1))
+        # 1 is used here directly to simulate the instant the first
+        # collection process starts.
         first_sum = synchronous_sum_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 1
         )
 
         self.assertEqual(first_sum.value, 1)
 
         synchronous_sum_aggregation.aggregate(measurement(1))
+        # 2 is used here directly to simulate the instant the first
+        # collection process starts.
         second_sum = synchronous_sum_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 2
         )
 
         self.assertEqual(second_sum.value, 2)
@@ -130,19 +134,23 @@ def test_collect_delta(self):
         )
 
         synchronous_sum_aggregation = _SumAggregation(
-            {}, True, AggregationTemporality.DELTA
+            Mock(), True, AggregationTemporality.DELTA, 0
         )
 
         synchronous_sum_aggregation.aggregate(measurement(1))
+        # 1 is used here directly to simulate the instant the first
+        # collection process starts.
         first_sum = synchronous_sum_aggregation.collect(
-            AggregationTemporality.DELTA
+            AggregationTemporality.DELTA, 1
         )
 
         self.assertEqual(first_sum.value, 1)
 
         synchronous_sum_aggregation.aggregate(measurement(1))
+        # 2 is used here directly to simulate the instant the first
+        # collection process starts.
         second_sum = synchronous_sum_aggregation.collect(
-            AggregationTemporality.DELTA
+            AggregationTemporality.DELTA, 2
         )
 
         self.assertEqual(second_sum.value, 1)
@@ -157,17 +165,21 @@ def test_collect_cumulative(self):
         """
 
         sum_aggregation = _SumAggregation(
-            {}, True, AggregationTemporality.CUMULATIVE
+            Mock(), True, AggregationTemporality.CUMULATIVE, 0
         )
 
         sum_aggregation.aggregate(measurement(1))
-        first_sum = sum_aggregation.collect(AggregationTemporality.CUMULATIVE)
+        first_sum = sum_aggregation.collect(
+            AggregationTemporality.CUMULATIVE, 1
+        )
 
         self.assertEqual(first_sum.value, 1)
 
         # should have been reset after first collect
         sum_aggregation.aggregate(measurement(1))
-        second_sum = sum_aggregation.collect(AggregationTemporality.CUMULATIVE)
+        second_sum = sum_aggregation.collect(
+            AggregationTemporality.CUMULATIVE, 1
+        )
 
         self.assertEqual(second_sum.value, 1)
 
@@ -176,7 +188,9 @@ def test_collect_cumulative(self):
         )
 
         # if no point seen for a whole interval, should return None
-        third_sum = sum_aggregation.collect(AggregationTemporality.CUMULATIVE)
+        third_sum = sum_aggregation.collect(
+            AggregationTemporality.CUMULATIVE, 1
+        )
         self.assertIsNone(third_sum)
 
 
@@ -206,12 +220,16 @@ def test_collect(self):
         last_value_aggregation = _LastValueAggregation(Mock())
 
         self.assertIsNone(
-            last_value_aggregation.collect(AggregationTemporality.CUMULATIVE)
+            last_value_aggregation.collect(
+                AggregationTemporality.CUMULATIVE, 1
+            )
         )
 
         last_value_aggregation.aggregate(measurement(1))
+        # 1 is used here directly to simulate the instant the first
+        # collection process starts.
         first_number_data_point = last_value_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 1
         )
         self.assertIsInstance(first_number_data_point, NumberDataPoint)
 
@@ -222,8 +240,10 @@ def test_collect(self):
         # CI fails the last assertion without this
         sleep(0.1)
 
+        # 2 is used here directly to simulate the instant the second
+        # collection process starts.
         second_number_data_point = last_value_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 2
         )
 
         self.assertEqual(second_number_data_point.value, 1)
@@ -233,9 +253,10 @@ def test_collect(self):
             first_number_data_point.time_unix_nano,
         )
 
-        # if no observation seen for the interval, it should return None
+        # 3 is used here directly to simulate the instant the second
+        # collection process starts.
         third_number_data_point = last_value_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 3
         )
         self.assertIsNone(third_number_data_point)
 
@@ -247,7 +268,9 @@ def test_aggregate(self):
         """
 
         explicit_bucket_histogram_aggregation = (
-            _ExplicitBucketHistogramAggregation(Mock(), boundaries=[0, 2, 4])
+            _ExplicitBucketHistogramAggregation(
+                Mock(), 0, boundaries=[0, 2, 4]
+            )
         )
 
         explicit_bucket_histogram_aggregation.aggregate(measurement(-1))
@@ -279,7 +302,7 @@ def test_aggregate(self):
         )
 
         histo = explicit_bucket_histogram_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 1
         )
         self.assertEqual(histo.sum, 14)
 
@@ -290,7 +313,7 @@ def test_min_max(self):
         """
 
         explicit_bucket_histogram_aggregation = (
-            _ExplicitBucketHistogramAggregation(Mock())
+            _ExplicitBucketHistogramAggregation(Mock(), 0)
         )
 
         explicit_bucket_histogram_aggregation.aggregate(measurement(-1))
@@ -303,7 +326,9 @@ def test_min_max(self):
         self.assertEqual(explicit_bucket_histogram_aggregation._max, 9999)
 
         explicit_bucket_histogram_aggregation = (
-            _ExplicitBucketHistogramAggregation(Mock(), record_min_max=False)
+            _ExplicitBucketHistogramAggregation(
+                Mock(), 0, record_min_max=False
+            )
         )
 
         explicit_bucket_histogram_aggregation.aggregate(measurement(-1))
@@ -321,12 +346,16 @@ def test_collect(self):
         """
 
         explicit_bucket_histogram_aggregation = (
-            _ExplicitBucketHistogramAggregation(Mock(), boundaries=[0, 1, 2])
+            _ExplicitBucketHistogramAggregation(
+                Mock(), 0, boundaries=[0, 1, 2]
+            )
         )
 
         explicit_bucket_histogram_aggregation.aggregate(measurement(1))
+        # 1 is used here directly to simulate the instant the first
+        # collection process starts.
         first_histogram = explicit_bucket_histogram_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 1
         )
 
         self.assertEqual(first_histogram.bucket_counts, (0, 1, 0, 0))
@@ -336,8 +365,10 @@ def test_collect(self):
         sleep(0.1)
 
         explicit_bucket_histogram_aggregation.aggregate(measurement(1))
+        # 2 is used here directly to simulate the instant the second
+        # collection process starts.
         second_histogram = explicit_bucket_histogram_aggregation.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 2
         )
 
         self.assertEqual(second_histogram.bucket_counts, (0, 2, 0, 0))
@@ -347,50 +378,23 @@ def test_collect(self):
             second_histogram.time_unix_nano, first_histogram.time_unix_nano
         )
 
-        explicit_bucket_histogram_aggregation = (
-            _ExplicitBucketHistogramAggregation(Mock(), boundaries=[0, 1, 2])
-        )
-
-        explicit_bucket_histogram_aggregation.aggregate(measurement(1))
-        first_histogram = explicit_bucket_histogram_aggregation.collect(
-            AggregationTemporality.DELTA
-        )
-
-        self.assertEqual(first_histogram.bucket_counts, (0, 1, 0, 0))
-        self.assertEqual(first_histogram.sum, 1)
-
-        # CI fails the last assertion without this
-        sleep(0.1)
-
-        explicit_bucket_histogram_aggregation.aggregate(measurement(1))
-        second_histogram = explicit_bucket_histogram_aggregation.collect(
-            AggregationTemporality.DELTA
-        )
-
-        self.assertEqual(second_histogram.bucket_counts, (0, 1, 0, 0))
-        self.assertEqual(second_histogram.sum, 1)
-
-        self.assertGreater(
-            second_histogram.time_unix_nano, first_histogram.time_unix_nano
-        )
-
 
 class TestAggregationFactory(TestCase):
     def test_sum_factory(self):
         counter = Counter("name", Mock(), Mock())
         factory = SumAggregation()
-        aggregation = factory._create_aggregation(counter, Mock())
+        aggregation = factory._create_aggregation(counter, Mock(), 0)
         self.assertIsInstance(aggregation, _SumAggregation)
         self.assertTrue(aggregation._instrument_is_monotonic)
         self.assertEqual(
             aggregation._instrument_temporality, AggregationTemporality.DELTA
         )
-        aggregation2 = factory._create_aggregation(counter, Mock())
+        aggregation2 = factory._create_aggregation(counter, Mock(), 0)
         self.assertNotEqual(aggregation, aggregation2)
 
         counter = UpDownCounter("name", Mock(), Mock())
         factory = SumAggregation()
-        aggregation = factory._create_aggregation(counter, Mock())
+        aggregation = factory._create_aggregation(counter, Mock(), 0)
         self.assertIsInstance(aggregation, _SumAggregation)
         self.assertFalse(aggregation._instrument_is_monotonic)
         self.assertEqual(
@@ -399,7 +403,7 @@ def test_sum_factory(self):
 
         counter = ObservableCounter("name", Mock(), Mock(), None)
         factory = SumAggregation()
-        aggregation = factory._create_aggregation(counter, Mock())
+        aggregation = factory._create_aggregation(counter, Mock(), 0)
         self.assertIsInstance(aggregation, _SumAggregation)
         self.assertTrue(aggregation._instrument_is_monotonic)
         self.assertEqual(
@@ -416,19 +420,19 @@ def test_explicit_bucket_histogram_factory(self):
             ),
             record_min_max=False,
         )
-        aggregation = factory._create_aggregation(histo, Mock())
+        aggregation = factory._create_aggregation(histo, Mock(), 0)
         self.assertIsInstance(aggregation, _ExplicitBucketHistogramAggregation)
         self.assertFalse(aggregation._record_min_max)
         self.assertEqual(aggregation._boundaries, (0.0, 5.0))
-        aggregation2 = factory._create_aggregation(histo, Mock())
+        aggregation2 = factory._create_aggregation(histo, Mock(), 0)
         self.assertNotEqual(aggregation, aggregation2)
 
     def test_last_value_factory(self):
         counter = Counter("name", Mock(), Mock())
         factory = LastValueAggregation()
-        aggregation = factory._create_aggregation(counter, Mock())
+        aggregation = factory._create_aggregation(counter, Mock(), 0)
         self.assertIsInstance(aggregation, _LastValueAggregation)
-        aggregation2 = factory._create_aggregation(counter, Mock())
+        aggregation2 = factory._create_aggregation(counter, Mock(), 0)
         self.assertNotEqual(aggregation, aggregation2)
 
 
@@ -440,7 +444,7 @@ def setUpClass(cls):
     def test_counter(self):
 
         aggregation = self.default_aggregation._create_aggregation(
-            Counter("name", Mock(), Mock()), Mock()
+            Counter("name", Mock(), Mock()), Mock(), 0
         )
         self.assertIsInstance(aggregation, _SumAggregation)
         self.assertTrue(aggregation._instrument_is_monotonic)
@@ -451,7 +455,7 @@ def test_counter(self):
     def test_up_down_counter(self):
 
         aggregation = self.default_aggregation._create_aggregation(
-            UpDownCounter("name", Mock(), Mock()), Mock()
+            UpDownCounter("name", Mock(), Mock()), Mock(), 0
         )
         self.assertIsInstance(aggregation, _SumAggregation)
         self.assertFalse(aggregation._instrument_is_monotonic)
@@ -464,6 +468,7 @@ def test_observable_counter(self):
         aggregation = self.default_aggregation._create_aggregation(
             ObservableCounter("name", Mock(), Mock(), callbacks=[Mock()]),
             Mock(),
+            0,
         )
         self.assertIsInstance(aggregation, _SumAggregation)
         self.assertTrue(aggregation._instrument_is_monotonic)
@@ -479,6 +484,7 @@ def test_observable_up_down_counter(self):
                 "name", Mock(), Mock(), callbacks=[Mock()]
             ),
             Mock(),
+            0,
         )
         self.assertIsInstance(aggregation, _SumAggregation)
         self.assertFalse(aggregation._instrument_is_monotonic)
@@ -496,6 +502,7 @@ def test_histogram(self):
                 Mock(),
             ),
             Mock(),
+            0,
         )
         self.assertIsInstance(aggregation, _ExplicitBucketHistogramAggregation)
 
@@ -509,5 +516,6 @@ def test_observable_gauge(self):
                 callbacks=[Mock()],
             ),
             Mock(),
+            0,
         )
         self.assertIsInstance(aggregation, _LastValueAggregation)
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
@@ -322,6 +322,47 @@ def test_drop_aggregation(self):
             ),
         )
 
+    def test_same_collection_start(self):
+
+        counter = Counter("name", Mock(), Mock())
+        up_down_counter = UpDownCounter("name", Mock(), Mock())
+
+        metric_reader_storage = MetricReaderStorage(
+            SdkConfiguration(
+                resource=Mock(),
+                metric_readers=(),
+                views=(View(instrument_name="name"),),
+            ),
+            MagicMock(
+                **{
+                    "__getitem__.return_value": AggregationTemporality.CUMULATIVE
+                }
+            ),
+            MagicMock(**{"__getitem__.return_value": DefaultAggregation()}),
+        )
+
+        metric_reader_storage.consume_measurement(Measurement(1, counter))
+        metric_reader_storage.consume_measurement(
+            Measurement(1, up_down_counter)
+        )
+
+        actual = metric_reader_storage.collect()
+
+        self.assertEqual(
+            list(
+                actual.resource_metrics[0]
+                .scope_metrics[0]
+                .metrics[0]
+                .data.data_points
+            )[0].time_unix_nano,
+            list(
+                actual.resource_metrics[0]
+                .scope_metrics[1]
+                .metrics[0]
+                .data.data_points
+            )[0].time_unix_nano,
+        )
+
     def test_conflicting_view_configuration(self):
 
         observable_counter = ObservableCounter(
diff --git a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py
--- a/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py
+++ b/opentelemetry-sdk/tests/metrics/test_view_instrument_match.py
@@ -202,7 +202,7 @@ def test_collect(self):
         )
 
         number_data_points = view_instrument_match.collect(
-            AggregationTemporality.CUMULATIVE
+            AggregationTemporality.CUMULATIVE, 0
         )
         number_data_points = list(number_data_points)
         self.assertEqual(len(number_data_points), 1)