Repository: census-instrumentation/opencensus-python
Pull request #287 (2018-08-28 21:31:51)

Stats exemplars

googlebot: So there's good news and bad news.

:thumbsup: The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so.  Everything is all good there.

:confused: The bad news is that it appears that one or more commits were authored or co-authored by someone other than the pull request submitter.  We need to confirm that all authors are ok with their commits being contributed to this project.  Please have them confirm that here in the pull request.

*Note to project maintainer: This is a terminal state, meaning the `cla/google` commit status will not change from this state. It's up to you to confirm consent of all the commit author(s), set the `cla` label to `yes` (if enabled on your project), and then merge this pull request when appropriate.*

<!-- need_author_consent -->
songy23: Please fix the CLA, rebase against `census-instrumentation:master` and resolve the merge conflicts.
songy23: Let me try manual verification.
googlebot: A Googler has manually verified that the CLAs look good.

(Googler, please make sure the reason for overriding the CLA status is clearly documented in these comments.)

<!-- cla_yes -->
bogdandrutu: @songy23 did you verify that both @MarceloAquino7 and @vcasadei signed the CLA? If you did that is great.
songy23: Yes, I think they have other PRs where CLA looks good, for example:
https://github.com/census-ecosystem/opencensus-go-exporter-graphite/pull/12 for @vcasadei 
https://github.com/census-instrumentation/opencensus-python/pull/256 for @MarceloAquino7 

The problem is this PR contains commits from more than one people, and CLA won't pass automatically in this case.
googlebot: So there's good news and bad news.

:thumbsup: The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so.  Everything is all good there.

:confused: The bad news is that it appears that one or more commits were authored or co-authored by someone other than the pull request submitter.  We need to confirm that all authors are ok with their commits being contributed to this project.  Please have them confirm that here in the pull request.

*Note to project maintainer: This is a terminal state, meaning the `cla/google` commit status will not change from this state. It's up to you to confirm consent of all the commit author(s), set the `cla` label to `yes` (if enabled on your project), and then merge this pull request when appropriate.*

<!-- need_author_consent -->
googlebot: So there's good news and bad news.

:thumbsup: The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so.  Everything is all good there.

:confused: The bad news is that it appears that one or more commits were authored or co-authored by someone other than the pull request submitter.  We need to confirm that all authors are ok with their commits being contributed to this project.  Please have them confirm that here in the pull request.

*Note to project maintainer: This is a terminal state, meaning the `cla/google` commit status will not change from this state. It's up to you to confirm consent of all the commit author(s), set the `cla` label to `yes` (if enabled on your project), and then merge this pull request when appropriate.*

<!-- need_author_consent -->
vcasadei: It's failing the system test, I'm not sure why, but I've tried to rebuild and it still fails

nox > Session docs successful. :)
nox > Ran multiple sessions:
nox > * unit(py='2.7'): passed
nox > * unit(py='3.4'): passed
nox > * unit(py='3.5'): passed
nox > * unit(py='3.6'): passed
**nox > * system(py='2.7'): failed**
nox > * system(py='3.6'): passed
nox > * lint: passed
nox > * lint_setup_py: passed
nox > * cover: passed
nox > * docs: passed
songy23: > It's failing the system test, I'm not sure why, but I've tried to rebuild and it still fails

Seems it's a transient failure (https://github.com/census-instrumentation/opencensus-python/issues/239).
songy23: @liyanhui1228 Could you trigger another CI build? I think the CI failure is transient but I don't have permission to restart the build.
liyanhui1228: Yeah I just restarted the build.
googlebot: A Googler has manually verified that the CLAs look good.

(Googler, please make sure the reason for overriding the CLA status is clearly documented in these comments.)

<!-- cla_yes -->

Patch:
diff --git a/opencensus/stats/aggregation_data.py b/opencensus/stats/aggregation_data.py
index b97314e..6dfb6da 100644
--- a/opencensus/stats/aggregation_data.py
+++ b/opencensus/stats/aggregation_data.py
@@ -41,7 +41,7 @@ class SumAggregationDataFloat(BaseAggregationData):
         super(SumAggregationDataFloat, self).__init__(sum_data)
         self._sum_data = sum_data
 
-    def add_sample(self, value):
+    def add_sample(self, value, timestamp=None, attachments=None):
         """Allows the user to add a sample to the Sum Aggregation Data
         The value of the sample is then added to the current sum data
         """
@@ -64,7 +64,7 @@ class CountAggregationData(BaseAggregationData):
         super(CountAggregationData, self).__init__(count_data)
         self._count_data = count_data
 
-    def add_sample(self, value):
+    def add_sample(self, value, timestamp=None, attachments=None):
         """Adds a sample to the current Count Aggregation Data and adds 1 to
         the count data"""
         self._count_data = self._count_data + 1
