diff --git a/docs/content/instrumenting/histogram.md b/docs/content/instrumenting/histogram.md index fa0ffe1a..aa62b4b7 100644 --- a/docs/content/instrumenting/histogram.md +++ b/docs/content/instrumenting/histogram.md @@ -73,6 +73,17 @@ only rendered in OpenMetrics format. See [Exemplars](../exemplars/) for details. h.observe(0.43, exemplar={'trace_id': 'abc123'}) ``` +### `observe_batch(amounts)` + +Record a finite iterable of observations, such as a list, tuple, or generator. + +```python +h.observe_batch([0.12, 0.45, 0.08]) +``` + +New exemplars are not supported; existing exemplars are preserved. Floating-point +rounding may make the sum differ from repeated `observe()` calls. + ### `time()` Observe the duration in seconds of a block of code or function and add it to the diff --git a/docs/content/instrumenting/summary.md b/docs/content/instrumenting/summary.md index 714dfd2f..c5243add 100644 --- a/docs/content/instrumenting/summary.md +++ b/docs/content/instrumenting/summary.md @@ -55,6 +55,16 @@ s.observe(0.43) # observe 430ms s.observe(1024) # observe 1024 bytes ``` +### `observe_batch(amounts)` + +Record a finite iterable of observations, such as a list, tuple, or generator. + +```python +s.observe_batch([0.12, 0.45, 0.08]) +``` + +Floating-point rounding may make the sum differ from repeated `observe()` calls. + ### `time()` Observe the duration in seconds of a block of code or function and add it to the diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index dfa158b9..7fe8f9c9 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -1,3 +1,5 @@ +from bisect import bisect_left +import math import os from threading import Lock import time @@ -564,6 +566,19 @@ def observe(self, amount: float) -> None: self._count.inc(1) self._sum.inc(amount) + def observe_batch(self, amounts: Iterable[float]) -> None: + """Observe a finite iterable of amounts. + + The same restrictions on negative amounts as observe() apply. + """ + self._raise_if_not_observable() + amounts = list(amounts) + if not amounts: + return + total = sum(amounts) + self._sum.inc(total) + self._count.inc(len(amounts)) + def time(self) -> Timer: """Time a block of code or function, and observe the duration in seconds. @@ -691,6 +706,26 @@ def observe(self, amount: float, exemplar: Optional[Dict[str, str]] = None) -> N self._buckets[i].set_exemplar(Exemplar(exemplar, amount, time.time())) break + def observe_batch(self, amounts: Iterable[float]) -> None: + """Observe a finite iterable of amounts. + + The same restrictions on negative amounts as observe() apply. + Exemplars are not supported; existing exemplars are preserved. + """ + self._raise_if_not_observable() + amounts = list(amounts) + if not amounts: + return + total = sum(amounts) + counts = [0] * len(self._upper_bounds) + for amount in amounts: + if not math.isnan(amount): + counts[bisect_left(self._upper_bounds, amount)] += 1 + self._sum.inc(total) + for bucket, bucket_count in zip(self._buckets, counts): + if bucket_count: + bucket.inc(bucket_count) + def time(self) -> Timer: """Time a block of code or function, and observe the duration in seconds. diff --git a/tests/test_core.py b/tests/test_core.py index a502cfdb..c65bcfb7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,4 +1,6 @@ from concurrent.futures import ThreadPoolExecutor +from decimal import Decimal +import math import os from threading import Barrier, Event import time @@ -311,6 +313,67 @@ 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_observe_batch(self): + self.summary.observe(1) + self.summary.observe_batch([-2, 0, 0.5, 2]) + self.summary.observe_batch((3, 4)) + self.assertEqual(7, self.registry.get_sample_value('s_count')) + self.assertEqual(8.5, self.registry.get_sample_value('s_sum')) + + def test_observe_batch_empty(self): + self.summary.observe(1) + self.summary.observe_batch([]) + self.summary.observe_batch(()) + self.assertEqual(1, self.registry.get_sample_value('s_count')) + self.assertEqual(1, self.registry.get_sample_value('s_sum')) + + def test_observe_batch_not_observable(self): + assert_not_observable(self.summary_with_labels.observe_batch, [1]) + assert_not_observable(self.summary_with_labels.observe_batch, []) + + def test_observe_batch_labels(self): + self.summary_with_labels.labels('a').observe_batch([1, 2]) + self.summary_with_labels.labels('b').observe_batch([4]) + self.assertEqual(2, self.registry.get_sample_value('s_with_labels_count', {'label1': 'a'})) + self.assertEqual(3, self.registry.get_sample_value('s_with_labels_sum', {'label1': 'a'})) + self.assertEqual(1, self.registry.get_sample_value('s_with_labels_count', {'label1': 'b'})) + self.assertEqual(4, self.registry.get_sample_value('s_with_labels_sum', {'label1': 'b'})) + + def test_observe_batch_nonfinite(self): + self.summary.observe_batch([float('inf')]) + self.assertEqual(float('inf'), self.registry.get_sample_value('s_sum')) + self.summary.observe_batch([float('-inf')]) + self.assertTrue(math.isnan(self.registry.get_sample_value('s_sum'))) + self.summary.observe_batch([float('nan')]) + self.assertEqual(3, self.registry.get_sample_value('s_count')) + self.assertTrue(math.isnan(self.registry.get_sample_value('s_sum'))) + + def test_observe_batch_invalid_amounts(self): + self.summary.observe(1) + self.assertRaises(TypeError, self.summary.observe_batch, [2, 'invalid']) + self.assertEqual(1, self.registry.get_sample_value('s_count')) + self.assertEqual(1, self.registry.get_sample_value('s_sum')) + self.assertRaises(TypeError, self.summary.observe_batch, [Decimal('1.5')]) + self.assertEqual(1, self.registry.get_sample_value('s_count')) + self.assertEqual(1, self.registry.get_sample_value('s_sum')) + + def test_observe_batch_iterators(self): + self.summary.observe_batch(iter(())) + self.summary.observe_batch(iter([1, 2])) + self.summary.observe_batch(x for x in [3, 4]) + self.assertEqual(4, self.registry.get_sample_value('s_count')) + self.assertEqual(10, self.registry.get_sample_value('s_sum')) + + def test_observe_batch_iteration_failure(self): + def amounts(): + yield 2 + raise RuntimeError('iteration failed') + + self.summary.observe(1) + self.assertRaises(RuntimeError, self.summary.observe_batch, amounts()) + self.assertEqual(1, self.registry.get_sample_value('s_count')) + self.assertEqual(1, self.registry.get_sample_value('s_sum')) + def test_function_decorator(self): self.assertEqual(0, self.registry.get_sample_value('s_count')) @@ -449,6 +512,93 @@ def test_histogram_not_observable(self): """.observe() must fail if the Summary is not observable.""" assert_not_observable(self.labels.observe, 1) + def test_observe_batch(self): + self.histogram.observe(0.5) + self.histogram.observe_batch([-1, 0, 1, 2.5]) + self.histogram.observe_batch((3, 20)) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '0.005'})) + self.assertEqual(3, self.registry.get_sample_value('h_bucket', {'le': '0.5'})) + self.assertEqual(4, self.registry.get_sample_value('h_bucket', {'le': '1.0'})) + self.assertEqual(5, self.registry.get_sample_value('h_bucket', {'le': '2.5'})) + self.assertEqual(6, self.registry.get_sample_value('h_bucket', {'le': '5.0'})) + self.assertEqual(7, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + self.assertEqual(7, self.registry.get_sample_value('h_count')) + self.assertEqual(26, self.registry.get_sample_value('h_sum')) + + def test_observe_batch_empty(self): + self.histogram.observe(1) + self.histogram.observe_batch([]) + self.histogram.observe_batch(()) + self.assertEqual(1, self.registry.get_sample_value('h_count')) + self.assertEqual(1, self.registry.get_sample_value('h_sum')) + + def test_observe_batch_not_observable(self): + assert_not_observable(self.labels.observe_batch, [1]) + assert_not_observable(self.labels.observe_batch, []) + + def test_observe_batch_labels(self): + self.labels.labels('a').observe_batch([1, 2]) + self.labels.labels('b').observe_batch([4]) + self.assertEqual(1, self.registry.get_sample_value('hl_bucket', {'l': 'a', 'le': '1.0'})) + self.assertEqual(2, self.registry.get_sample_value('hl_count', {'l': 'a'})) + self.assertEqual(3, self.registry.get_sample_value('hl_sum', {'l': 'a'})) + self.assertEqual(1, self.registry.get_sample_value('hl_count', {'l': 'b'})) + self.assertEqual(4, self.registry.get_sample_value('hl_sum', {'l': 'b'})) + + def test_observe_batch_custom_buckets(self): + h = Histogram('custom', 'help', registry=self.registry, buckets=[-2, 0, 1, 1, 2]) + h.observe_batch([-3, -2, 0, 1, 2, 3]) + self.assertEqual(2, self.registry.get_sample_value('custom_bucket', {'le': '-2.0'})) + self.assertEqual(3, self.registry.get_sample_value('custom_bucket', {'le': '0.0'})) + self.assertEqual(4, self.registry.get_sample_value('custom_bucket', {'le': '1.0'})) + self.assertEqual(5, self.registry.get_sample_value('custom_bucket', {'le': '2.0'})) + self.assertEqual(6, self.registry.get_sample_value('custom_count')) + + def test_observe_batch_nonfinite(self): + self.histogram.observe_batch([float('nan')]) + self.assertEqual(0, self.registry.get_sample_value('h_count')) + self.assertTrue(math.isnan(self.registry.get_sample_value('h_sum'))) + self.histogram.observe_batch([float('-inf'), float('inf')]) + self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '0.005'})) + self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + self.assertEqual(2, self.registry.get_sample_value('h_count')) + self.assertTrue(math.isnan(self.registry.get_sample_value('h_sum'))) + + def test_observe_batch_invalid_amounts(self): + self.histogram.observe(1) + self.assertRaises(TypeError, self.histogram.observe_batch, [2, 'invalid']) + self.assertEqual(1, self.registry.get_sample_value('h_count')) + self.assertEqual(1, self.registry.get_sample_value('h_sum')) + self.assertRaises(TypeError, self.histogram.observe_batch, [Decimal('1.5')]) + self.assertEqual(1, self.registry.get_sample_value('h_count')) + self.assertEqual(1, self.registry.get_sample_value('h_sum')) + + def test_observe_batch_iterators(self): + self.histogram.observe_batch(iter(())) + self.histogram.observe_batch(iter([1, 2])) + self.histogram.observe_batch(x for x in [3, 4]) + self.assertEqual(1, 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(4, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) + self.assertEqual(4, self.registry.get_sample_value('h_count')) + self.assertEqual(10, self.registry.get_sample_value('h_sum')) + + def test_observe_batch_iteration_failure(self): + def amounts(): + yield 2 + raise RuntimeError('iteration failed') + + self.histogram.observe(1) + self.assertRaises(RuntimeError, self.histogram.observe_batch, amounts()) + self.assertEqual(1, self.registry.get_sample_value('h_count')) + self.assertEqual(1, self.registry.get_sample_value('h_sum')) + + def test_observe_batch_preserves_exemplars(self): + self.histogram.observe(0.25, exemplar={'trace_id': 'abc'}) + before = [s.exemplar for s in self.histogram.collect()[0].samples] + self.histogram.observe_batch([0.125, 0.25, 1]) + self.assertEqual(before, [s.exemplar for s in self.histogram.collect()[0].samples]) + 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) diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index 6451060c..afea2aaf 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -104,6 +104,29 @@ def test_histogram_adds(self): self.assertEqual(3, self.registry.get_sample_value('h_sum')) self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '5.0'})) + def test_summary_observe_batch_adds(self): + s1 = Summary('s', 'help', registry=None) + values.ValueClass = MultiProcessValue(lambda: 456) + s2 = Summary('s', 'help', registry=None) + s1.observe_batch([0.25, 1]) + s2.observe_batch([2, 3]) + s1.observe(0.5) + self.assertEqual(5, self.registry.get_sample_value('s_count')) + self.assertEqual(6.75, self.registry.get_sample_value('s_sum')) + + def test_histogram_observe_batch_adds(self): + h1 = Histogram('h', 'help', ['route'], registry=None).labels('/test') + values.ValueClass = MultiProcessValue(lambda: 456) + h2 = Histogram('h', 'help', ['route'], registry=None).labels('/test') + h1.observe_batch([0.25, 1]) + h2.observe_batch([2, 3]) + h1.observe(0.5) + self.assertEqual(5, self.registry.get_sample_value('h_count', {'route': '/test'})) + self.assertEqual(6.75, self.registry.get_sample_value('h_sum', {'route': '/test'})) + self.assertEqual(3, self.registry.get_sample_value('h_bucket', {'route': '/test', 'le': '1.0'})) + self.assertEqual(4, self.registry.get_sample_value('h_bucket', {'route': '/test', 'le': '2.5'})) + self.assertEqual(5, self.registry.get_sample_value('h_bucket', {'route': '/test', 'le': '+Inf'})) + def test_gauge_all(self): g1 = Gauge('g', 'help', registry=None) values.ValueClass = MultiProcessValue(lambda: 456)