Skip to content

Commit dddd69e

Browse files
initial commit
1 parent ded318a commit dddd69e

5 files changed

Lines changed: 229 additions & 0 deletions

File tree

‎docs/content/instrumenting/histogram.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ only rendered in OpenMetrics format. See [Exemplars](../exemplars/) for details.
7373
h.observe(0.43, exemplar={'trace_id': 'abc123'})
7474
```
7575

76+
### `observe_batch(amounts)`
77+
78+
Record a finite iterable of observations, such as a list, tuple, or generator.
79+
80+
```python
81+
h.observe_batch([0.12, 0.45, 0.08])
82+
```
83+
84+
New exemplars are not supported; existing exemplars are preserved. Floating-point
85+
rounding may make the sum differ from repeated `observe()` calls.
86+
7687
### `time()`
7788

7889
Observe the duration in seconds of a block of code or function and add it to the

‎docs/content/instrumenting/summary.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ s.observe(0.43) # observe 430ms
5555
s.observe(1024) # observe 1024 bytes
5656
```
5757

58+
### `observe_batch(amounts)`
59+
60+
Record a finite iterable of observations, such as a list, tuple, or generator.
61+
62+
```python
63+
s.observe_batch([0.12, 0.45, 0.08])
64+
```
65+
66+
Floating-point rounding may make the sum differ from repeated `observe()` calls.
67+
5868
### `time()`
5969

6070
Observe the duration in seconds of a block of code or function and add it to the

‎prometheus_client/metrics.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from bisect import bisect_left
2+
import math
13
import os
24
from threading import Lock
35
import time
@@ -564,6 +566,19 @@ def observe(self, amount: float) -> None:
564566
self._count.inc(1)
565567
self._sum.inc(amount)
566568

569+
def observe_batch(self, amounts: Iterable[float]) -> None:
570+
"""Observe a finite iterable of amounts.
571+
572+
The same restrictions on negative amounts as observe() apply.
573+
"""
574+
self._raise_if_not_observable()
575+
amounts = list(amounts)
576+
if not amounts:
577+
return
578+
total = sum(amounts)
579+
self._sum.inc(total)
580+
self._count.inc(len(amounts))
581+
567582
def time(self) -> Timer:
568583
"""Time a block of code or function, and observe the duration in seconds.
569584
@@ -691,6 +706,26 @@ def observe(self, amount: float, exemplar: Optional[Dict[str, str]] = None) -> N
691706
self._buckets[i].set_exemplar(Exemplar(exemplar, amount, time.time()))
692707
break
693708

709+
def observe_batch(self, amounts: Iterable[float]) -> None:
710+
"""Observe a finite iterable of amounts.
711+
712+
The same restrictions on negative amounts as observe() apply.
713+
Exemplars are not supported; existing exemplars are preserved.
714+
"""
715+
self._raise_if_not_observable()
716+
amounts = list(amounts)
717+
if not amounts:
718+
return
719+
total = sum(amounts)
720+
counts = [0] * len(self._upper_bounds)
721+
for amount in amounts:
722+
if not math.isnan(amount):
723+
counts[bisect_left(self._upper_bounds, amount)] += 1
724+
self._sum.inc(total)
725+
for bucket, bucket_count in zip(self._buckets, counts):
726+
if bucket_count:
727+
bucket.inc(bucket_count)
728+
694729
def time(self) -> Timer:
695730
"""Time a block of code or function, and observe the duration in seconds.
696731

‎tests/test_core.py‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from concurrent.futures import ThreadPoolExecutor
2+
from decimal import Decimal
3+
import math
24
import os
35
from threading import Barrier, Event
46
import time
@@ -311,6 +313,67 @@ def test_summary_not_observable(self):
311313
""".observe() must fail if the Summary is not observable."""
312314
assert_not_observable(self.summary_with_labels.observe, 1)
313315