@@ -97,6 +97,9 @@ class DistributionAggregationData(BaseAggregationData):
     :type counts_per_bucket: list(int)
     :param counts_per_bucket: the number of occurrences per bucket
 
+    :type exemplars: list(Exemplar)
+    :param: exemplars: the exemplars associated with histogram buckets.
+
     :type bounds: list(float)
     :param bounds: the histogram distribution of the values
 
@@ -108,7 +111,8 @@ class DistributionAggregationData(BaseAggregationData):
                  max_,
                  sum_of_sqd_deviations,
                  counts_per_bucket=None,
-                 bounds=None):
+                 bounds=None,
+                 exemplars=None):
         super(DistributionAggregationData, self).__init__(mean_data)
         self._mean_data = mean_data
         self._count_data = count_data
@@ -126,6 +130,13 @@ class DistributionAggregationData(BaseAggregationData):
         self._counts_per_bucket = counts_per_bucket
         self._bounds = bucket_boundaries.BucketBoundaries(
                                             boundaries=bounds).boundaries
+        bucket = 0
+        for _ in self.bounds:
+            bucket = bucket + 1
+
+        # If there is no histogram, do not record an exemplar
+        self._exemplars = \
+            {bucket: exemplars} if len(self._bounds) > 0 else None
 
     @property
     def mean_data(self):
@@ -157,6 +168,11 @@ class DistributionAggregationData(BaseAggregationData):
         """The current counts per bucket for the distribution"""
         return self._counts_per_bucket
 
+    @property
+    def exemplars(self):
+        """The current counts per bucket for the distribution"""
+        return self._exemplars
+
     @property
     def bounds(self):
         """The current bounds for the distribution"""
@@ -174,15 +190,17 @@ class DistributionAggregationData(BaseAggregationData):
             return 0
         return self.sum_of_sqd_deviations / (self._count_data - 1)
 
-    def add_sample(self, value):
+    def add_sample(self, value, timestamp, attachments):
         """Adding a sample to Distribution Aggregation Data"""
         if value < self.min:
             self._min = value
         if value > self.max:
             self._max = value
         self._count_data += 1
-        self.increment_bucket_count(value)
+        bucket = self.increment_bucket_count(value)
 
+        if attachments is not None and self.exemplars is not None:
+            self.exemplars[bucket] = Exemplar(value, timestamp, attachments)
         if self.count_data == 1:
             self._mean_data = value
             return
@@ -196,22 +214,29 @@ class DistributionAggregationData(BaseAggregationData):
 
     def increment_bucket_count(self, value):
         """Increment the bucket count based on a given value from the user"""
-        if len(self._bounds) == 0:
-            self._counts_per_bucket[0] += 1
-            return
-
         i = 0
+        incremented = False
         for b in self._bounds:
-            if value < b:
+            if value < b and not incremented:
                 self._counts_per_bucket[i] += 1
-                return
+                incremented = True
             i += 1
 
+        if incremented:
+            return i
+
+        if len(self._bounds) == 0:
+            self._counts_per_bucket[0] += 1
+            return i
+
         self._counts_per_bucket[(len(self._bounds))-1] += 1
 
+        return i
+
 
 class LastValueAggregationData(BaseAggregationData):
-    """LastValue Aggregation Data is the value of aggregated data
+    """
+    LastValue Aggregation Data is the value of aggregated data
 
     :type value: long
     :param value: represents the current value
@@ -221,15 +246,63 @@ class LastValueAggregationData(BaseAggregationData):
         super(LastValueAggregationData, self).__init__(value)
         self._value = value
 
-    def add_sample(self, value):
+    def add_sample(self, value, timestamp=None, attachments=None):
         """Adds a sample to the current
-           LastValue Aggregation Data and overwrite
-           the current recorded value
-        """
+        LastValue Aggregation Data and overwrite
+        the current recorded value"""
         self._value = value
 
     @property
     def value(self):
-        """The current value recorded
-        """
+        """The current value recorded"""
         return self._value
+
+
+class Exemplar(object):
+    """ Exemplar represents an example point that may be used to annotate
+        aggregated distribution values, associated with a histogram bucket.
+
+        :type value: double
+        :param value: value of the Exemplar point.
+
+        :type timestamp: time
+        :param timestamp: the time that this Exemplar's value was recorded.
+
+        :type attachments: dict
+        :param attachments: the contextual information about the example value.
+    """
+
+    def __init__(self,
+                 value,
+                 timestamp,
+                 attachments):
+        self._value = value
+
+        self._timestamp = timestamp
+
+        if attachments is None:
+            raise TypeError('attachments should not be empty')
+
+        for key, value in attachments.items():
+            if key is None or not isinstance(key, str):
+                raise TypeError('attachment key should not be '
+                                'empty and should be a string')
+            if value is None or not isinstance(value, str):
+                raise TypeError('attachment value should not be '
+                                'empty and should be a string')
+        self._attachments = attachments
+
+    @property
+    def value(self):
+        """The current value of the Exemplar point"""
+        return self._value
+
+    @property
+    def timestamp(self):
+        """The time that this Exemplar's value was recorded"""
+        return self._timestamp
+
+    @property
+    def attachments(self):
+        """The contextual information about the example value"""
+        return self._attachments
diff --git a/opencensus/stats/measure_to_view_map.py b/opencensus/stats/measure_to_view_map.py
index f4e9d94..3900dfa 100644
--- a/opencensus/stats/measure_to_view_map.py
+++ b/opencensus/stats/measure_to_view_map.py
@@ -93,7 +93,7 @@ class MeasureToViewMap(object):
         self._measure_to_view_data_list_map[view.measure.name].append(
             ViewData(view=view, start_time=timestamp, end_time=timestamp))
 
