Skip to content

Commit 9a39e5c

Browse files
committed
Add support for modelling and exposing exemplars
Signed-off-by: Brian Brazil <brian.brazil@robustperception.io>
1 parent 1b840fb commit 9a39e5c

File tree

4 files changed

+87
-11
lines changed

4 files changed

+87
-11
lines changed

prometheus_client/core.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
# Timestamp and exemplar are optional.
3939
# Value can be an int or a float.
4040
# Timestamp can be a float containing a unixtime in seconds,
41-
# or a Timestamp object.
41+
# a Timestamp object, or None.
42+
# Exemplar can be an Exemplar object, or None.
4243
Sample = namedtuple('Sample', ['name', 'labels', 'value', 'timestamp', 'exemplar'])
4344
Sample.__new__.__defaults__ = (None, None)
4445

@@ -64,6 +65,10 @@ def __eq__(self, other):
6465
return self.sec == other.sec and self.nsec == other.nsec
6566

6667

68+
Exemplar = namedtuple('Exemplar', ['labels', 'value', 'timestamp'])
69+
Exemplar.__new__.__defaults__ = (None, )
70+
71+
6772
class CollectorRegistry(object):
6873
'''Metric collector registry.
6974
@@ -211,11 +216,11 @@ def __init__(self, name, documentation, typ, unit=''):
211216
self.unit = unit
212217
self.samples = []
213218

214-
def add_sample(self, name, labels, value, timestamp=None):
219+
def add_sample(self, name, labels, value, timestamp=None, exemplar=None):
215220
'''Add a sample to the metric.
216221
217222
Internal-only, do not use.'''
218-
self.samples.append(Sample(name, labels, value, timestamp))
223+
self.samples.append(Sample(name, labels, value, timestamp, exemplar))
219224

220225
def __eq__(self, other):
221226
return (isinstance(other, Metric) and
@@ -362,12 +367,20 @@ def add_metric(self, labels, buckets, sum_value, timestamp=None):
362367
363368
Args:
364369
labels: A list of label values
365-
buckets: A list of pairs of bucket names and values.
370+
buckets: A list of lists.
371+
Each inner list can be a pair of bucket name and value,
372+
or a triple of bucket name, value, and exemplar.
366373
The buckets must be sorted, and +Inf present.
367374
sum_value: The sum value of the metric.
368375
'''
369-
for bucket, value in buckets:
370-
self.samples.append(Sample(self.name + '_bucket', dict(list(zip(self._labelnames, labels)) + [('le', bucket)]), value, timestamp))
376+
for b in buckets:
377+
bucket, value = b[:2]
378+
exemplar = None
379+
if len(b) == 3:
380+
exemplar = b[2]
381+
self.samples.append(Sample(self.name + '_bucket',
382+
dict(list(zip(self._labelnames, labels)) + [('le', bucket)]),
383+
value, timestamp, exemplar))
371384
# +Inf is last and provides the count value.
372385
self.samples.append(Sample(self.name + '_count', dict(zip(self._labelnames, labels)), buckets[-1][1], timestamp))
373386
self.samples.append(Sample(self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value, timestamp))
@@ -1030,8 +1043,6 @@ def create_response(request):
10301043
10311044
The default buckets are intended to cover a typical web/rpc request from milliseconds to seconds.
10321045
They can be overridden by passing `buckets` keyword argument to `Histogram`.
1033-
1034-
**NB** The Python client doesn't store or expose quantile information at this time.
10351046
'''
10361047
_type = 'histogram'
10371048
_reserved_labelnames = ['le']

prometheus_client/exposition.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def generate_latest(registry=core.REGISTRY):
7171
for metric in registry.collect():
7272
mname = metric.name
7373
mtype = metric.type
74+
# Munging from OpenMetrics into Prometheus format.
7475
if mtype == 'counter':
7576
mname = mname + '_total'
7677
elif mtype == 'info':
@@ -84,12 +85,13 @@ def generate_latest(registry=core.REGISTRY):
8485
mtype = 'histogram'
8586
elif mtype == 'unknown':
8687
mtype = 'untyped'
88+
8789
output.append('# HELP {0} {1}'.format(
8890
mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
8991
output.append('\n# TYPE {0} {1}\n'.format(mname, mtype))
9092
for s in metric.samples:
9193
if s.name == metric.name + '_created':
92-
continue # Ignore OpenMetrics specific sample.
94+
continue # Ignore OpenMetrics specific sample. TODO: Make these into a gauge.
9395
if s.labels:
9496
labelstr = '{{{0}}}'.format(','.join(
9597
['{0}="{1}"'.format(

prometheus_client/openmetrics/exposition.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,27 @@ def generate_latest(registry):
2525
for k, v in sorted(s.labels.items())]))
2626
else:
2727
labelstr = ''
28+
if s.exemplar:
29+
if metric.type != 'histogram' or not s.name.endswith('_bucket'):
30+
raise ValueError("Metric {0} has exemplars, but is not a histogram bucket".format(metric.name))
31+
labels = '{{{0}}}'.format(','.join(
32+
['{0}="{1}"'.format(
33+
k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))
34+
for k, v in sorted(s.exemplar.labels.items())]))
35+
if s.exemplar.timestamp is not None:
36+
exemplarstr = ' # {0} {1} {2}'.format(labels,
37+
core._floatToGoString(s.exemplar.value), s.exemplar.timestamp)
38+
else:
39+
exemplarstr = ' # {0} {1}'.format(labels,
40+
core._floatToGoString(s.exemplar.value))
41+
else:
42+
exemplarstr = ''
2843
timestamp = ''
2944
if s.timestamp is not None:
3045
# Convert to milliseconds.
3146
timestamp = ' {0}'.format(s.timestamp)
32-
output.append('{0}{1} {2}{3}\n'.format(s.name, labelstr, core._floatToGoString(s.value), timestamp))
47+
output.append('{0}{1} {2}{3}{4}\n'.format(s.name, labelstr,
48+
core._floatToGoString(s.value), timestamp, exemplarstr))
3349
output.append('# EOF\n')
3450
return ''.join(output).encode('utf-8')
3551

tests/openmetrics/test_exposition.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from prometheus_client import Gauge, Counter, Summary, Histogram, Info, Enum, Metric
1313
from prometheus_client import CollectorRegistry
14-
from prometheus_client.core import GaugeHistogramMetricFamily, Timestamp
14+
from prometheus_client.core import GaugeHistogramMetricFamily, Timestamp, Exemplar
1515
from prometheus_client.openmetrics.exposition import (
1616
generate_latest,
1717
)
@@ -86,6 +86,53 @@ def test_histogram(self):
8686
# EOF
8787
''', generate_latest(self.registry))
8888

