diff --git a/.circleci/config.yml b/.circleci/config.yml index 9d95e6ee..b555a052 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -75,6 +75,7 @@ workflows: - "3.7" - "3.8" - "3.9" + - "3.10" - test_nooptionals: matrix: parameters: diff --git a/README.md b/README.md index 13b81883..efd74d93 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,27 @@ c.labels('get', '/') c.labels('post', '/submit') ``` +### Exemplars + +Exemplars can be added to counter and histogram metrics. Exemplars can be +specified by passing a dict of label value pairs to be exposed as the exemplar. +For example with a counter: + +```python +from prometheus_client import Counter +c = Counter('my_requests_total', 'HTTP Failures', ['method', 'endpoint']) +c.labels('get', '/').inc(exemplar={'trace_id': 'abc123'}) +c.labels('post', '/submit').inc(1.0, {'trace_id': 'def456'}) +``` + +And with a histogram: + +```python +from prometheus_client import Histogram +h = Histogram('request_latency_seconds', 'Description of histogram') +h.observe(4.7, {'trace_id': 'abc123'}) +``` + ### Process Collector The Python client automatically exports metrics about process CPU usage, RAM, @@ -510,6 +531,7 @@ This comes with a number of limitations: - Info and Enum metrics do not work - The pushgateway cannot be used - Gauges cannot use the `pid` label +- Exemplars are not supported There's several steps to getting this working: diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 18233e24..e3741448 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -34,7 +34,6 @@ CONTENT_TYPE_LATEST = str('text/plain; version=0.0.4; charset=utf-8') """Content type of the latest text format""" PYTHON27_OR_OLDER = sys.version_info < (3, ) -PYTHON26_OR_OLDER = sys.version_info < (2, 7) PYTHON376_OR_NEWER = sys.version_info > (3, 7, 5) @@ -115,8 +114,8 @@ def prometheus_app(environ, start_response): params = parse_qs(environ.get('QUERY_STRING', '')) if environ['PATH_INFO'] == '/favicon.ico': # Serve empty response for browsers - status = '200 OK' - header = ('', '') + status = str('200 OK') + header = (str(''), str('')) output = b'' else: # Bake output @@ -445,7 +444,7 @@ def _use_gateway(method, gateway, job, registry, grouping_key, timeout, handler) gateway_url = urlparse(gateway) # See https://bugs.python.org/issue27657 for details on urlparse in py>=3.7.6. if not gateway_url.scheme or ( - (PYTHON376_OR_NEWER or PYTHON26_OR_OLDER) + PYTHON376_OR_NEWER and gateway_url.scheme not in ['http', 'https'] ): gateway = 'http://{0}'.format(gateway) @@ -481,7 +480,16 @@ def _escape_grouping_key(k, v): def instance_ip_grouping_key(): """Grouping key with instance set to the IP Address of this host.""" with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: - s.connect(('localhost', 0)) + if sys.platform == 'darwin': + # This check is done this way only on MacOS devices + # it is done this way because the localhost method does + # not work. + # This method was adapted from this StackOverflow answer: + # https://stackoverflow.com/a/28950776 + s.connect(('10.255.255.255', 1)) + else: + s.connect(('localhost', 0)) + return {'instance': s.getsockname()[0]} diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 28359930..399830ae 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -10,6 +10,7 @@ RESERVED_METRIC_LABEL_NAME_RE, ) from .registry import REGISTRY +from .samples import Exemplar from .utils import floatToGoString, INF if sys.version_info > (3,): @@ -36,18 +37,32 @@ def _build_full_name(metric_type, name, namespace, subsystem, unit): return full_name +def _validate_labelname(l): + if not METRIC_LABEL_NAME_RE.match(l): + raise ValueError('Invalid label metric name: ' + l) + if RESERVED_METRIC_LABEL_NAME_RE.match(l): + raise ValueError('Reserved label metric name: ' + l) + + def _validate_labelnames(cls, labelnames): labelnames = tuple(labelnames) for l in labelnames: - if not METRIC_LABEL_NAME_RE.match(l): - raise ValueError('Invalid label metric name: ' + l) - if RESERVED_METRIC_LABEL_NAME_RE.match(l): - raise ValueError('Reserved label metric name: ' + l) + _validate_labelname(l) if l in cls._reserved_labelnames: raise ValueError('Reserved label metric name: ' + l) return labelnames +def _validate_exemplar(exemplar): + runes = 0 + for k, v in exemplar.items(): + _validate_labelname(k) + runes += len(k) + runes += len(v) + if runes > 128: + raise ValueError('Exemplar labels have %d UTF-8 characters, exceeding the limit of 128') + + class MetricWrapperBase(object): _type = None _reserved_labelnames = () @@ -76,8 +91,8 @@ def describe(self): def collect(self): metric = self._get_metric() - for suffix, labels, value in self._samples(): - metric.add_sample(self._name + suffix, labels, value) + for suffix, labels, value, timestamp, exemplar in self._samples(): + metric.add_sample(self._name + suffix, labels, value, timestamp, exemplar) return [metric] def __str__(self): @@ -202,8 +217,8 @@ def _multi_samples(self): metrics = self._metrics.copy() for labels, metric in metrics.items(): series_labels = list(zip(self._labelnames, labels)) - for suffix, sample_labels, value in metric._samples(): - yield (suffix, dict(series_labels + list(sample_labels.items())), value) + for suffix, sample_labels, value, timestamp, exemplar in metric._samples(): + yield (suffix, dict(series_labels + list(sample_labels.items())), value, timestamp, exemplar) def _child_samples(self): # pragma: no cover raise NotImplementedError('_child_samples() must be implemented by %r' % self) @@ -256,11 +271,15 @@ def _metric_init(self): self._labelvalues) self._created = time.time() - def inc(self, amount=1): + def inc(self, amount=1, exemplar=None): """Increment counter by the given amount.""" + self._raise_if_not_observable() if amount < 0: raise ValueError('Counters can only be incremented by non-negative amounts.') self._value.inc(amount) + if exemplar: + _validate_exemplar(exemplar) + self._value.set_exemplar(Exemplar(exemplar, amount, time.time())) def count_exceptions(self, exception=Exception): """Count exceptions in a block of code or function. @@ -274,8 +293,8 @@ def count_exceptions(self, exception=Exception): def _child_samples(self): return ( - ('_total', {}, self._value.get()), - ('_created', {}, self._created), + ('_total', {}, self._value.get(), None, self._value.get_exemplar()), + ('_created', {}, self._created, None, None), ) @@ -353,14 +372,17 @@ def _metric_init(self): def inc(self, amount=1): """Increment gauge by the given amount.""" + self._raise_if_not_observable() self._value.inc(amount) def dec(self, amount=1): """Decrement gauge by the given amount.""" + self._raise_if_not_observable() self._value.inc(-amount) def set(self, value): """Set gauge to the given value.""" + self._raise_if_not_observable() self._value.set(float(value)) def set_to_current_time(self): @@ -392,13 +414,15 @@ def set_function(self, f): multiple threads. All other methods of the Gauge become NOOPs. """ + self._raise_if_not_observable() + def samples(self): - return (('', {}, float(f())),) + return (('', {}, float(f()), None, None),) self._child_samples = create_bound_method(samples, self) def _child_samples(self): - return (('', {}, self._value.get()),) + return (('', {}, self._value.get(), None, None),) class Summary(MetricWrapperBase): @@ -450,6 +474,7 @@ def observe(self, amount): https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations for details. """ + self._raise_if_not_observable() self._count.inc(1) self._sum.inc(amount) @@ -463,9 +488,10 @@ def time(self): def _child_samples(self): return ( - ('_count', {}, self._count.get()), - ('_sum', {}, self._sum.get()), - ('_created', {}, self._created)) + ('_count', {}, self._count.get(), None, None), + ('_sum', {}, self._sum.get(), None, None), + ('_created', {}, self._created, None, None), + ) class Histogram(MetricWrapperBase): @@ -557,7 +583,7 @@ def _metric_init(self): self._labelvalues + (floatToGoString(b),)) ) - def observe(self, amount): + def observe(self, amount, exemplar=None): """Observe the given amount. The amount is usually positive or zero. Negative values are @@ -567,10 +593,14 @@ def observe(self, amount): https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations for details. """ + self._raise_if_not_observable() self._sum.inc(amount) for i, bound in enumerate(self._upper_bounds): if amount <= bound: self._buckets[i].inc(1) + if exemplar: + _validate_exemplar(exemplar) + self._buckets[i].set_exemplar(Exemplar(exemplar, amount, time.time())) break def time(self): @@ -585,11 +615,11 @@ def _child_samples(self): acc = 0 for i, bound in enumerate(self._upper_bounds): acc += self._buckets[i].get() - samples.append(('_bucket', {'le': floatToGoString(bound)}, acc)) - samples.append(('_count', {}, acc)) + samples.append(('_bucket', {'le': floatToGoString(bound)}, acc, None, self._buckets[i].get_exemplar())) + samples.append(('_count', {}, acc, None, None)) if self._upper_bounds[0] >= 0: - samples.append(('_sum', {}, self._sum.get())) - samples.append(('_created', {}, self._created)) + samples.append(('_sum', {}, self._sum.get(), None, None)) + samples.append(('_created', {}, self._created, None, None)) return tuple(samples) @@ -626,7 +656,7 @@ def info(self, val): def _child_samples(self): with self._lock: - return (('_info', self._value, 1.0,),) + return (('_info', self._value, 1.0, None, None),) class Enum(MetricWrapperBase): @@ -684,7 +714,7 @@ def state(self, state): def _child_samples(self): with self._lock: return [ - ('', {self._name: s}, 1 if i == self._value else 0,) + ('', {self._name: s}, 1 if i == self._value else 0, None, None) for i, s in enumerate(self._states) ] diff --git a/prometheus_client/metrics_core.py b/prometheus_client/metrics_core.py index 4fd0fcd7..4ab2af16 100644 --- a/prometheus_client/metrics_core.py +++ b/prometheus_client/metrics_core.py @@ -58,6 +58,15 @@ def __repr__(self): self.samples, ) + def _restricted_metric(self, names): + """Build a snapshot of a metric with samples restricted to a given set of names.""" + samples = [s for s in self.samples if s[0] in names] + if samples: + m = Metric(self.name, self.documentation, self.type) + m.samples = samples + return m + return None + class UnknownMetricFamily(Metric): """A single unknown metric and its samples. diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 6d5925ed..56d00b5f 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -8,6 +8,14 @@ """Content type of the latest OpenMetrics text format""" +def _is_valid_exemplar_metric(metric, sample): + if metric.type == 'counter' and sample.name.endswith('_total'): + return True + if metric.type in ('histogram', 'gaugehistogram') and sample.name.endswith('_bucket'): + return True + return False + + def generate_latest(registry): '''Returns the metrics from the registry in latest text format as a string.''' output = [] @@ -28,8 +36,8 @@ def generate_latest(registry): else: labelstr = '' if s.exemplar: - if metric.type not in ('histogram', 'gaugehistogram') or not s.name.endswith('_bucket'): - raise ValueError("Metric {0} has exemplars, but is not a histogram bucket".format(metric.name)) + if not _is_valid_exemplar_metric(metric, s): + raise ValueError("Metric {0} has exemplars, but is not a histogram bucket or counter".format(metric.name)) labels = '{{{0}}}'.format(','.join( ['{0}="{1}"'.format( k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')) diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index fff1f98c..67cde45a 100644 --- a/prometheus_client/registry.py +++ b/prometheus_client/registry.py @@ -94,28 +94,7 @@ def restricted_registry(self, names): Experimental.""" names = set(names) - collectors = set() - metrics = [] - with self._lock: - if 'target_info' in names and self._target_info: - metrics.append(self._target_info_metric()) - names.remove('target_info') - for name in names: - if name in self._names_to_collectors: - collectors.add(self._names_to_collectors[name]) - for collector in collectors: - for metric in collector.collect(): - samples = [s for s in metric.samples if s[0] in names] - if samples: - m = Metric(metric.name, metric.documentation, metric.type) - m.samples = samples - metrics.append(m) - - class RestrictedRegistry(object): - def collect(self): - return metrics - - return RestrictedRegistry() + return RestrictedRegistry(names, self) def set_target_info(self, labels): with self._lock: @@ -150,4 +129,27 @@ def get_sample_value(self, name, labels=None): return None +class RestrictedRegistry(object): + def __init__(self, names, registry): + self._name_set = set(names) + self._registry = registry + + def collect(self): + collectors = set() + target_info_metric = None + with self._registry._lock: + if 'target_info' in self._name_set and self._registry._target_info: + target_info_metric = self._registry._target_info_metric() + for name in self._name_set: + if name != 'target_info' and name in self._registry._names_to_collectors: + collectors.add(self._registry._names_to_collectors[name]) + if target_info_metric: + yield target_info_metric + for collector in collectors: + for metric in collector.collect(): + m = metric._restricted_metric(self._name_set) + if m: + yield m + + REGISTRY = CollectorRegistry(auto_describe=True) diff --git a/prometheus_client/values.py b/prometheus_client/values.py index bccb38e9..842837c5 100644 --- a/prometheus_client/values.py +++ b/prometheus_client/values.py @@ -14,6 +14,7 @@ class MutexValue(object): def __init__(self, typ, metric_name, name, labelnames, labelvalues, **kwargs): self._value = 0.0 + self._exemplar = None self._lock = Lock() def inc(self, amount): @@ -24,10 +25,18 @@ def set(self, value): with self._lock: self._value = value + def set_exemplar(self, exemplar): + with self._lock: + self._exemplar = exemplar + def get(self): with self._lock: return self._value + def get_exemplar(self): + with self._lock: + return self._exemplar + def MultiProcessValue(process_identifier=os.getpid): """Returns a MmapedValue class based on a process_identifier function. @@ -100,11 +109,19 @@ def set(self, value): self._value = value self._file.write_value(self._key, self._value) + def set_exemplar(self, exemplar): + # TODO: Implement exemplars for multiprocess mode. + return + def get(self): with lock: self.__check_for_pid_change() return self._value + def get_exemplar(self): + # TODO: Implement exemplars for multiprocess mode. + return None + return MmapedValue diff --git a/setup.py b/setup.py index 0469f935..95adc296 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,14 @@ from os import path -import sys from setuptools import setup -if sys.version_info >= (2, 7): - with open(path.join(path.abspath(path.dirname(__file__)), 'README.md')) as f: - long_description = f.read() -else: # Assuming we don't run setup in order to publish under python 2.6 - long_description = "NA" +with open(path.join(path.abspath(path.dirname(__file__)), 'README.md')) as f: + long_description = f.read() setup( name="prometheus_client", - version="0.11.0", + version="0.12.0", author="Brian Brazil", author_email="brian.brazil@robustperception.io", description="Python client for the Prometheus monitoring system.", @@ -47,6 +43,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: System :: Monitoring", diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index c3739623..87a382c9 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -import sys import time +import unittest from prometheus_client import ( CollectorRegistry, Counter, Enum, Gauge, Histogram, Info, Metric, Summary, @@ -11,12 +11,6 @@ ) from prometheus_client.openmetrics.exposition import generate_latest -if sys.version_info < (2, 7): - # We need the skip decorators from unittest2 on Python 2.6. - import unittest2 as unittest -else: - import unittest - class TestGenerateText(unittest.TestCase): def setUp(self): @@ -70,7 +64,6 @@ def test_summary(self): # EOF """, generate_latest(self.registry)) - @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_histogram(self): s = Histogram('hh', 'A histogram', registry=self.registry) s.observe(0.05) @@ -114,29 +107,36 @@ def test_histogram_negative_buckets(self): """, generate_latest(self.registry)) def test_histogram_exemplar(self): - class MyCollector(object): - def collect(self): - metric = Metric("hh", "help", 'histogram') - # This is not sane, but it covers all the cases. - metric.add_sample("hh_bucket", {"le": "1"}, 0, None, Exemplar({'a': 'b'}, 0.5)) - metric.add_sample("hh_bucket", {"le": "2"}, 0, None, Exemplar({'le': '7'}, 0.5, 12)) - metric.add_sample("hh_bucket", {"le": "3"}, 0, 123, Exemplar({'a': 'b'}, 2.5, 12)) - metric.add_sample("hh_bucket", {"le": "4"}, 0, None, Exemplar({'a': '\n"\\'}, 3.5)) - metric.add_sample("hh_bucket", {"le": "+Inf"}, 0, None, None) - yield metric - - self.registry.register(MyCollector()) - self.assertEqual(b"""# HELP hh help + s = Histogram('hh', 'A histogram', buckets=[1, 2, 3, 4], registry=self.registry) + s.observe(0.5, {'a': 'b'}) + s.observe(1.5, {'le': '7'}) + s.observe(2.5, {'a': 'b'}) + s.observe(3.5, {'a': '\n"\\'}) + print(generate_latest(self.registry)) + self.assertEqual(b"""# HELP hh A histogram # TYPE hh histogram -hh_bucket{le="1"} 0.0 # {a="b"} 0.5 -hh_bucket{le="2"} 0.0 # {le="7"} 0.5 12 -hh_bucket{le="3"} 0.0 123 # {a="b"} 2.5 12 -hh_bucket{le="4"} 0.0 # {a="\\n\\"\\\\"} 3.5 -hh_bucket{le="+Inf"} 0.0 +hh_bucket{le="1.0"} 1.0 # {a="b"} 0.5 123.456 +hh_bucket{le="2.0"} 2.0 # {le="7"} 1.5 123.456 +hh_bucket{le="3.0"} 3.0 # {a="b"} 2.5 123.456 +hh_bucket{le="4.0"} 4.0 # {a="\\n\\"\\\\"} 3.5 123.456 +hh_bucket{le="+Inf"} 4.0 +hh_count 4.0 +hh_sum 8.0 +hh_created 123.456 # EOF """, generate_latest(self.registry)) - def test_nonhistogram_exemplar(self): + def test_counter_exemplar(self): + c = Counter('cc', 'A counter', registry=self.registry) + c.inc(exemplar={'a': 'b'}) + self.assertEqual(b"""# HELP cc A counter +# TYPE cc counter +cc_total 1.0 # {a="b"} 1.0 123.456 +cc_created 123.456 +# EOF +""", generate_latest(self.registry)) + + def test_untyped_exemplar(self): class MyCollector(object): def collect(self): metric = Metric("hh", "help", 'untyped') @@ -148,7 +148,7 @@ def collect(self): with self.assertRaises(ValueError): generate_latest(self.registry) - def test_nonhistogram_bucket_exemplar(self): + def test_histogram_non_bucket_exemplar(self): class MyCollector(object): def collect(self): metric = Metric("hh", "help", 'histogram') @@ -160,6 +160,18 @@ def collect(self): with self.assertRaises(ValueError): generate_latest(self.registry) + def test_counter_non_total_exemplar(self): + class MyCollector(object): + def collect(self): + metric = Metric("cc", "A counter", 'counter') + metric.add_sample("cc_total", {}, 1, None, None) + metric.add_sample("cc_created", {}, 123.456, None, Exemplar({'a': 'b'}, 1.0, 123.456)) + yield metric + + self.registry.register(MyCollector()) + with self.assertRaises(ValueError): + generate_latest(self.registry) + def test_gaugehistogram(self): self.custom_collector( GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))], gsum_value=7)) diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py index 104547eb..8fd2319d 100644 --- a/tests/openmetrics/test_parser.py +++ b/tests/openmetrics/test_parser.py @@ -2,6 +2,7 @@ import math import sys +import unittest from prometheus_client.core import ( CollectorRegistry, CounterMetricFamily, Exemplar, @@ -12,12 +13,6 @@ from prometheus_client.openmetrics.exposition import generate_latest from prometheus_client.openmetrics.parser import text_string_to_metric_families -if sys.version_info < (2, 7): - # We need the skip decorators from unittest2 on Python 2.6. - import unittest2 as unittest -else: - import unittest - class TestParse(unittest.TestCase): @@ -549,7 +544,6 @@ def test_fallback_to_state_machine_label_parsing(self): mock2.assert_not_called() mock3.assert_called_once_with('1') - @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_roundtrip(self): text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations. # TYPE go_gc_duration_seconds summary diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 0f14bfc3..139c8717 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -1,16 +1,10 @@ from __future__ import absolute_import, unicode_literals -import sys -from unittest import TestCase +from unittest import skipUnless, TestCase from prometheus_client import CollectorRegistry, Counter from prometheus_client.exposition import CONTENT_TYPE_LATEST -if sys.version_info < (2, 7): - from unittest2 import skipUnless -else: - from unittest import skipUnless - try: # Python >3.5 only import asyncio diff --git a/tests/test_core.py b/tests/test_core.py index 55c88b76..733e9dd5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,8 @@ +# coding=utf-8 from __future__ import unicode_literals from concurrent.futures import ThreadPoolExecutor +import sys import time import pytest @@ -19,6 +21,21 @@ import unittest +def assert_not_observable(fn, *args, **kwargs): + """ + Assert that a function call falls with a ValueError exception containing + 'missing label values' + """ + + try: + fn(*args, **kwargs) + except ValueError as e: + assert 'missing label values' in str(e) + return + + assert False, "Did not raise a 'missing label values' exception" + + class TestCounter(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() @@ -30,13 +47,12 @@ def test_increment(self): self.assertEqual(1, self.registry.get_sample_value('c_total')) self.counter.inc(7) self.assertEqual(8, self.registry.get_sample_value('c_total')) - + def test_repr(self): self.assertEqual(repr(self.counter), "prometheus_client.metrics.Counter(c)") def test_negative_increment_raises(self): self.assertRaises(ValueError, self.counter.inc, -1) - def test_function_decorator(self): @self.counter.count_exceptions(ValueError) @@ -76,18 +92,43 @@ def test_block_decorator(self): def test_count_exceptions_not_observable(self): counter = Counter('counter', 'help', labelnames=('label',), registry=self.registry) + assert_not_observable(counter.count_exceptions) - try: - counter.count_exceptions() - except ValueError as e: - self.assertIn('missing label values', str(e)) + def test_inc_not_observable(self): + """.inc() must fail if the counter is not observable.""" + + counter = Counter('counter', 'help', labelnames=('label',), registry=self.registry) + assert_not_observable(counter.inc) + + def test_exemplar_invalid_label_name(self): + self.assertRaises(ValueError, self.counter.inc, exemplar={':o)': 'smile'}) + self.assertRaises(ValueError, self.counter.inc, exemplar={'1': 'number'}) + + def test_exemplar_unicode(self): + # 128 characters should not raise, even using characters larger than 1 byte. + self.counter.inc(exemplar={ + 'abcdefghijklmnopqrstuvwxyz': '26+16 characters', + 'x123456': '7+15 characters', + 'zyxwvutsrqponmlkjihgfedcba': '26+16 characters', + 'unicode': '7+15 chars 平', + }) + + def test_exemplar_too_long(self): + # 129 characters should fail. + self.assertRaises(ValueError, self.counter.inc, exemplar={ + 'abcdefghijklmnopqrstuvwxyz': '26+16 characters', + 'x1234567': '8+15 characters', + 'zyxwvutsrqponmlkjihgfedcba': '26+16 characters', + 'y123456': '7+15 characters', + }) class TestGauge(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() self.gauge = Gauge('g', 'help', registry=self.registry) - + self.gauge_with_label = Gauge('g2', 'help', labelnames=("label1",), registry=self.registry) + def test_repr(self): self.assertEqual(repr(self.gauge), "prometheus_client.metrics.Gauge(g)") @@ -100,6 +141,21 @@ def test_gauge(self): self.gauge.set(9) self.assertEqual(9, self.registry.get_sample_value('g')) + def test_inc_not_observable(self): + """.inc() must fail if the gauge is not observable.""" + + assert_not_observable(self.gauge_with_label.inc) + + def test_dec_not_observable(self): + """.dec() must fail if the gauge is not observable.""" + + assert_not_observable(self.gauge_with_label.dec) + + def test_set_not_observable(self): + """.set() must fail if the gauge is not observable.""" + + assert_not_observable(self.gauge_with_label.set, 1) + def test_inprogress_function_decorator(self): self.assertEqual(0, self.registry.get_sample_value('g')) @@ -127,6 +183,11 @@ def test_gauge_function(self): x['a'] = None self.assertEqual(1, self.registry.get_sample_value('g')) + def test_set_function_not_observable(self): + """.set_function() must fail if the gauge is not observable.""" + + assert_not_observable(self.gauge_with_label.set_function, lambda: 1) + def test_time_function_decorator(self): self.assertEqual(0, self.registry.get_sample_value('g')) @@ -167,25 +228,18 @@ def test_time_block_decorator(self): def test_track_in_progress_not_observable(self): g = Gauge('test', 'help', labelnames=('label',), registry=self.registry) - - try: - g.track_inprogress() - except ValueError as e: - self.assertIn('missing label values', str(e)) + assert_not_observable(g.track_inprogress) def test_timer_not_observable(self): g = Gauge('test', 'help', labelnames=('label',), registry=self.registry) - - try: - g.time() - except ValueError as e: - self.assertIn('missing label values', str(e)) + assert_not_observable(g.time) class TestSummary(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() self.summary = Summary('s', 'help', registry=self.registry) + self.summary_with_labels = Summary('s_with_labels', 'help', labelnames=("label1",), registry=self.registry) def test_repr(self): self.assertEqual(repr(self.summary), "prometheus_client.metrics.Summary(s)") @@ -197,6 +251,10 @@ def test_summary(self): self.assertEqual(1, self.registry.get_sample_value('s_count')) self.assertEqual(10, self.registry.get_sample_value('s_sum')) + def test_summary_not_observable(self): + """.observe() must fail if the Summary is not observable.""" + assert_not_observable(self.summary_with_labels.observe, 1) + def test_function_decorator(self): self.assertEqual(0, self.registry.get_sample_value('s_count')) @@ -267,10 +325,7 @@ def test_block_decorator(self): def test_timer_not_observable(self): s = Summary('test', 'help', labelnames=('label',), registry=self.registry) - try: - s.time() - except ValueError as e: - self.assertIn('missing label values', str(e)) + assert_not_observable(s.time) class TestHistogram(unittest.TestCase): @@ -315,6 +370,10 @@ def test_histogram(self): self.assertEqual(3, self.registry.get_sample_value('h_count')) self.assertEqual(float("inf"), self.registry.get_sample_value('h_sum')) + def test_histogram_not_observable(self): + """.observe() must fail if the Summary is not observable.""" + assert_not_observable(self.labels.observe, 1) + 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) @@ -380,6 +439,19 @@ def test_block_decorator(self): self.assertEqual(1, self.registry.get_sample_value('h_count')) self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + def test_exemplar_invalid_label_name(self): + self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={':o)': 'smile'}) + self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={'1': 'number'}) + + def test_exemplar_too_long(self): + # 129 characters in total should fail. + self.assertRaises(ValueError, self.histogram.observe, 1.0, exemplar={ + 'abcdefghijklmnopqrstuvwxyz': '26+16 characters', + 'x1234567': '8+15 characters', + 'zyxwvutsrqponmlkjihgfedcba': '26+16 characters', + 'y123456': '7+15 characters', + }) + class TestInfo(unittest.TestCase): def setUp(self): @@ -761,7 +833,19 @@ def test_restricted_registry(self): m = Metric('s', 'help', 'summary') m.samples = [Sample('s_sum', {}, 7)] - self.assertEqual([m], registry.restricted_registry(['s_sum']).collect()) + self.assertEqual([m], list(registry.restricted_registry(['s_sum']).collect())) + + def test_restricted_registry_adds_new_metrics(self): + registry = CollectorRegistry() + Counter('c_total', 'help', registry=registry) + + restricted_registry = registry.restricted_registry(['s_sum']) + + Summary('s', 'help', registry=registry).observe(7) + m = Metric('s', 'help', 'summary') + m.samples = [Sample('s_sum', {}, 7)] + + self.assertEqual([m], list(restricted_registry.collect())) def test_target_info_injected(self): registry = CollectorRegistry(target_info={'foo': 'bar'}) @@ -785,11 +869,38 @@ def test_target_info_restricted_registry(self): m = Metric('s', 'help', 'summary') m.samples = [Sample('s_sum', {}, 7)] - self.assertEqual([m], registry.restricted_registry(['s_sum']).collect()) + self.assertEqual([m], list(registry.restricted_registry(['s_sum']).collect())) + + m = Metric('target', 'Target metadata', 'info') + m.samples = [Sample('target_info', {'foo': 'bar'}, 1)] + self.assertEqual([m], list(registry.restricted_registry(['target_info']).collect())) + + @unittest.skipIf(sys.version_info < (3, 3), "Test requires Python 3.3+.") + def test_restricted_registry_does_not_call_extra(self): + from unittest.mock import MagicMock + registry = CollectorRegistry() + mock_collector = MagicMock() + mock_collector.describe.return_value = [Metric('foo', 'help', 'summary')] + registry.register(mock_collector) + Summary('s', 'help', registry=registry).observe(7) + + m = Metric('s', 'help', 'summary') + m.samples = [Sample('s_sum', {}, 7)] + self.assertEqual([m], list(registry.restricted_registry(['s_sum']).collect())) + mock_collector.collect.assert_not_called() + + def test_restricted_registry_does_not_yield_while_locked(self): + registry = CollectorRegistry(target_info={'foo': 'bar'}) + Summary('s', 'help', registry=registry).observe(7) + + m = Metric('s', 'help', 'summary') + m.samples = [Sample('s_sum', {}, 7)] + self.assertEqual([m], list(registry.restricted_registry(['s_sum']).collect())) m = Metric('target', 'Target metadata', 'info') m.samples = [Sample('target_info', {'foo': 'bar'}, 1)] - self.assertEqual([m], registry.restricted_registry(['target_info']).collect()) + for _ in registry.restricted_registry(['target_info', 's_sum']).collect(): + self.assertFalse(registry._lock.locked()) if __name__ == '__main__': diff --git a/tests/test_exposition.py b/tests/test_exposition.py index a6c79ef0..757f1705 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals -import sys import threading import time +import unittest import pytest @@ -17,12 +17,6 @@ passthrough_redirect_handler, ) -if sys.version_info < (2, 7): - # We need the skip decorators from unittest2 on Python 2.6. - import unittest2 as unittest -else: - import unittest - try: from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer except ImportError: @@ -98,7 +92,6 @@ def test_summary(self): ss_created{a="c",b="d"} 123.456 """, generate_latest(self.registry)) - @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_histogram(self): s = Histogram('hh', 'A histogram', registry=self.registry) s.observe(0.05) @@ -376,10 +369,6 @@ def my_redirect_handler(url, method, timeout, headers, data): # ensure the redirect took place at the expected redirect location. self.assertEqual(self.requests[1][0].path, "/" + self.redirect_flag) - @unittest.skipIf( - sys.platform == "darwin", - "instance_ip_grouping_key() does not work on macOS." - ) def test_instance_ip_grouping_key(self): self.assertTrue('' != instance_ip_grouping_key()['instance']) diff --git a/tests/test_gc_collector.py b/tests/test_gc_collector.py index c0cda88f..00714422 100644 --- a/tests/test_gc_collector.py +++ b/tests/test_gc_collector.py @@ -3,12 +3,7 @@ import gc import platform import sys - -if sys.version_info < (2, 7): - # We need the skip decorators from unittest2 on Python 2.6. - import unittest2 as unittest -else: - import unittest +import unittest from prometheus_client import CollectorRegistry, GCCollector diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index 96792a36..310f8c77 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -3,8 +3,8 @@ import glob import os import shutil -import sys import tempfile +import unittest import warnings from prometheus_client import mmap_dict, values @@ -18,12 +18,6 @@ get_value_class, MultiProcessValue, MutexValue, ) -if sys.version_info < (2, 7): - # We need the skip decorators from unittest2 on Python 2.6. - import unittest2 as unittest -else: - import unittest - class TestMultiProcessDeprecation(unittest.TestCase): def setUp(self): @@ -196,7 +190,6 @@ def files(): c3 = Counter('c3', 'c3', registry=None) self.assertEqual(files(), ['counter_0.db', 'counter_1.db']) - @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_collect(self): pid = 0 values.ValueClass = MultiProcessValue(lambda: pid) @@ -257,7 +250,6 @@ def add_label(key, value): self.assertEqual(metrics['h'].samples, expected_histogram) - @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_merge_no_accumulate(self): pid = 0 values.ValueClass = MultiProcessValue(lambda: pid) diff --git a/tests/test_parser.py b/tests/test_parser.py index 44a3fa45..780f0bc7 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import math -import sys +import unittest from prometheus_client.core import ( CollectorRegistry, CounterMetricFamily, GaugeMetricFamily, @@ -10,12 +10,6 @@ from prometheus_client.exposition import generate_latest from prometheus_client.parser import text_string_to_metric_families -if sys.version_info < (2, 7): - # We need the skip decorators from unittest2 on Python 2.6. - import unittest2 as unittest -else: - import unittest - class TestParse(unittest.TestCase): def assertEqualMetrics(self, first, second, msg=None): @@ -289,7 +283,6 @@ def test_timestamps(self): b.add_metric([], 88, timestamp=1234566) self.assertEqualMetrics([a, b], list(families)) - @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_roundtrip(self): text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations. # TYPE go_gc_duration_seconds summary diff --git a/tests/test_twisted.py b/tests/test_twisted.py index 110aded7..ea1796f4 100644 --- a/tests/test_twisted.py +++ b/tests/test_twisted.py @@ -1,14 +1,9 @@ from __future__ import absolute_import, unicode_literals -import sys +from unittest import skipUnless from prometheus_client import CollectorRegistry, Counter, generate_latest -if sys.version_info < (2, 7): - from unittest2 import skipUnless -else: - from unittest import skipUnless - try: from twisted.internet import reactor from twisted.trial.unittest import TestCase diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 79fea884..7a285a9c 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -22,17 +22,6 @@ def capture(self, status, header): self.captured_status = status self.captured_headers = header - def assertIn(self, item, iterable): - try: - super().assertIn(item, iterable) - except: # Python < 2.7 - self.assertTrue( - item in iterable, - msg="{item} not found in {iterable}".format( - item=item, iterable=iterable - ) - ) - def validate_metrics(self, metric_name, help_text, increments): """ WSGI app serves the metrics from the provided registry. diff --git a/tox.ini b/tox.ini index 8b2105c7..29845187 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = coverage-clean,py2.7,py3.4,py3.5,py3.6,py3.7,py3.8,py3.9,pypy2.7,pypy3.7,{py2.7,py3.9}-nooptionals,coverage-report,flake8,isort +envlist = coverage-clean,py2.7,py3.4,py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,pypy2.7,pypy3.7,{py2.7,py3.9}-nooptionals,coverage-report,flake8,isort [base]