-    def record(self, tags, measurement_map, timestamp):
+    def record(self, tags, measurement_map, timestamp, attachments=None):
         """records stats with a set of tags"""
         for measure, value in measurement_map.items():
             if measure != self._registered_measures.get(measure.name):
@@ -105,7 +105,8 @@ class MeasureToViewMap(object):
                     view_datas.extend(view_data_list)
             for view_data in view_datas:
                 view_data.record(
-                    context=tags, value=value, timestamp=timestamp)
+                    context=tags, value=value, timestamp=timestamp,
+                    attachment=attachments)
             self.export(view_datas)
 
     def export(self, view_datas):
diff --git a/opencensus/stats/measurement_map.py b/opencensus/stats/measurement_map.py
index 5bbe8e9..956f25a 100644
--- a/opencensus/stats/measurement_map.py
+++ b/opencensus/stats/measurement_map.py
@@ -25,10 +25,14 @@ class MeasurementMap(object):
     :param measure_to_view_map: the measure to view map that will store the
                                 recorded stats with tags
 
+    :type: attachments: dict
+    :param attachments: the contextual information about the attachment value.
+
     """
-    def __init__(self, measure_to_view_map):
+    def __init__(self, measure_to_view_map, attachments=None):
         self._measurement_map = {}
         self._measure_to_view_map = measure_to_view_map
+        self._attachments = attachments
 
     @property
     def measurement_map(self):
@@ -40,6 +44,11 @@ class MeasurementMap(object):
         """the current measure to view map for the measurement map"""
         return self._measure_to_view_map
 
+    @property
+    def attachments(self):
+        """the current contextual information about the attachment value."""
+        return self._attachments
+
     def measure_int_put(self, measure, value):
         """associates the measure of type Int with the given value"""
         self._measurement_map[measure] = value
@@ -48,6 +57,24 @@ class MeasurementMap(object):
         """associates the measure of type Float with the given value"""
         self._measurement_map[measure] = value
 
+    def measure_put_attachment(self, key, value):
+        """Associate the contextual information of an Exemplar to this MeasureMap
+            Contextual information is represented as key - value string pairs.
+            If this method is called multiple times with the same key,
+            only the last value will be kept.
+        """
+        if self._attachments is None:
+            self._attachments = dict()
+
+        if key is None or not isinstance(key, str):
+            raise TypeError('attachment key should not be '
+                            'empty and should be a string')
+        if value is None or not isinstance(value, str):
+            raise TypeError('attachment value should not be '
+                            'empty and should be a string')
+
+        self._attachments[key] = value
+
     def record(self, tag_map_tags=execution_context.get_current_tag_map()):
         """records all the measures at the same time with a tag_map.
         tag_map could either be explicitly passed to the method, or implicitly
@@ -56,5 +83,6 @@ class MeasurementMap(object):
         self.measure_to_view_map.record(
                 tags=tag_map_tags,
                 measurement_map=self.measurement_map,
-                timestamp=datetime.utcnow().isoformat() + 'Z'
+                timestamp=datetime.utcnow().isoformat() + 'Z',
+                attachments=self.attachments
         )
diff --git a/opencensus/stats/view_data.py b/opencensus/stats/view_data.py
index 4ccfec7..4cca409 100644
--- a/opencensus/stats/view_data.py
+++ b/opencensus/stats/view_data.py
@@ -79,7 +79,7 @@ class ViewData(object):
             i += 1
         return tag_values
 
-    def record(self, context, value, timestamp):
+    def record(self, context, value, timestamp, attachments=None):
         """records the view data against context"""
         tag_values = self.get_tag_values(tags=context.map,
                                          columns=self.view.columns)
@@ -87,4 +87,5 @@ class ViewData(object):
         if tuple_vals not in self.tag_value_aggregation_data_map:
             self.tag_value_aggregation_data_map[tuple_vals] = copy.deepcopy(
                 self.view.aggregation.aggregation_data)
