Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/content/instrumenting/histogram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/content/instrumenting/summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions prometheus_client/metrics.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from bisect import bisect_left
import math
import os
from threading import Lock
import time
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
150 changes: 150 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
import math
import os
from threading import Barrier, Event
import time
Expand Down Expand Up @@ -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'))

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions tests/test_multiprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down