Implement the python-statsd Timer interface.

This commit is contained in:
Dave Shawley 2021-07-18 11:15:28 -04:00
parent 3f0f46cd6d
commit 1c6ba8f80e
No known key found for this signature in database
GPG key ID: F41A8A99298F8EED
5 changed files with 184 additions and 18 deletions

View file

@ -1,6 +1,7 @@
Next release
------------
- Added ``Connector.timer`` method (addresses :issue:`8`)
- Implement ``Timer`` abstraction from python-statsd
:tag:`0.1.0 <0.0.1...0.1.0>` (10-May-2021)
------------------------------------------

View file

@ -36,15 +36,29 @@ metric to send and the task consumes the internal queue when it is connected.
The following convenience methods are available. You can also call ``inject_metric`` for complete control over
the payload.
+--------------+--------------------------------------+
+--------------+--------------------------------------------------------------+
| ``incr`` | Increment a counter metric |
+--------------+--------------------------------------+
+--------------+--------------------------------------------------------------+
| ``decr`` | Decrement a counter metric |
+--------------+--------------------------------------+
+--------------+--------------------------------------------------------------+
| ``gauge`` | Adjust or set a gauge metric |
+--------------+--------------------------------------+
+--------------+--------------------------------------------------------------+
| ``timer`` | Append a duration to a timer metric using a context manager |
+--------------+--------------------------------------------------------------+
| ``timing`` | Append a duration to a timer metric |
+--------------+--------------------------------------+
+--------------+--------------------------------------------------------------+
If you are a `python-statsd`_ user, then the method names should look very familiar. That is quite intentional.
I like the interface and many others do as well. There is one very very important difference though -- the
``timing`` method takes the duration as the number of **seconds** as a :class:`float` instead of the number of
milliseconds.
.. warning::
If you are accustomed to using `python-statsd`_, be aware that the ``timing`` method expects the number of
seconds as a :class:`float` instead of the number of milliseconds.
.. _python-statsd: https://statsd.readthedocs.io/en/latest/
Tornado helpers
===============

View file

@ -46,6 +46,7 @@ Reference
.. autoclass:: sprockets_statsd.statsd.Connector
:members:
:inherited-members: incr, decr, gauge, timer, timing
Tornado helpers
---------------
@ -63,6 +64,9 @@ Internals
.. autoclass:: sprockets_statsd.statsd.Processor
:members:
.. autoclass:: sprockets_statsd.statsd.Timer
:members:
.. autoclass:: sprockets_statsd.statsd.StatsdProtocol
:members:

View file

@ -1,5 +1,4 @@
import asyncio
import contextlib
import logging
import socket
import time
@ -47,6 +46,80 @@ class ThrottleGuard:
self.counter = 0
class Timer:
"""Implement Timer interface from python-statsd.
Instances of this class are returned from
:meth:`.AbstractConnector.timer` to maintain some compatibility
with `python-statsd`_. You should not create instances of this
class yourself.
This implementation follows the careful protocol created by
python-statsd in that it raises :exc:`RuntimeError` in the
following cases:
* :meth:`.stop` is called before calling :meth:`.start`
* :meth:`.send` is called before calling :meth:`.stop`
which requires a prior call to :meth:`.start`
The call to :meth:`.send` clears the timing values so calling it
twice in a row will result in a :exc:`RuntimeError` as well.
"""
def __init__(self, connector: 'AbstractConnector', path: str):
self._connector = connector
self._path = path
self._start_time, self._finish_time = None, None
def start(self) -> 'Timer':
"""Start the timer and return `self`."""
self._start_time = time.time()
return self
def stop(self, send: bool = True) -> 'Timer':
"""Stop the timer and send the timing.
:param send: immediately send the recorded timing to the
processor
You can delay sending the timing by setting the `send`
parameter to :data:`False`.
"""
if self._start_time is None:
raise RuntimeError('Timer.stop called before start')
self._finish_time = time.time()
if send:
self.send()
return self
def send(self):
"""Send the recorded timing to the connector.
This method will raise a :exc:`RuntimeError` if a timing has
not been recorded by calling :meth:`start` and :meth:`stop` in
sequence.
"""
if self._start_time is None:
raise RuntimeError('Timer.send called before start')
if self._finish_time is None:
raise RuntimeError('Timer.send called before stop')
self._connector.timing(
self._path,
max(self._finish_time, self._start_time) - self._start_time)
self._start_time, self._finish_time = None, None
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
return False
class AbstractConnector:
"""StatsD connector that does not send metrics or connect.
@ -110,19 +183,13 @@ class AbstractConnector:
"""
self.inject_metric(f'timers.{path}', str(seconds * 1000.0), 'ms')
@contextlib.contextmanager
def timer(self, path):
def timer(self, path) -> Timer:
"""Send a timer metric using a context manager.
:param path: timer to append the measured time to
"""
start = time.time()
try:
yield
finally:
fini = time.time()
self.timing(path, max(fini, start) - start)
return Timer(self, path)
class Connector(AbstractConnector):