-        self.tag_value_aggregation_data_map.get(tuple_vals).add_sample(value)
+        self.tag_value_aggregation_data_map.get(tuple_vals).\
+            add_sample(value, timestamp, attachments)


Test patch:
diff --git a/tests/unit/stats/test_aggregation_data.py b/tests/unit/stats/test_aggregation_data.py
index 214492e..2149357 100644
--- a/tests/unit/stats/test_aggregation_data.py
+++ b/tests/unit/stats/test_aggregation_data.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import unittest
+import time
 import mock
 from opencensus.stats import aggregation_data as aggregation_data_module
 
@@ -41,7 +42,7 @@ class TestSumAggregationData(unittest.TestCase):
         value = 3
         sum_aggregation_data = aggregation_data_module.SumAggregationDataFloat(
             sum_data=sum_data)
-        sum_aggregation_data.add_sample(value=value)
+        sum_aggregation_data.add_sample(value, None, None)
 
         self.assertEqual(4, sum_aggregation_data.sum_data)
 
@@ -59,7 +60,7 @@ class TestCountAggregationData(unittest.TestCase):
         count_data = 0
         count_aggregation_data = aggregation_data_module.CountAggregationData(
             count_data=count_data)
-        count_aggregation_data.add_sample(10)
+        count_aggregation_data.add_sample(10, None, None)
 
         self.assertEqual(1, count_aggregation_data.count_data)
 
@@ -77,8 +78,7 @@ class TestLastValueAggregationData(unittest.TestCase):
         last_value_aggregation_data = aggregation_data_module.LastValueAggregationData(
             value=first_data)
         self.assertEqual(0, last_value_aggregation_data.value)
-        
-        last_value_aggregation_data.add_sample(1)
+        last_value_aggregation_data.add_sample(1, None, None)
         self.assertEqual(1, last_value_aggregation_data.value)
 
 
@@ -115,6 +115,87 @@ class TestDistributionAggregationData(unittest.TestCase):
         self.assertIsNotNone(dist_agg_data.sum)
         self.assertEqual(0, dist_agg_data.variance)
 
+    def test_constructor_with_exemplar(self):
+        timestamp = time.time()
+        attachments = {"One": "one", "Two": "two"}
+        exemplar_1 = aggregation_data_module.Exemplar(4, timestamp, attachments)
+        exemplar_2 = aggregation_data_module.Exemplar(5, timestamp, attachments)
+        mean_data = 1
+        count_data = 0
+        _min = 0
+        _max = 1
+        sum_of_sqd_deviations = mock.Mock()
+        counts_per_bucket = [1, 1, 1]
+        bounds = [0, 1/2, 1]
+        exemplars = [exemplar_1, exemplar_2]
+
+        dist_agg_data = aggregation_data_module.DistributionAggregationData(
+                mean_data=mean_data,
+                count_data=count_data,
+                min_= _min,
+                max_ = _max,
+                sum_of_sqd_deviations=sum_of_sqd_deviations,
+                exemplars=exemplars,
+                counts_per_bucket=counts_per_bucket,
+                bounds=bounds
+            )
+
+        self.assertEqual(1, dist_agg_data.mean_data)
+        self.assertEqual(0, dist_agg_data.count_data)
+        self.assertEqual(0, dist_agg_data.min)
+        self.assertEqual(1, dist_agg_data.max)
+        self.assertEqual(sum_of_sqd_deviations,
+                         dist_agg_data.sum_of_sqd_deviations)
+        self.assertEqual([1, 1, 1], dist_agg_data.counts_per_bucket)
+        self.assertEqual([exemplar_1, exemplar_2], dist_agg_data.exemplars[3])
+        self.assertEqual([0, 1/2, 1], dist_agg_data.bounds)
+
+        self.assertIsNotNone(dist_agg_data.sum)
+        self.assertEqual(0, dist_agg_data.variance)
+
+    def test_exemplar(self):
+        timestamp = time.time()
+        attachments = {"One": "one", "Two": "two"}
+        exemplar = aggregation_data_module.Exemplar(4, timestamp, attachments)
+
+        self.assertEqual(4, exemplar.value)
+        self.assertEqual(timestamp, exemplar.timestamp)
+        self.assertEqual(attachments, exemplar.attachments)
+
+    def test_exemplar_null_attachments(self):
+        timestamp = time.time()
+
+        with self.assertRaisesRegexp(TypeError, 'attachments should not be empty'):
+            aggregation_data_module.Exemplar(6, timestamp, None)
+
+    def test_exemplar_null_attachment_key(self):
+        timestamp = time.time()
+        attachment = {None: "one", "Two": "two"}
+
+        with self.assertRaisesRegexp(TypeError, 'attachment key should not be empty and should be a string'):
+            aggregation_data_module.Exemplar(6, timestamp, attachment)
+
+    def test_exemplar_null_attachment_value(self):
+        timestamp = time.time()
+        attachment = {"One": "one", "Two": None}
+
+        with self.assertRaisesRegexp(TypeError, 'attachment value should not be empty and should be a string'):
+            aggregation_data_module.Exemplar(6, timestamp, attachment)
+
+    def test_exemplar_int_attachment_key(self):
+        timestamp = time.time()
+        attachment = {1: "one", "Two": "two"}
+
+        with self.assertRaisesRegexp(TypeError, 'attachment key should not be empty and should be a string'):
+            aggregation_data_module.Exemplar(6, timestamp, attachment)
+
+    def test_exemplar_int_attachment_value(self):
+        timestamp = time.time()
+        attachment = {"One": "one", "Two": 2}
+
+        with self.assertRaisesRegexp(TypeError, 'attachment value should not be empty and should be a string'):
+            aggregation_data_module.Exemplar(6, timestamp, attachment)
+
     def test_variance(self):
         mean_data = mock.Mock()
         count_data = 0
