Skip to content

Commit bf79c0a

Browse files
committed
Merge pull request prometheus#7 from brian-brazil/histogram
Add histogram support.
2 parents 19e1bf3 + b4686db commit bf79c0a

File tree

3 files changed

+175
-10
lines changed

3 files changed

+175
-10
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,31 @@ with s.time():
9292
pass
9393
```
9494

95+
### Histogram
96+
97+
Histograms track the size and number of events in buckets.
98+
This allows for aggregatable calculation of quantiles.
99+
100+
```python
101+
from prometheus_client import Histogram
102+
h = Histogram('request_latency_seconds', 'Description of histogram')
103+
h.observe(4.7) # Observe 4.7 (seconds in this case)
104+
```
105+
106+
The default buckets are intended to cover a typical web/rpc request from milliseconds to seconds.
107+
They can be overridden by passing `buckets` keyword argument to `Histogram`.
108+
109+
There are utilities for timing code:
110+
111+
```python
112+
@h.time()
113+
def f():
114+
pass
115+
116+
with h.time():
117+
pass
118+
```
119+
95120
### Labels
96121

97122
All metrics can have labels, allowing grouping of related time series.

prometheus_client/__init__.py

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
from functools import wraps
1717
from threading import Lock
1818

19-
__all__ = ['Counter', 'Gauge', 'Summary', 'CollectorRegistry']
19+
__all__ = ['Counter', 'Gauge', 'Summary', 'Histogram']
2020

2121
_METRIC_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$')
2222
_METRIC_LABEL_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$')
2323
_RESERVED_METRIC_LABEL_NAME_RE = re.compile(r'^__.*$')
24+
_INF = float("inf")
25+
_MINUS_INF = float("-inf")
2426

2527

2628

@@ -71,7 +73,7 @@ def get_sample_value(self, name, labels=None):
7173
REGISTRY = CollectorRegistry()
7274
'''The default registry.'''
7375

74-
_METRIC_TYPES = ('counter', 'gauge', 'summary', 'untyped')
76+
_METRIC_TYPES = ('counter', 'gauge', 'summary', 'histogram', 'untyped')
7577

7678
class Metric(object):
7779
'''A single metric and it's samples.'''
@@ -90,10 +92,11 @@ def add_sample(self, name, labels, value):
9092

9193
class _LabelWrapper(object):
9294
'''Handles labels for the wrapped metric.'''
93-
def __init__(self, wrappedClass, labelnames):
95+
def __init__(self, wrappedClass, labelnames, **kwargs):
9496
self._wrappedClass = wrappedClass
9597
self._type = wrappedClass._type
9698
self._labelnames = labelnames
99+
self._kwargs = kwargs
97100
self._lock = Lock()
98101
self._metrics = {}
99102

@@ -108,7 +111,7 @@ def labels(self, *labelvalues):
108111
labelvalues = tuple(labelvalues)
109112
with self._lock:
110113
if labelvalues not in self._metrics:
111-
self._metrics[labelvalues] = self._wrappedClass()
114+
self._metrics[labelvalues] = self._wrappedClass(**self._kwargs)
112115
return self._metrics[labelvalues]
113116

114117
def remove(self, *labelvalues):
@@ -129,7 +132,7 @@ def _samples(self):
129132

130133
def _MetricWrapper(cls):
131134
'''Provides common functionality for metrics.'''
132-
def init(name, documentation, labelnames=(), namespace='', subsystem='', registry=REGISTRY):
135+
def init(name, documentation, labelnames=(), namespace='', subsystem='', registry=REGISTRY, **kwargs):
133136
if labelnames:
134137
for l in labelnames:
135138
if not _METRIC_LABEL_NAME_RE.match(l):
@@ -138,9 +141,9 @@ def init(name, documentation, labelnames=(), namespace='', subsystem='', registr
138141
raise ValueError('Reserved label metric name: ' + l)
139142
if l in cls._reserved_labelnames:
140143
raise ValueError('Reserved label metric name: ' + l)
141-
collector = _LabelWrapper(cls, labelnames)
144+
collector = _LabelWrapper(cls, labelnames, **kwargs)
142145
else:
143-
collector = cls()
146+
collector = cls(**kwargs)
144147

145148
full_name = ''
146149
if namespace:
@@ -159,7 +162,8 @@ def collect():
159162
return [metric]
160163
collector.collect = collect
161164

162-
registry.register(collector)
165+
if registry:
166+
registry.register(collector)
163167
return collector
164168

165169
return init
@@ -300,6 +304,73 @@ def _samples(self):
300304
('_count', {}, self._count),
301305
('_sum', {}, self._sum))
302306

307+
def _floatToGoString(d):
308+
if d == _INF:
309+
return '+Inf'
310+
elif d == _MINUS_INF:
311+
return '-Inf'
312+
else:
313+
return repr(d)
314+
315+
@_MetricWrapper
316+
class Histogram(object):
317+
_type = 'histogram'
318+
_reserved_labelnames = ['histogram']
319+
def __init__(self, buckets=(.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, _INF)):
320+
self._sum = 0.0
321+
self._lock = Lock()
322+
buckets = [float (b) for b in buckets]
323+
if buckets != sorted(buckets):
324+
# This is probably an error on the part of the user,
325+
# so raise rather than sorting for them.
326+
raise ValueError('Buckets not in sorted order')
327+
if buckets and buckets[-1] != _INF:
328+
buckets.append(_INF)
329+
if len(buckets) < 2:
330+
raise ValueError('Must have at least two buckets')
331+
self._upper_bounds = buckets
332+
self._buckets = [0.0] * len(buckets)
333+
334+
def observe(self, amount):
335+
'''Observe the given amount.'''
336+
with self._lock:
337+
self._sum += amount
338+
for i, bound in enumerate(self._upper_bounds):
339+
if amount <= bound:
340+
self._buckets[i] += 1
341+
break
342+
343+
def time(self):
344+
'''Time a block of code or function, and observe the duration in seconds.
345+
346+
Can be used as a function decorator or context manager.
347+
'''
348+
class Timer(object):
349+
def __init__(self, histogram):
350+
self._histogram = histogram
351+
def __enter__(self):
352+
self._start = time.time()
353+
def __exit__(self, typ, value, traceback):
354+
# Time can go backwards.
355+
self._histogram.observe(max(time.time() - self._start, 0))
356+
def __call__(self, f):
357+
@wraps(f)
358+
def wrapped(*args, **kwargs):
359+
with self:
360+
return f(*args, **kwargs)
361+
return wrapped
362+
return Timer(self)
363+
364+
def _samples(self):
365+
with self._lock:
366+
samples = []
367+
acc = 0
368+
for i, bound in enumerate(self._upper_bounds):
369+
acc += self._buckets[i]
370+
samples.append(('_bucket', {'le': _floatToGoString(bound)}, acc))
371+
samples.append(('_count', {}, acc))
372+
samples.append(('_sum', {}, self._sum))
373+
return tuple(samples)
303374

304375

305376
CONTENT_TYPE_LATEST = 'text/plain; version=0.0.4; charset=utf-8'
@@ -320,7 +391,7 @@ def generate_latest(registry=REGISTRY):
320391
for k, v in labels.items()]))
321392
else:
322393
labelstr = ''
323-
output.append('{0}{1} {2}\n'.format(name, labelstr, value))
394+
output.append('{0}{1} {2}\n'.format(name, labelstr, _floatToGoString(value)))
324395
return ''.join(output).encode('utf-8')
325396

326397

@@ -353,6 +424,9 @@ def write_to_textfile(path, registry):
353424
s = Summary('ss', 'A summary', ['a', 'b'])
354425
s.labels('c', 'd').observe(17)
355426

427+
h = Histogram('hh', 'A histogram')
428+
h.observe(.6)
429+
356430
from BaseHTTPServer import HTTPServer
357431
server_address = ('', 8000)
358432
httpd = HTTPServer(server_address, MetricsHandler)

tests/test_client.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import unicode_literals
22
import unittest
33

4-
from prometheus_client import Gauge, Counter, Summary
4+
from prometheus_client import Gauge, Counter, Summary, Histogram
55
from prometheus_client import CollectorRegistry, generate_latest
66

77
class TestCounter(unittest.TestCase):
@@ -104,6 +104,72 @@ def test_block_decorator(self):
104104
pass
105105
self.assertEqual(1, self.registry.get_sample_value('s_count'))
106106

107+
class TestHistogram(unittest.TestCase):
108+
def setUp(self):
109+
self.registry = CollectorRegistry()
110+
self.histogram = Histogram('h', 'help', registry=self.registry)
111+
112+
def test_histogram(self):
113+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'}))
114+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '2.5'}))
115+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '5.0'}))
116+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
117+
self.assertEqual(0, self.registry.get_sample_value('h_count'))
118+
self.assertEqual(0, self.registry.get_sample_value('h_sum'))
119+
120+
self.histogram.observe(2)
121+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'}))
122+
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '2.5'}))
123+
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '5.0'}))
124+
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
125+
self.assertEqual(1, self.registry.get_sample_value('h_count'))
126+
self.assertEqual(2, self.registry.get_sample_value('h_sum'))
127+
128+
self.histogram.observe(2.5)
129+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'}))
130+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '2.5'}))
131+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '5.0'}))
132+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
133+
self.assertEqual(2, self.registry.get_sample_value('h_count'))
134+
self.assertEqual(4.5, self.registry.get_sample_value('h_sum'))
135+
136+
self.histogram.observe(float("inf"))
137+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'}))
138+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '2.5'}))
139+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '5.0'}))
140+
self.assertEqual(3, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
141+
self.assertEqual(3, self.registry.get_sample_value('h_count'))
142+
self.assertEqual(float("inf"), self.registry.get_sample_value('h_sum'))
143+
144+
def test_setting_buckets(self):
145+
h = Histogram('h', 'help', registry=None, buckets=[0, 1, 2])
146+
self.assertEqual([0.0, 1.0, 2.0, float("inf")], h._upper_bounds)
147+
148+
h = Histogram('h', 'help', registry=None, buckets=[0, 1, 2, float("inf")])
149+
self.assertEqual([0.0, 1.0, 2.0, float("inf")], h._upper_bounds)
150+
151+
self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, buckets=[])
152+
self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, buckets=[float("inf")])
153+
self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, buckets=[3, 1])
154+
155+
def test_function_decorator(self):
156+
self.assertEqual(0, self.registry.get_sample_value('h_count'))
157+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
158+
@self.histogram.time()
159+
def f():
160+
pass
161+
f()
162+
self.assertEqual(1, self.registry.get_sample_value('h_count'))
163+
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
164+
165+
def test_block_decorator(self):
166+
self.assertEqual(0, self.registry.get_sample_value('h_count'))
167+
self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
168+
with self.histogram.time():
169+
pass
170+
self.assertEqual(1, self.registry.get_sample_value('h_count'))
171+
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
172+
107173
class TestMetricWrapper(unittest.TestCase):
108174
def setUp(self):
109175
self.registry = CollectorRegistry()

0 commit comments

Comments
 (0)