View file

@ -460,3 +460,83 @@ class ConnectorOptionTests(ProcessorTestCase):
for _ in range(connector.processor.queue.qsize()):
metric = await connector.processor.queue.get()
self.assertEqual(metric, b'counters.counter:1|c')
class ConnectorTimerTests(ProcessorTestCase):
ip_protocol = socket.IPPROTO_TCP
async def asyncSetUp(self):
await super().asyncSetUp()
self.connector = statsd.Connector(self.statsd_server.host,
self.statsd_server.port)
await self.connector.start()
await self.wait_for(self.statsd_server.client_connected.acquire())
async def asyncTearDown(self):
await self.wait_for(self.connector.stop())
await super().asyncTearDown()
async def test_that_stop_raises_if_not_started(self):
timer = self.connector.timer('whatever')
with self.assertRaises(RuntimeError):
timer.stop()
async def test_that_start_returns_instance(self):
timer = self.connector.timer('whatever')
self.assertIs(timer, timer.start())
async def test_that_stop_returns_instance(self):
timer = self.connector.timer('whatever')
timer.start()
self.assertIs(timer, timer.stop())
async def test_that_timing_is_sent_by_stop(self):
timer = self.connector.timer('whatever')
timer.start()
self.assertTrue(self.statsd_server.message_received.locked(),
'timing sent to server unexpectedly')
timer.stop()
await self.wait_for(self.statsd_server.message_received.acquire())
async def test_that_timing_send_can_be_delayed(self):
timer = self.connector.timer('whatever')
timer.start()
self.assertTrue(self.statsd_server.message_received.locked(),
'timing sent to server unexpectedly')
timer.stop(send=False)
self.assertTrue(self.statsd_server.message_received.locked(),
'timing sent to server unexpectedly')
timer.send()
await self.wait_for(self.statsd_server.message_received.acquire())
async def test_that_send_raises_when_already_sent(self):
timer = self.connector.timer('whatever')
timer.start()
timer.stop(send=False)
timer.send()
await self.wait_for(self.statsd_server.message_received.acquire())
with self.assertRaises(RuntimeError):
timer.send()
async def test_that_send_raises_when_not_started(self):
timer = self.connector.timer('whatever')
with self.assertRaises(RuntimeError):
timer.send()
async def test_that_send_raises_when_not_stopped(self):
timer = self.connector.timer('whatever')
timer.start()
with self.assertRaises(RuntimeError):
timer.send()
async def test_that_timer_can_be_reused(self):
timer = self.connector.timer('whatever')
with timer:
pass
await self.wait_for(self.statsd_server.message_received.acquire())
self.assertTrue(self.statsd_server.message_received.locked())
with timer:
pass
await self.wait_for(self.statsd_server.message_received.acquire())