316+
def test_observe_batch(self):
317+
self.summary.observe(1)
318+
self.summary.observe_batch([-2, 0, 0.5, 2])
319+
self.summary.observe_batch((3, 4))
320+
self.assertEqual(7, self.registry.get_sample_value('s_count'))
321+
self.assertEqual(8.5, self.registry.get_sample_value('s_sum'))
322+
323+
def test_observe_batch_empty(self):
324+
self.summary.observe(1)
325+
self.summary.observe_batch([])
326+
self.summary.observe_batch(())
327+
self.assertEqual(1, self.registry.get_sample_value('s_count'))
328+
self.assertEqual(1, self.registry.get_sample_value('s_sum'))
329+
330+
def test_observe_batch_not_observable(self):
331+
assert_not_observable(self.summary_with_labels.observe_batch, [1])
332+
assert_not_observable(self.summary_with_labels.observe_batch, [])
333+
334+
def test_observe_batch_labels(self):
335+
self.summary_with_labels.labels('a').observe_batch([1, 2])
336+
self.summary_with_labels.labels('b').observe_batch([4])
337+
self.assertEqual(2, self.registry.get_sample_value('s_with_labels_count', {'label1': 'a'}))
338+
self.assertEqual(3, self.registry.get_sample_value('s_with_labels_sum', {'label1': 'a'}))
339+
self.assertEqual(1, self.registry.get_sample_value('s_with_labels_count', {'label1': 'b'}))
340+
self.assertEqual(4, self.registry.get_sample_value('s_with_labels_sum', {'label1': 'b'}))
341+
342+
def test_observe_batch_nonfinite(self):
343+
self.summary.observe_batch([float('inf')])
344+
self.assertEqual(float('inf'), self.registry.get_sample_value('s_sum'))
345+
self.summary.observe_batch([float('-inf')])
346+
self.assertTrue(math.isnan(self.registry.get_sample_value('s_sum')))
347+
self.summary.observe_batch([float('nan')])
348+
self.assertEqual(3, self.registry.get_sample_value('s_count'))
349+
self.assertTrue(math.isnan(self.registry.get_sample_value('s_sum')))
350+
351+
def test_observe_batch_invalid_amounts(self):
352+
self.summary.observe(1)
353+
self.assertRaises(TypeError, self.summary.observe_batch, [2, 'invalid'])
354+
self.assertEqual(1, self.registry.get_sample_value('s_count'))
355+
self.assertEqual(1, self.registry.get_sample_value('s_sum'))
356+
self.assertRaises(TypeError, self.summary.observe_batch, [Decimal('1.5')])
357+
self.assertEqual(1, self.registry.get_sample_value('s_count'))
358+
self.assertEqual(1, self.registry.get_sample_value('s_sum'))
359+
360+
def test_observe_batch_iterators(self):
361+
self.summary.observe_batch(iter(()))
362+
self.summary.observe_batch(iter([1, 2]))
363+
self.summary.observe_batch(x for x in [3, 4])
364+
self.assertEqual(4, self.registry.get_sample_value('s_count'))
365+
self.assertEqual(10, self.registry.get_sample_value('s_sum'))
366+
367+
def test_observe_batch_iteration_failure(self):
368+
def amounts():
369+
yield 2
370+
raise RuntimeError('iteration failed')
371+
372+
self.summary.observe(1)
373+
self.assertRaises(RuntimeError, self.summary.observe_batch, amounts())
374+
self.assertEqual(1, self.registry.get_sample_value('s_count'))
375+
self.assertEqual(1, self.registry.get_sample_value('s_sum'))
376+
314377
def test_function_decorator(self):
315378
self.assertEqual(0, self.registry.get_sample_value('s_count'))
316379

@@ -449,6 +512,93 @@ def test_histogram_not_observable(self):
449512
""".observe() must fail if the Summary is not observable."""
450513
assert_not_observable(self.labels.observe, 1)
451514

