8000 Merge pull request #669 from prometheus/exemplars · Cameron-Calpin/client_python@c8f1bd3 · GitHub
[go: up one dir, main page]

Skip to content

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit c8f1bd3

Browse files
authored
Merge pull request prometheus#669 from prometheus/exemplars
Support exemplars for single process mode
2 parents 09fb459 + fb35c5f commit c8f1bd3

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
+
_validate_labelname(k)
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
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
@@ -46,7 +47,7 @@ def test_increment(self):
4647
self.assertEqual(1, self.registry.get_sample_value('c_total'))
4748
self.counter.inc(7)
4849
self.assertEqual(8, self.registry.get_sample_value('c_total'))
49-
50+
5051
def test_repr(self):
5152
self.assertEqual(repr(self.counter), "prometheus_client.metrics.Counter(c)")
5253

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

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

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

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

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

0 commit comments

Comments
 (0)
0