@@ -168,7 +249,7 @@ class TestDistributionAggregationData(unittest.TestCase):
             bounds=bounds
         )
 
-        dist_agg_data.add_sample(value=value)
+        dist_agg_data.add_sample(value, None, None)
         self.assertEqual(0, dist_agg_data.min)
         self.assertEqual(3, dist_agg_data.max)
         self.assertEqual(1, dist_agg_data.count_data)
@@ -185,16 +266,68 @@ class TestDistributionAggregationData(unittest.TestCase):
             bounds=bounds
         )
 
-        dist_agg_data.add_sample(value=value)
+        dist_agg_data.add_sample(value, None, None)
         self.assertEqual(2, dist_agg_data.count_data)
         self.assertEqual(2.0, dist_agg_data.mean_data)
         self.assertEqual(4.0, dist_agg_data.sum_of_sqd_deviations)
         self.assertIsNot(0, dist_agg_data.count_data)
 
         value_2 = -1
-        dist_agg_data.add_sample(value=value_2)
+        dist_agg_data.add_sample(value_2, None, None)
         self.assertEqual(value_2, dist_agg_data.min)
 
+    def test_add_sample_attachment(self):
+        mean_data = 1.0
+        count_data = 1
+        _min = 0
+        _max = 1
+        sum_of_sqd_deviations = 2
+        counts_per_bucket = [1, 1, 1, 1]
+        bounds = [0, 0.5, 1, 1.5]
+
+        value = 3
+        timestamp = time.time()
+        attachments = {"One": "one", "Two": "two"}
+        exemplar_1 = aggregation_data_module.Exemplar(4, timestamp, attachments)
+
+        dist_agg_data = aggregation_data_module.DistributionAggregationData(
+            mean_data=mean_data,
+            count_data=count_data,
+            min_= _min,
+            max_ = _max,
+            sum_of_sqd_deviations=sum_of_sqd_deviations,
+            counts_per_bucket=counts_per_bucket,
+            bounds=bounds,
+            exemplars=exemplar_1
+        )
+
+        self.assertEqual({4:exemplar_1}, dist_agg_data.exemplars)
+
+        dist_agg_data.add_sample(value, timestamp, attachments)
+        self.assertEqual(0, dist_agg_data.min)
+        self.assertEqual(3, dist_agg_data.max)
+        self.assertEqual(2, dist_agg_data.count_data)
+        self.assertEqual(2.0, dist_agg_data.mean_data)
+        self.assertEqual(3, dist_agg_data.exemplars[4].value)
+
+        count_data = 4
+        dist_agg_data = aggregation_data_module.DistributionAggregationData(
+            mean_data=mean_data,
+            count_data=count_data,
+            min_= _min,
+            max_ = _max,
+            sum_of_sqd_deviations=sum_of_sqd_deviations,
+            counts_per_bucket=[2, 1, 2, 1, 1],
+            bounds=[1, 2, 3, 4, 5]
+        )
+
+        dist_agg_data.add_sample(value, timestamp, attachments)
+        self.assertEqual(5, dist_agg_data.count_data)
+        self.assertEqual(1.4, dist_agg_data.mean_data)
+        self.assertEqual(5.2, dist_agg_data.sum_of_sqd_deviations)
+        self.assertIsNot(0, dist_agg_data.count_data)
+        self.assertEqual(3, dist_agg_data.exemplars[5].value)
+
     def test_increment_bucket_count(self):
         mean_data = mock.Mock()
         count_data = mock.Mock()
