From b4686db5096f0004e474766cc497cdabbabe7348 Mon Sep 17 00:00:00 2001 From: Brian Brazil Date: Sun, 22 Feb 2015 19:29:09 +0000 Subject: [PATCH] Add histogram support. --- README.md | 25 ++++++++++ prometheus_client/__init__.py | 92 +++++++++++++++++++++++++++++++---- tests/test_client.py | 68 +++++++++++++++++++++++++- 3 files changed, 175 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6455d820..e39b3474 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,31 @@ with s.time(): pass ``` +### Histogram + +Histograms track the size and number of events in buckets. +This allows for aggregatable calculation of quantiles. + +```python +from prometheus_client import Histogram +h = Histogram('request_latency_seconds', 'Description of histogram') +h.observe(4.7) # Observe 4.7 (seconds in this case) +``` + +The default buckets are intended to cover a typical web/rpc request from milliseconds to seconds. +They can be overridden by passing `buckets` keyword argument to `Histogram`. + +There are utilities for timing code: + +```python +@h.time() +def f(): + pass + +with h.time(): + pass +``` + ### Labels All metrics can have labels, allowing grouping of related time series. diff --git a/prometheus_client/__init__.py b/prometheus_client/__init__.py index aa446160..1847e091 100644 --- a/prometheus_client/__init__.py +++ b/prometheus_client/__init__.py @@ -16,11 +16,13 @@ from functools import wraps from threading import Lock -__all__ = ['Counter', 'Gauge', 'Summary', 'CollectorRegistry'] +__all__ = ['Counter', 'Gauge', 'Summary', 'Histogram'] _METRIC_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$') _METRIC_LABEL_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$') _RESERVED_METRIC_LABEL_NAME_RE = re.compile(r'^__.*$') +_INF = float("inf") +_MINUS_INF = float("-inf") @@ -71,7 +73,7 @@ def get_sample_value(self, name, labels=None): REGISTRY = CollectorRegistry() '''The default registry.''' -_METRIC_TYPES = ('counter', 'gauge', 'summary', 'untyped') +_METRIC_TYPES = ('counter', 'gauge', 'summary', 'histogram', 'untyped') class Metric(object): '''A single metric and it's samples.''' @@ -90,10 +92,11 @@ def add_sample(self, name, labels, value): class _LabelWrapper(object): '''Handles labels for the wrapped metric.''' - def __init__(self, wrappedClass, labelnames): + def __init__(self, wrappedClass, labelnames, **kwargs): self._wrappedClass = wrappedClass self._type = wrappedClass._type self._labelnames = labelnames + self._kwargs = kwargs self._lock = Lock() self._metrics = {} @@ -108,7 +111,7 @@ def labels(self, *labelvalues): labelvalues = tuple(labelvalues) with self._lock: if labelvalues not in self._metrics: - self._metrics[labelvalues] = self._wrappedClass() + self._metrics[labelvalues] = self._wrappedClass(**self._kwargs) return self._metrics[labelvalues] def remove(self, *labelvalues): @@ -129,7 +132,7 @@ def _samples(self): def _MetricWrapper(cls): '''Provides common functionality for metrics.''' - def init(name, documentation, labelnames=(), namespace='', subsystem='', registry=REGISTRY): + def init(name, documentation, labelnames=(), namespace='', subsystem='', registry=REGISTRY, **kwargs): if labelnames: for l in labelnames: if not _METRIC_LABEL_NAME_RE.match(l): @@ -138,9 +141,9 @@ def init(name, documentation, labelnames=(), namespace='', subsystem='', registr raise ValueError('Reserved label metric name: ' + l) if l in cls._reserved_labelnames: raise ValueError('Reserved label metric name: ' + l) - collector = _LabelWrapper(cls, labelnames) + collector = _LabelWrapper(cls, labelnames, **kwargs) else: - collector = cls() + collector = cls(**kwargs) full_name = '' if namespace: @@ -159,7 +162,8 @@ def collect(): return [metric] collector.collect = collect - registry.register(collector) + if registry: + registry.register(collector) return collector return init @@ -300,6 +304,73 @@ def _samples(self): ('_count', {}, self._count), ('_sum', {}, self._sum)) +def _floatToGoString(d): + if d == _INF: + return '+Inf' + elif d == _MINUS_INF: + return '-Inf' + else: + return repr(d) + +@_MetricWrapper +class Histogram(object): + _type = 'histogram' + _reserved_labelnames = ['histogram'] + def __init__(self, buckets=(.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, _INF)): + self._sum = 0.0 + self._lock = Lock() + buckets = [float (b) for b in buckets] + if buckets != sorted(buckets): + # This is probably an error on the part of the user, + # so raise rather than sorting for them. + raise ValueError('Buckets not in sorted order') + if buckets and buckets[-1] != _INF: + buckets.append(_INF) + if len(buckets) < 2: + raise ValueError('Must have at least two buckets') + self._upper_bounds = buckets + self._buckets = [0.0] * len(buckets) + + def observe(self, amount): + '''Observe the given amount.''' + with self._lock: + self._sum += amount + for i, bound in enumerate(self._upper_bounds): + if amount <= bound: + self._buckets[i] += 1 + break + + def time(self): + '''Time a block of code or function, and observe the duration in seconds. + + Can be used as a function decorator or context manager. + ''' + class Timer(object): + def __init__(self, histogram): + self._histogram = histogram + def __enter__(self): + self._start = time.time() + def __exit__(self, typ, value, traceback): + # Time can go backwards. + self._histogram.observe(max(time.time() - self._start, 0)) + def __call__(self, f): + @wraps(f) + def wrapped(*args, **kwargs): + with self: + return f(*args, **kwargs) + return wrapped + return Timer(self) + + def _samples(self): + with self._lock: + samples = [] + acc = 0 + for i, bound in enumerate(self._upper_bounds): + acc += self._buckets[i] + samples.append(('_bucket', {'le': _floatToGoString(bound)}, acc)) + samples.append(('_count', {}, acc)) + samples.append(('_sum', {}, self._sum)) + return tuple(samples) CONTENT_TYPE_LATEST = 'text/plain; version=0.0.4; charset=utf-8' @@ -320,7 +391,7 @@ def generate_latest(registry=REGISTRY): for k, v in labels.items()])) else: labelstr = '' - output.append('{0}{1} {2}\n'.format(name, labelstr, value)) + output.append('{0}{1} {2}\n'.format(name, labelstr, _floatToGoString(value))) return ''.join(output).encode('utf-8') @@ -353,6 +424,9 @@ def write_to_textfile(path, registry): s = Summary('ss', 'A summary', ['a', 'b']) s.labels('c', 'd').observe(17) + h = Histogram('hh', 'A histogram') + h.observe(.6) + from BaseHTTPServer import HTTPServer server_address = ('', 8000) httpd = HTTPServer(server_address, MetricsHandler) diff --git a/tests/test_client.py b/tests/test_client.py index 837258af..b3d42782 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import unittest -from prometheus_client import Gauge, Counter, Summary +from prometheus_client import Gauge, Counter, Summary, Histogram from prometheus_client import CollectorRegistry, generate_latest class TestCounter(unittest.TestCase): @@ -104,6 +104,72 @@ def test_block_decorator(self): pass self.assertEqual(1, self.registry.get_sample_value('s_count')) +class TestHistogram(unittest.TestCase): + def setUp(self): + self.registry = CollectorRegistry() + self.histogram = Histogram('h', 'help', registry=self.registry) + + def test_histogram(self): + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'})) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '2.5'})) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '5.0'})) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + self.assertEqual(0, self.registry.get_sample_value('h_count')) + self.assertEqual(0, self.registry.get_sample_value('h_sum')) + + self.histogram.observe(2) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'})) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '2.5'})) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '5.0'})) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + self.assertEqual(1, self.registry.get_sample_value('h_count')) + self.assertEqual(2, self.registry.get_sample_value('h_sum')) + + self.histogram.observe(2.5) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '2.5'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '5.0'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + self.assertEqual(2, self.registry.get_sample_value('h_count')) + self.assertEqual(4.5, self.registry.get_sample_value('h_sum')) + + self.histogram.observe(float("inf")) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '1.0'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '2.5'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '5.0'})) + self.assertEqual(3, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + self.assertEqual(3, self.registry.get_sample_value('h_count')) + self.assertEqual(float("inf"), self.registry.get_sample_value('h_sum')) + + def test_setting_buckets(self): + h = Histogram('h', 'help', registry=None, buckets=[0, 1, 2]) + self.assertEqual([0.0, 1.0, 2.0, float("inf")], h._upper_bounds) + + h = Histogram('h', 'help', registry=None, buckets=[0, 1, 2, float("inf")]) + self.assertEqual([0.0, 1.0, 2.0, float("inf")], h._upper_bounds) + + self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, buckets=[]) + self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, buckets=[float("inf")]) + self.assertRaises(ValueError, Histogram, 'h', 'help', registry=None, buckets=[3, 1]) + + def test_function_decorator(self): + self.assertEqual(0, self.registry.get_sample_value('h_count')) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + @self.histogram.time() + def f(): + pass + f() + self.assertEqual(1, self.registry.get_sample_value('h_count')) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + + def test_block_decorator(self): + self.assertEqual(0, self.registry.get_sample_value('h_count')) + self.assertEqual(0, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + with self.histogram.time(): + pass + self.assertEqual(1, self.registry.get_sample_value('h_count')) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + class TestMetricWrapper(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry()