Repository: prometheus/client_python
Pull request #664 (2021-06-04 23:29:41)

Allow counters to emit exemplars via OpenMetrics
The parsing code already contains tests for parsing exemplars on counters, just the exposition is affected.

@brian-brazil as a member of OpenMetrics would you be willing to review this?


Patch:
diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py
index 6d5925e..56d00b5 100644
--- a/prometheus_client/openmetrics/exposition.py
+++ b/prometheus_client/openmetrics/exposition.py
@@ -8,6 +8,14 @@ CONTENT_TYPE_LATEST = str('application/openmetrics-text; version=0.0.1; charset=
 """Content type of the latest OpenMetrics text format"""
 
 
+def _is_valid_exemplar_metric(metric, sample):
+    if metric.type == 'counter' and sample.name.endswith('_total'):
+        return True
+    if metric.type in ('histogram', 'gaugehistogram') and sample.name.endswith('_bucket'):
+        return True
+    return False
+
+
 def generate_latest(registry):
     '''Returns the metrics from the registry in latest text format as a string.'''
     output = []
@@ -28,8 +36,8 @@ def generate_latest(registry):
                 else:
                     labelstr = ''
                 if s.exemplar:
-                    if metric.type not in ('histogram', 'gaugehistogram') or not s.name.endswith('_bucket'):
-                        raise ValueError("Metric {0} has exemplars, but is not a histogram bucket".format(metric.name))
+                    if not _is_valid_exemplar_metric(metric, s):
+                        raise ValueError("Metric {0} has exemplars, but is not a histogram bucket or counter".format(metric.name))
                     labels = '{{{0}}}'.format(','.join(
                         ['{0}="{1}"'.format(
                             k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))


Test patch:
diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py
index c373962..72578a0 100644
--- a/tests/openmetrics/test_exposition.py
+++ b/tests/openmetrics/test_exposition.py
@@ -136,7 +136,23 @@ hh_bucket{le="+Inf"} 0.0
 # EOF
 """, generate_latest(self.registry))
 
-    def test_nonhistogram_exemplar(self):
+    def test_counter_exemplar(self):
+        class MyCollector(object):
+            def collect(self):
+                metric = Metric("cc", "A counter", 'counter')
+                metric.add_sample("cc_total", {}, 1, None, Exemplar({'a': 'b'}, 1.0, 123.456))
+                metric.add_sample("cc_created", {}, 123.456, None, None)
+                yield metric
+
+        self.registry.register(MyCollector())
+        self.assertEqual(b"""# HELP cc A counter
+# TYPE cc counter
+cc_total 1.0 # {a="b"} 1.0 123.456
+cc_created 123.456
+# EOF
+""", generate_latest(self.registry))
+
+    def test_untyped_exemplar(self):
         class MyCollector(object):
             def collect(self):
                 metric = Metric("hh", "help", 'untyped')
@@ -148,7 +164,7 @@ hh_bucket{le="+Inf"} 0.0
         with self.assertRaises(ValueError):
             generate_latest(self.registry)
 
-    def test_nonhistogram_bucket_exemplar(self):
+    def test_histogram_non_bucket_exemplar(self):
         class MyCollector(object):
             def collect(self):
                 metric = Metric("hh", "help", 'histogram')
@@ -160,6 +176,18 @@ hh_bucket{le="+Inf"} 0.0
         with self.assertRaises(ValueError):
             generate_latest(self.registry)
 
+    def test_counter_non_total_exemplar(self):
+        class MyCollector(object):
+            def collect(self):
+                metric = Metric("cc", "A counter", 'counter')
+                metric.add_sample("cc_total", {}, 1, None, None)
+                metric.add_sample("cc_created", {}, 123.456, None, Exemplar({'a': 'b'}, 1.0, 123.456))
+                yield metric
+
+        self.registry.register(MyCollector())
+        with self.assertRaises(ValueError):
+            generate_latest(self.registry)
+
     def test_gaugehistogram(self):
         self.custom_collector(
             GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))], gsum_value=7))