diff --git a/tests/unit/stats/test_measure_to_view_map.py b/tests/unit/stats/test_measure_to_view_map.py
index 863b5a3..f84fcae 100644
--- a/tests/unit/stats/test_measure_to_view_map.py
+++ b/tests/unit/stats/test_measure_to_view_map.py
@@ -66,7 +66,7 @@ class TestMeasureToViewMap(unittest.TestCase):
             view.measure.name:
             [ViewData(view=view, start_time=timestamp, end_time=timestamp)]
         }
-        print(measure_to_view_map._measure_to_view_data_list_map)
+
         view_data = measure_to_view_map.get_view(
             view_name=name, timestamp=timestamp)
         self.assertIsNotNone(view_data)
@@ -244,7 +244,7 @@ class TestMeasureToViewMap(unittest.TestCase):
         measure_to_view_map = measure_to_view_map_module.MeasureToViewMap()
         measure_to_view_map._registered_measures = {}
         record = measure_to_view_map.record(
-            tags=tags, measurement_map=measurement_map, timestamp=timestamp)
+            tags=tags, measurement_map=measurement_map, timestamp=timestamp, attachments=None)
         self.assertNotEqual(
             measure,
             measure_to_view_map._registered_measures.get(measure.name))
@@ -253,7 +253,7 @@ class TestMeasureToViewMap(unittest.TestCase):
         measure_to_view_map._registered_measures = {measure.name: measure}
         measure_to_view_map._measure_to_view_data_list_map = {}
         record = measure_to_view_map.record(
-            tags=tags, measurement_map=measurement_map, timestamp=timestamp)
+            tags=tags, measurement_map=measurement_map, timestamp=timestamp, attachments=None)
         self.assertEqual(
             measure,
             measure_to_view_map._registered_measures.get(measure.name))
@@ -263,7 +263,7 @@ class TestMeasureToViewMap(unittest.TestCase):
             measure.name: [mock.Mock()]
         }
         measure_to_view_map.record(
-            tags=tags, measurement_map=measurement_map, timestamp=timestamp)
+            tags=tags, measurement_map=measurement_map, timestamp=timestamp, attachments=None)
         self.assertEqual(
             measure,
             measure_to_view_map._registered_measures.get(measure.name))
@@ -274,7 +274,7 @@ class TestMeasureToViewMap(unittest.TestCase):
             "testing": [mock.Mock()]
         }
         measure_to_view_map.record(
-            tags=tags, measurement_map=measurement_map, timestamp=timestamp)
+            tags=tags, measurement_map=measurement_map, timestamp=timestamp, attachments=None)
         self.assertTrue(measure.name not in
                         measure_to_view_map._measure_to_view_data_list_map)
 
@@ -294,7 +294,7 @@ class TestMeasureToViewMap(unittest.TestCase):
         measurement_map = {}
         measure_to_view_map = measure_to_view_map_module.MeasureToViewMap()
         record = measure_to_view_map.record(
-            tags=tags, measurement_map=measurement_map, timestamp=timestamp)
+            tags=tags, measurement_map=measurement_map, timestamp=timestamp, attachments=None)
         self.assertIsNone(record)
 
     def test_record_with_exporter(self):
diff --git a/tests/unit/stats/test_measurement_map.py b/tests/unit/stats/test_measurement_map.py
index 6d125dc..0aa23c4 100644
--- a/tests/unit/stats/test_measurement_map.py
+++ b/tests/unit/stats/test_measurement_map.py
@@ -17,6 +17,10 @@ import mock
 from opencensus.stats import measurement_map as measurement_map_module
 from opencensus.stats.measure_to_view_map import MeasureToViewMap
 from opencensus.tags import execution_context
+from opencensus.stats.measure import BaseMeasure
+from opencensus.stats.measure import MeasureInt
+from opencensus.stats import measure_to_view_map as measure_to_view_map_module
+from opencensus.stats.view import View
 
 
 class TestMeasurementMap(unittest.TestCase):
@@ -50,6 +54,71 @@ class TestMeasurementMap(unittest.TestCase):
 
         self.assertEqual({'testKey': 1.0}, measurement_map.measurement_map)
 