89+
def test_histogram_exemplar(self):
90+
class MyCollector(object):
91+
def collect(self):
92+
metric = Metric("hh", "help", 'histogram')
93+
# This is not sane, but it covers all the cases.
94+
metric.add_sample("hh_bucket", {"le": "1"}, 0, None, Exemplar({'a': 'b'}, 0.5))
95+
metric.add_sample("hh_bucket", {"le": "2"}, 0, None, Exemplar({'le': '7'}, 0.5, 12))
96+
metric.add_sample("hh_bucket", {"le": "3"}, 0, 123, Exemplar({'a': 'b'}, 2.5, 12))
97+
metric.add_sample("hh_bucket", {"le": "4"}, 0, None, Exemplar({'a': '\n"\\'}, 3.5))
98+
metric.add_sample("hh_bucket", {"le": "+Inf"}, 0, None, None)
99+
yield metric
100+
101+
self.registry.register(MyCollector())
102+
self.assertEqual(b'''# HELP hh help
103+
# TYPE hh histogram
104+
hh_bucket{le="1"} 0.0 # {a="b"} 0.5
105+
hh_bucket{le="2"} 0.0 # {le="7"} 0.5 12
106+
hh_bucket{le="3"} 0.0 123 # {a="b"} 2.5 12
107+
hh_bucket{le="4"} 0.0 # {a="\\n\\"\\\\"} 3.5
108+
hh_bucket{le="+Inf"} 0.0
109+
# EOF
110+
''', generate_latest(self.registry))
111+
112+
def test_nonhistogram_exemplar(self):
113+
class MyCollector(object):
114+
def collect(self):
115+
metric = Metric("hh", "help", 'untyped')
116+
# This is not sane, but it covers all the cases.
117+
metric.add_sample("hh_bucket", {}, 0, None, Exemplar({'a': 'b'}, 0.5))
118+
yield metric
119+
120+
self.registry.register(MyCollector())
121+
with self.assertRaises(ValueError):
122+
generate_latest(self.registry)
123+
124+
def test_nonhistogram_bucket_exemplar(self):
125+
class MyCollector(object):
126+
def collect(self):
127+
metric = Metric("hh", "help", 'histogram')
128+
# This is not sane, but it covers all the cases.
129+
metric.add_sample("hh_count", {}, 0, None, Exemplar({'a': 'b'}, 0.5))
130+
yield metric
131+
132+
self.registry.register(MyCollector())
133+
with self.assertRaises(ValueError):
134+
generate_latest(self.registry)
135+
89136
def test_gaugehistogram(self):
90137
self.custom_collector(GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))]))
91138
self.assertEqual(b'''# HELP gh help

0 commit comments

Comments
 (0)