515+
def test_observe_batch(self):
516+
self.histogram.observe(0.5)
517+
self.histogram.observe_batch([-1, 0, 1, 2.5])
518+
self.histogram.observe_batch((3, 20))
519+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '0.005'}))
520+
self.assertEqual(3, self.registry.get_sample_value('h_bucket', {'le': '0.5'}))
521+
self.assertEqual(4, self.registry.get_sample_value('h_bucket', {'le': '1.0'}))
522+
self.assertEqual(5, self.registry.get_sample_value('h_bucket', {'le': '2.5'}))
523+
self.assertEqual(6, self.registry.get_sample_value('h_bucket', {'le': '5.0'}))
524+
self.assertEqual(7, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
525+
self.assertEqual(7, self.registry.get_sample_value('h_count'))
526+
self.assertEqual(26, self.registry.get_sample_value('h_sum'))
527+
528+
def test_observe_batch_empty(self):
529+
self.histogram.observe(1)
530+
self.histogram.observe_batch([])
531+
self.histogram.observe_batch(())
532+
self.assertEqual(1, self.registry.get_sample_value('h_count'))
533+
self.assertEqual(1, self.registry.get_sample_value('h_sum'))
534+
535+
def test_observe_batch_not_observable(self):
536+
assert_not_observable(self.labels.observe_batch, [1])
537+
assert_not_observable(self.labels.observe_batch, [])
538+
539+
def test_observe_batch_labels(self):
540+
self.labels.labels('a').observe_batch([1, 2])
541+
self.labels.labels('b').observe_batch([4])
542+
self.assertEqual(1, self.registry.get_sample_value('hl_bucket', {'l': 'a', 'le': '1.0'}))
543+
self.assertEqual(2, self.registry.get_sample_value('hl_count', {'l': 'a'}))
544+
self.assertEqual(3, self.registry.get_sample_value('hl_sum', {'l': 'a'}))
545+
self.assertEqual(1, self.registry.get_sample_value('hl_count', {'l': 'b'}))
546+
self.assertEqual(4, self.registry.get_sample_value('hl_sum', {'l': 'b'}))
547+
548+
def test_observe_batch_custom_buckets(self):
549+
h = Histogram('custom', 'help', registry=self.registry, buckets=[-2, 0, 1, 1, 2])
550+
h.observe_batch([-3, -2, 0, 1, 2, 3])
551+
self.assertEqual(2, self.registry.get_sample_value('custom_bucket', {'le': '-2.0'}))
552+
self.assertEqual(3, self.registry.get_sample_value('custom_bucket', {'le': '0.0'}))
553+
self.assertEqual(4, self.registry.get_sample_value('custom_bucket', {'le': '1.0'}))
554+
self.assertEqual(5, self.registry.get_sample_value('custom_bucket', {'le': '2.0'}))
555+
self.assertEqual(6, self.registry.get_sample_value('custom_count'))
556+
557+
def test_observe_batch_nonfinite(self):
558+
self.histogram.observe_batch([float('nan')])
559+
self.assertEqual(0, self.registry.get_sample_value('h_count'))
560+
self.assertTrue(math.isnan(self.registry.get_sample_value('h_sum')))
561+
self.histogram.observe_batch([float('-inf'), float('inf')])
562+
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '0.005'}))
563+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
564+
self.assertEqual(2, self.registry.get_sample_value('h_count'))
565+
self.assertTrue(math.isnan(self.registry.get_sample_value('h_sum')))
566+
567+
def test_observe_batch_invalid_amounts(self):
568+
self.histogram.observe(1)
569+
self.assertRaises(TypeError, self.histogram.observe_batch, [2, 'invalid'])
570+
self.assertEqual(1, self.registry.get_sample_value('h_count'))
571+
self.assertEqual(1, self.registry.get_sample_value('h_sum'))
572+
self.assertRaises(TypeError, self.histogram.observe_batch, [Decimal('1.5')])
573+
self.assertEqual(1, self.registry.get_sample_value('h_count'))
574+
self.assertEqual(1, self.registry.get_sample_value('h_sum'))
575+
576+
def test_observe_batch_iterators(self):
577+
self.histogram.observe_batch(iter(()))
578+
self.histogram.observe_batch(iter([1, 2]))
579+
self.histogram.observe_batch(x for x in [3, 4])
580+
self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '1.0'}))
581+
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '2.5'}))
582+
self.assertEqual(4, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
583+
self.assertEqual(4, self.registry.get_sample_value('h_count'))
584+
self.assertEqual(10, self.registry.get_sample_value('h_sum'))
585+
586+
def test_observe_batch_iteration_failure(self):
587+
def amounts():
588+
yield 2
589+
raise RuntimeError('iteration failed')
590+
591+
self.histogram.observe(1)
592+
self.assertRaises(RuntimeError, self.histogram.observe_batch, amounts())
593+
self.assertEqual(1, self.registry.get_sample_value('h_count'))
594+
self.assertEqual(1, self.registry.get_sample_value('h_sum'))
595+
596+
def test_observe_batch_preserves_exemplars(self):
597+
self.histogram.observe(0.25, exemplar={'trace_id': 'abc'})
598+
before = [s.exemplar for s in self.histogram.collect()[0].samples]
599+
self.histogram.observe_batch([0.125, 0.25, 1])
600+
self.assertEqual(before, [s.exemplar for s in self.histogram.collect()[0].samples])
601+
452602
def test_setting_buckets(self):
453603
h = Histogram('h', 'help', registry=None, buckets=[0, 1, 2])
454604
self.assertEqual([0.0, 1.0, 2.0, float("inf")], h._upper_bounds)

‎tests/test_multiprocess.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,29 @@ def test_histogram_adds(self):
104104
self.assertEqual(3, self.registry.get_sample_value('h_sum'))
105105
self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '5.0'}))
106106

107+
def test_summary_observe_batch_adds(self):
108+
s1 = Summary('s', 'help', registry=None)
109+
values.ValueClass = MultiProcessValue(lambda: 456)
110+
s2 = Summary('s', 'help', registry=None)
111+
s1.observe_batch([0.25, 1])
112+
s2.observe_batch([2, 3])
113+
s1.observe(0.5)
114+
self.assertEqual(5, self.registry.get_sample_value('s_count'))
115+
self.assertEqual(6.75, self.registry.get_sample_value('s_sum'))
116+
117+
def test_histogram_observe_batch_adds(self):
118+
h1 = Histogram('h', 'help', ['route'], registry=None).labels('/test')
119+
values.ValueClass = MultiProcessValue(lambda: 456)
120+
h2 = Histogram('h', 'help', ['route'], registry=None).labels('/test')
121+
h1.observe_batch([0.25, 1])
122+
h2.observe_batch([2, 3])
123+
h1.observe(0.5)
124+
self.assertEqual(5, self.registry.get_sample_value('h_count', {'route': '/test'}))
125+
self.assertEqual(6.75, self.registry.get_sample_value('h_sum', {'route': '/test'}))
126+
self.assertEqual(3, self.registry.get_sample_value('h_bucket', {'route': '/test', 'le': '1.0'}))
127+
self.assertEqual(4, self.registry.get_sample_value('h_bucket', {'route': '/test', 'le': '2.5'}))
128+
self.assertEqual(5, self.registry.get_sample_value('h_bucket', {'route': '/test', 'le': '+Inf'}))
129+
107130
def test_gauge_all(self):
108131
g1 = Gauge('g', 'help', registry=None)
109132
values.ValueClass = MultiProcessValue(lambda: 456)

0 commit comments

Comments
 (0)