+    def test_put_attachment_none_key(self):
+        measure_to_view_map = mock.Mock()
+        test_key = None
+        test_value = 'testValue'
+        measurement_map = measurement_map_module.MeasurementMap(
+            measure_to_view_map=measure_to_view_map, attachments={})
+        with self.assertRaisesRegexp(TypeError, 'attachment key should not be empty and should be a string'):
+            measurement_map.measure_put_attachment(test_key, test_value)
+
+    def test_put_attachment_none_value(self):
+        measure_to_view_map = mock.Mock()
+        test_key = 'testKey'
+        test_value = None
+        measurement_map = measurement_map_module.MeasurementMap(
+            measure_to_view_map=measure_to_view_map, attachments={})
+        with self.assertRaisesRegexp(TypeError, 'attachment value should not be empty and should be a string'):
+            measurement_map.measure_put_attachment(test_key, test_value)
+
+    def test_put_attachment_int_key(self):
+        measure_to_view_map = mock.Mock()
+        test_key = 42
+        test_value = 'testValue'
+        measurement_map = measurement_map_module.MeasurementMap(
+            measure_to_view_map=measure_to_view_map, attachments={})
+        with self.assertRaisesRegexp(TypeError, 'attachment key should not be empty and should be a string'):
+            measurement_map.measure_put_attachment(test_key, test_value)
+
+    def test_put_attachment_int_value(self):
+        measure_to_view_map = mock.Mock()
+        test_key = 'testKey'
+        test_value = 42
+        measurement_map = measurement_map_module.MeasurementMap(
+            measure_to_view_map=measure_to_view_map, attachments={})
+        with self.assertRaisesRegexp(TypeError, 'attachment value should not be empty and should be a string'):
+            measurement_map.measure_put_attachment(test_key, test_value)
+
+    def test_put_attachment(self):
+        measure_to_view_map = mock.Mock()
+        test_key = 'testKey'
+        test_value = 'testValue'
+        measurement_map = measurement_map_module.MeasurementMap(
+            measure_to_view_map=measure_to_view_map, attachments={})
+        measurement_map.measure_put_attachment(test_key, test_value)
+        self.assertEqual({'testKey': 'testValue'}, measurement_map.attachments)
+
+    def test_put_none_attachment(self):
+        measure_to_view_map = mock.Mock()
+        test_key = 'testKey'
+        test_value = 'testValue'
+        measurement_map = measurement_map_module.MeasurementMap(
+            measure_to_view_map=measure_to_view_map)
+        measurement_map.measure_put_attachment(test_key, test_value)
+        self.assertEqual({'testKey': 'testValue'}, measurement_map.attachments)
+
+    def test_put_multiple_attachment(self):
+        measure_to_view_map = mock.Mock()
+        test_key = 'testKey'
+        test_value = 'testValue'
+        test_value2 = 'testValue2'
+        measurement_map = measurement_map_module.MeasurementMap(
+            measure_to_view_map=measure_to_view_map, attachments={})
+        measurement_map.measure_put_attachment(test_key, test_value)
+        measurement_map.measure_put_attachment(test_key, test_value2)
+        self.assertEqual({test_key: test_value2}, measurement_map.attachments)
+
     def test_record_against_explicit_tag_map(self):
         measure_to_view_map = mock.Mock()
         measurement_map = measurement_map_module.MeasurementMap(
diff --git a/tests/unit/stats/test_view_data.py b/tests/unit/stats/test_view_data.py
index 07807c0..b472f4e 100644
--- a/tests/unit/stats/test_view_data.py
+++ b/tests/unit/stats/test_view_data.py
@@ -16,8 +16,9 @@ import unittest
 import mock
 from datetime import datetime
 from opencensus.stats import aggregation as aggregation_module
-from opencensus.stats import view as view_module
+from opencensus.stats import measure as measure_module
 from opencensus.stats import view_data as view_data_module
+from opencensus.stats import view as view_module
 
 
 class TestViewData(unittest.TestCase):
@@ -111,6 +112,84 @@ class TestViewData(unittest.TestCase):
         self.assertIsNotNone(view_data.tag_value_aggregation_data_map.get(
             tuple_vals_2).add(value))
 
+    def test_record_with_attachment(self):
+        boundaries = [1, 2, 3]
+        distribution = {1: "test"}
+        distribution_aggregation = aggregation_module.DistributionAggregation(boundaries=boundaries,
+                                                                              distribution=distribution)
+        name = "testName"
+        description = "testMeasure"
+        unit = "testUnit"
+
+        measure = measure_module.MeasureInt(name=name, description=description, unit=unit)
+
+        description = "testMeasure"
+        columns = ["key1", "key2"]
+
+        view = view_module.View(name=name, description=description, columns=columns, measure=measure,
+                                aggregation=distribution_aggregation)
+
+        start_time = datetime.utcnow()
+        attachments = {"One": "one", "Two": "two"}
+        end_time = datetime.utcnow()
+        view_data = view_data_module.ViewData(view=view,
+                                              start_time=start_time,
+                                              end_time=end_time)
+        context = mock.Mock
+        context.map = {'key1': 'val1', 'key2': 'val2'}
+        time = datetime.utcnow().isoformat() + 'Z'
+        value = 1
+
+        view_data.record(context=context, value=value, timestamp=time, attachments=attachments)
+        tag_values = view_data.get_tag_values(
+            tags=context.map, columns=view.columns)
+        tuple_vals = tuple(tag_values)
+
+        self.assertEqual(['val1', 'val2'], tag_values)
+        self.assertIsNotNone(view_data.tag_value_aggregation_data_map)
+        self.assertTrue(tuple_vals in view_data.tag_value_aggregation_data_map)
+        self.assertIsNotNone(view_data.tag_value_aggregation_data_map[tuple_vals])
+        self.assertEqual(attachments, view_data.
+                         tag_value_aggregation_data_map[tuple_vals].exemplars[3].attachments)
+
+    def test_record_with_attachment_no_histogram(self):
+        boundaries = None
+        distribution = {1: "test"}
+        distribution_aggregation = aggregation_module.DistributionAggregation(boundaries=boundaries,
+                                                                              distribution=distribution)
+        name = "testName"
+        description = "testMeasure"
+        unit = "testUnit"
+
+        measure = measure_module.MeasureInt(name=name, description=description, unit=unit)
+
+        description = "testMeasure"
+        columns = ["key1", "key2"]
+
+        view = view_module.View(name=name, description=description, columns=columns, measure=measure,
+                                aggregation=distribution_aggregation)
+
+        start_time = datetime.utcnow()
+        attachments = {"One": "one", "Two": "two"}
+        end_time = datetime.utcnow()
+        view_data = view_data_module.ViewData(view=view,
+                                              start_time=start_time,
+                                              end_time=end_time)
+        context = mock.Mock
+        context.map = {'key1': 'val1', 'key2': 'val2'}
+        time = datetime.utcnow().isoformat() + 'Z'
+        value = 1
+        view_data.record(context=context, value=value, timestamp=time, attachments=attachments)
+        tag_values = view_data.get_tag_values(
+            tags=context.map, columns=view.columns)
+        tuple_vals = tuple(tag_values)
+
+        self.assertEqual(['val1', 'val2'], tag_values)
+        self.assertIsNotNone(view_data.tag_value_aggregation_data_map)
+        self.assertTrue(tuple_vals in view_data.tag_value_aggregation_data_map)
+        self.assertIsNotNone(view_data.tag_value_aggregation_data_map[tuple_vals])
+        self.assertIsNone(view_data.
+                         tag_value_aggregation_data_map[tuple_vals].exemplars)
 
     def test_record_with_multi_keys(self):
         measure = mock.Mock()
@@ -122,14 +201,13 @@ class TestViewData(unittest.TestCase):
         view_data = view_data_module.ViewData(view=view,
                                               start_time=start_time,
                                               end_time=end_time)
-
         context = mock.Mock()
         context.map = {'key1': 'val1', 'key2': 'val2'}
         time = datetime.utcnow().isoformat() + 'Z'
         value = 1
         self.assertEqual({}, view_data.tag_value_aggregation_data_map)
 
-        view_data.record(context=context, value=value, timestamp=time)
+        view_data.record(context=context, value=value, timestamp=time, attachments=None)
         tag_values = view_data.get_tag_values(
             tags=context.map, columns=view.columns)
         tuple_vals = tuple(tag_values)
@@ -144,7 +222,7 @@ class TestViewData(unittest.TestCase):
         context_2.map = {'key1': 'val3', 'key2': 'val2'}
         time_2 = datetime.utcnow().isoformat() + 'Z'
         value_2 = 2
-        view_data.record(context=context_2, value=value_2, timestamp=time_2)
+        view_data.record(context=context_2, value=value_2, timestamp=time_2, attachments=None)
         tag_values_2 = view_data.get_tag_values(
             tags=context_2.map, columns=view.columns)
         tuple_vals_2 = tuple(tag_values_2)
@@ -157,7 +235,7 @@ class TestViewData(unittest.TestCase):
         value_3 = 3
         # Use the same context {'key1': 'val1', 'key2': 'val2'}.
         # Record to entry [(val1, val2), sum=1].
-        view_data.record(context=context, value=value_3, timestamp=time_3)
+        view_data.record(context=context, value=value_3, timestamp=time_3, attachments=None)
         self.assertEqual(4, sum_data.sum_data)
         # The other entry should remain unchanged.
         self.assertEqual(2, sum_data_2.sum_data)
@@ -172,12 +250,11 @@ class TestViewData(unittest.TestCase):
         view_data = view_data_module.ViewData(view=view,
                                               start_time=start_time,
                                               end_time=end_time)
-
         context = mock.Mock()
         context.map = {'key1': 'val1', 'key3': 'val3'}  # key2 is not in the context.
         time = datetime.utcnow().isoformat() + 'Z'
         value = 4
-        view_data.record(context=context, value=value, timestamp=time)
+        view_data.record(context=context, value=value, timestamp=time, attachments=None)
         tag_values = view_data.get_tag_values(
             tags=context.map, columns=view.columns)
         tuple_vals = tuple(tag_values)