8000 Support exemplars for single process mode · dirkhusemann/client_python@fb35c5f · GitHub
[go: up one dir, main page]

Skip to content

Commit fb35c5f

Browse files
committed
Support exemplars for single process mode
Signed-off-by: Chris Marchbanks <csmarchbanks@gmail.com>
1 parent 9a24236 commit fb35c5f

File tree

5 files changed

+138
-51
lines changed

5 files changed

+138
-51
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,27 @@ c.labels('get', '/')
231231
c.labels('post', '/submit')
232232
```
233233

234+
### Exemplars
235+
236+
Exemplars can be added to counter and histogram metrics. Exemplars can be
237+
specified by passing a dict of label value pairs to be exposed as the exemplar.
238+
For example with a counter:
239+
240+
```python
241+
from prometheus_client import Counter
242+
c = Counter('my_requests_total', 'HTTP Failures', ['method', 'endpoint'])
243+
c.labels('get', '/').inc(exemplar={'trace_id': 'abc123'})
244+
c.labels('post', '/submit').inc(1.0, {'trace_id': 'def456'})
245+
```
246+
247+
And with a histogram:
248+
249+
```python
250+
from prometheus_client import Histogram
251+
h = Histogram('request_latency_seconds', 'Description of histogram')
252+
h.observe(4.7, {'trace_id': 'abc123'})
253+
```
254+
234255
### Process Collector
235256

236257
The Python client automatically exports metrics about process CPU usage, RAM,
@@ -510,6 +531,7 @@ This comes with a number of limitations:
510531
- Info and Enum metrics do not work
511532
- The pushgateway cannot be used
512533
- Gauges cannot use the `pid` label
534+
- Exemplars are not supported
513535

514536
There's several steps to getting this working:
515537

prometheus_client/metrics.py

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
RESERVED_METRIC_LABEL_NAME_RE,
1111
)
1212
from .registry import REGISTRY
13+
from .samples import Exemplar
1314
from .utils import floatToGoString, INF
1415

1516
if sys.version_info > (3,):
@@ -36,18 +37,32 @@ def _build_full_name(metric_type, name, namespace, subsystem, unit):
3637
return full_name
3738

3839

40+
def _validate_labelname(l):
41+
if not METRIC_LABEL_NAME_RE.match(l):
42+
raise ValueError('Invalid label metric name: ' + l)
43+
if RESERVED_METRIC_LABEL_NAME_RE.match(l):
44+
raise ValueError('Reserved label metric name: ' + l)
45+
46+
3947
def _validate_labelnames(cls, labelnames):
4048
labelnames = tuple(labelnames)
4149
for l in labelnames:
42-
if not METRIC_LABEL_NAME_RE.match(l):
43-
raise ValueError('Invalid label metric name: ' + l)
44-
if RESERVED_METRIC_LABEL_NAME_RE.match(l):
45-
raise ValueError('Reserved label metric name: ' + l)
50+
_validate_labelname(l)
4651
if l in cls._reserved_labelnames:
4752
raise ValueError('Reserved label metric name: ' + l)
4853
return labelnames
4954

5055

56+
def _validate_exemplar(exemplar):
57+
runes = 0
58+
for k, v in exemplar.items():
59+
_validate_labelname(k)
60+
runes += len(k)
61+
runes += len(v)
62+
if runes > 128:
63+
raise ValueError('Exemplar labels have %d UTF-8 characters, exceeding the limit of 128')
64+
65+
5166
class MetricWrapperBase(object):
5267
_type = None
5368
_reserved_labelnames = ()
@@ -76,8 +91,8 @@ def describe(self):
7691

7792
def collect(self):
7893
metric = self._get_metric()
79-
for suffix, labels, value in self._samples():
80-
metric.add_sample(self._name + suffix, labels, value)
94+
for suffix, labels, value, timestamp, exemplar in self._samples():
95+
metric.add_sample(self._name + suffix, labels, value, timestamp, exemplar)
8196
return [metric]
8297

8398
def __str__(self):
@@ -202,8 +217,8 @@ def _multi_samples(self):
202217
metrics = self._metrics.copy()
203218
for labels, metric in metrics.items():
204219
series_labels = list(zip(self._labelnames, labels))
205-
for suffix, sample_labels, value in metric._samples():
206-
yield (suffix, dict(series_labels + list(sample_labels.items())), value)
220+
for suffix, sample_labels, value, timestamp, exemplar in metric._samples():
221+
yield (suffix, dict(series_labels + list(sample_labels.items())), value, timestamp, exemplar)
207222

208223
def _child_samples(self): # pragma: no cover
209224
raise NotImplementedError('_child_samples() must be implemented by %r' % self)
@@ -256,12 +271,15 @@ def _metric_init(self):
256271
self._labelvalues)
257272
self._created = time.time()
258273

259-
def inc(self, amount=1):
274+
def inc(self, amount=1, exemplar=None):
260275
"""Increment counter by the given amount."""
261276
self._raise_if_not_observable()
262277
if amount < 0:
263278
raise ValueError('Counters can only be incremented by non-negative amounts.')
264279
self._value.inc(amount)
280+
if exemplar:
281+
_validate_exemplar(exemplar)
282+
self._value.set_exemplar(Exemplar(exemplar, amount, time.time()))
265283

266284
def count_exceptions(self, exception=Exception):
267285
"""Count exceptions in a block of code or function.
@@ -275,8 +293,8 @@ def count_exceptions(self, exception=Exception):
275293

276294
def _child_samples(self):
277295
return (
278-
('_total', {}, self._value.get()),
279-
('_created', {}, self._created),
296+
('_total', {}, self._value.get(), None, self._value.get_exemplar()),
297+
('_created', {}, self._created, None, None),
280298
)
281299

282300

@@ -399,12 +417,12 @@ def set_function(self, f):
399417
self._raise_if_not_observable()
400418

401419
def samples(self):
402-
return (('', {}, float(f())),)
420+
return (('', {}, float(f()), None, None),)
403421

404422
self._child_samples = create_bound_method(samples, self)
405423

406424
def _child_samples(self):
407-
return (('', {}, self._value.get()),)
425+
return (('', {}, self._value.get(), None, None),)
408426

409427

410428
class Summary(MetricWrapperBase):
@@ -470,9 +488,10 @@ def time(self):
470488

471489
def _child_samples(self):
472490
return (
473-
('_count', {}, self._count.get()),
474-
('_sum', {}, self._sum.get()),
475-
('_created', {}, self._created))
491+
('_count', {}, self._count.get(), None, None),
492+
('_sum', {}, self._sum.get(), None, None),
493+
('_created', {}, self._created, None, None),
494+
)
476495

477496

478497
class Histogram(MetricWrapperBase):
@@ -564,7 +583,7 @@ def _metric_init(self):
564583
self._labelvalues + (floatToGoString(b),))
565584
)
566585

567-
def observe(self, amount):
586+
def observe(self, amount, exemplar=None):
568587
"""Observe the given amount.
569588
570589
The amount is usually positive or zero. Negative values are
@@ -579,6 +598,9 @@ def observe(self, amount):
579598
for i, bound in enumerate(self._upper_bounds):
580599
if amount <= bound:
581600
self._buckets[i].inc(1)
601+
if exemplar:
602+
_validate_exemplar(exemplar)
603+
self._buckets[i].set_exemplar(Exemplar(exemplar, amount, time.time()))
582604
break
583605

584606
def time(self):
@@ -593,11 +615,11 @@ def _child_samples(self):
593615
acc = 0
594616
for i, bound in enumerate(self._upper_bounds):
595617
acc += self._buckets[i].get()
596-
samples.append(('_bucket', {'le': floatToGoString(bound)}, acc))
597-
samples.append(('_count', {}, acc))
618+
samples.append(('_bucket', {'le': floatToGoString(bound)}, acc, None, self._buckets[i].get_exemplar()))
619+
samples.append(('_count', {}, acc, None, None))
598620
if self._upper_bounds[0] >= 0:
599-
samples.append(('_sum', {}, self._sum.get()))
600-
samples.append(('_created', {}, self._created))
621+
samples.append(('_sum', {}, self._sum.get(), None, None))
622+
samples.append(('_created', {}, self._created, None, None))
601623
return tuple(samples)
602624

603625

@@ -634,7 +656,7 @@ def info(self, val):
634656

635657
def _child_samples(self):
636658
with self._lock:
637-
return (('_info', self._value, 1.0,),)
659+
return (('_info', self._value, 1.0, None, None),)
638660

639661

640662
class Enum(MetricWrapperBase):
@@ -692,7 +714,7 @@ def state(self, state):
692714
def _child_samples(self):
693715
with self._lock:
694716
return [
695-
('', {self._name: s}, 1 if i == self._value else 0,)
717+
('', {self._name: s}, 1 if i == self._value else 0, None, None)
696718
for i, s
697719
in enumerate(self._states)
698720
]

prometheus_client/values.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class MutexValue(object):
1414

1515
def __init__(self, typ, metric_name, name, labelnames, labelvalues, **kwargs):
1616
self._value = 0.0
17+
self._exemplar = None
1718
self._lock = Lock()
1819

1920
def inc(self, amount):
@@ -24,10 +25,18 @@ def set(self, value):
2425
with self._lock:
2526
self._value = value
2627

28+
def set_exemplar(self, exemplar):
29+
with self._lock:
30+
self._exemplar = exemplar
31+
2732
def get(self):
2833
with self._lock:
2934
return self._value
3035

36+
def get_exemplar(self):
37+
with self._lock:
38+
return self._exemplar
39+
3140

3241
def MultiProcessValue(process_identifier=os.getpid):
3342
"""Returns a MmapedValue class based on a process_identifier function.
@@ -100,11 +109,19 @@ def set(self, value):
100109
self._value = value
101110
self._file.write_value(self._key, self._value)
102111

112+
def set_exemplar(self, exemplar):
113+
# TODO: Implement exemplars for multiprocess mode.
114+
return
115+
103116
def get(self):
104117
with lock:
105118
self.__check_for_pid_change()
106119
return self._value
107120

121+
def get_exemplar(self):
122+
# TODO: Implement exemplars for multiprocess mode.
123+
return None
124+
108125
return MmapedValue
109126

110127

tests/openmetrics/test_exposition.py

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ def test_summary(self):
7070
# EOF
7171
""", generate_latest(self.registry))
7272

73-
@unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.")
7473
def test_histogram(self):
7574
s = Histogram('hh', 'A histogram', registry=self.registry)
7675
s.observe(0.05)
@@ -114,37 +113,28 @@ def test_histogram_negative_buckets(self):
114113
""", generate_latest(self.registry))
115114

116115
def test_histogram_exemplar(self):
117-
class MyCollector(object):
118-
def collect(self):
119-
metric = Metric("hh", "help", 'histogram')
120-
# This is not sane, but it covers all the cases.
121-
metric.add_sample("hh_bucket", {"le": "1"}, 0, None, Exemplar({'a': 'b'}, 0.5))
122-
metric.add_sample("hh_bucket", {"le": "2"}, 0, None, Exemplar({'le': '7'}, 0.5, 12))
123-
metric.add_sample("hh_bucket", {"le": "3"}, 0, 123, Exemplar({'a': 'b'}, 2.5, 12))
124-
metric.add_sample("hh_bucket", {"le": "4"}, 0, None, Exemplar({'a': '\n"\\'}, 3.5))
125-
metric.add_sample("hh_bucket", {"le": "+Inf"}, 0, None, None)
126-
yield metric
127-
128-
self.registry.register(MyCollector())
129-
self.assertEqual(b"""# HELP hh help
116+
s = Histogram('hh', 'A histogram', buckets=[1, 2, 3, 4], registry=self.registry)
117+
s.observe(0.5, {'a': 'b'})
118+
s.observe(1.5, {'le': '7'})
119+
s.observe(2.5, {'a': 'b'})
120+
s.observe(3.5, {'a': '\n"\\'})
121+
print(generate_latest(self.registry))
122+
self.assertEqual(b"""# HELP hh A histogram
130123
# TYPE hh histogram
131-
hh_bucket{le="1"} 0.0 # {a="b"} 0.5
132-
hh_bucket{le="2"} 0.0 # {le="7"} 0.5 12
133-
hh_bucket{le="3"} 0.0 123 # {a="b"} 2.5 12
134-
hh_bucket{le="4"} 0.0 # {a="\\n\\"\\\\"} 3.5
135-
hh_bucket{le="+Inf"} 0.0
124+
hh_bucket{le="1.0"} 1.0 # {a="b"} 0.5 123.456
125+
hh_bucket{le="2.0"} 2.0 # {le="7"} 1.5 123.456
126+
hh_bucket{le="3.0"} 3.0 # {a="b"} 2.5 123.456
127+
hh_bucket{le="4.0"} 4.0 # {a="\\n\\"\\\\"} 3.5 123.456
128+
hh_bucket{le="+Inf"} 4.0
129+
hh_count 4.0
130+
hh_sum 8.0
131+
hh_created 123.456
136132
# EOF
137133
""", generate_latest(self.registry))
138134

139135
def test_counter_exemplar(self):
140-
class MyCollector(object):
141-
def collect(self):
142-
metric = Metric("cc", "A counter", 'counter')
143-
metric.add_sample("cc_total", {}, 1, None, Exemplar({'a': 'b'}, 1.0, 123.456))
144-
metric.add_sample("cc_created", {}, 123.456, None, None)
145-
yield metric
146-
147-
self.registry.register(MyCollector())
136+
c = Counter('cc', 'A counter', registry=self.registry)
137+
c.inc(exemplar={'a': 'b'})
148138
self.assertEqual(b"""# HELP cc A counter
149139
# TYPE cc counter
150140
cc_total 1.0 # {a="b"} 1.0 123.456

tests/test_core.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# coding=utf-8
12
from __future__ import unicode_literals
23

34
from concurrent.futures import ThreadPoolExecutor
@@ -45,7 +46,7 @@ def test_increment(self):
4546
self.assertEqual(1, self.registry.get_sample_value('c_total'))
4647
self.counter.inc(7)
4748
self.assertEqual(8, self.registry.get_sample_value('c_total'))
48-
49+
4950
def test_repr(self):
5051
self.assertEqual(repr(self.counter), "prometheus_client.metrics.Counter(c)")
5152

@@ -98,6 +99,28 @@ def test_inc_not_observable(self):
9899
counter = Counter('counter', 'help', labelnames=('label',), registry=self.registry)
99100
assert_not_observable(counter.inc)
100101

102+
def test_exemplar_invalid_label_name(self):
103+
self.assertRaises(ValueError, self.counter.inc, exemplar={':o)': 'smile'})
104+
self.assertRaises(ValueError, self.counter.inc, exemplar={'1': 'number'})
105+
106+
def test_exemplar_unicode(self):
107+
# 128 characters should not raise, even using characters larger than 1 byte.
108+
self.counter.inc(exemplar={
109+
'abcdefghijklmnopqrstuvwxyz': '26+16 characters',
110+
'x123456': '7+15 characters',
111+
'zyxwvutsrqponmlkjihgfedcba': '26+16 characters',
112+
'unicode': '7+15 chars 平',
113+
})
114+
115+
def test_exemplar_too_long(self):
116+
# 129 characters should fail.
117+
self.assertRaises(ValueError, self.counter.inc, exemplar={
118+
'abcdefghijklmnopqrstuvwxyz': '26+16 characters',
119+
'x1234567': '8+15 characters',
120+
'zyxwvutsrqponmlkjihgfedcba': '26+16 characters',
121+
'y123456': '7+15 characters',
122+
})
123+
101124

102125
class TestGauge(unittest.TestCase):
103126
def setUp(self):
@@ -415,6 +438,19 @@ def test_block_decorator(self):
415438
self.assertEqual(1, self.registry.get_sample_value('h_count'))
416439
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
417440

441+
def test_exemplar_invalid_label_name(self):
442+
self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={':o)': 'smile'})
443+
self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={'1': 'number'})
444+
445+
def test_exemplar_too_long(self):
446+
# 129 characters in total should fail.
447+
self.assertRaises(ValueError, self.histogram.observe, 1.0, exemplar={
448+
'abcdefghijklmnopqrstuvwxyz': '26+16 characters',
449+
'x1234567': '8+15 characters',
450+
'zyxwvutsrqponmlkjihgfedcba': '26+16 characters',
451+
'y123456': '7+15 characters',
452+
})
453+
418454

419455
class TestInfo(unittest.TestCase):
420456
def setUp(self):

0 commit comments

Comments
 (0)
0