Implement timer metric in StatsdMixin.

This commit is contained in:
Dave Shawley 2016-01-19 07:51:09 -05:00
parent b1b63644be
commit a125c4ddeb
10 changed files with 218 additions and 0 deletions

22
docs/api.rst Normal file
View file

@ -0,0 +1,22 @@
Reference Documentation
=======================
This library defines mix-ins that record application metrics. Each mix-in
implements the same interface:
.. class:: sprockets.mixins.metrics.Mixin
.. data:: SETTINGS_KEY
Key in ``self.application.settings`` that contains this particular
mix-in's configuration data.
.. method:: record_timing(path, milliseconds)
:param str path: timing path to record
:param float milliseconds: number of milliseconds to record
Statsd Implementation
---------------------
.. autoclass:: sprockets.mixins.metrics.StatsdMixin
:members:

View file

@ -5,5 +5,6 @@ Release History
`Next Release`_
---------------
- Add :class:`sprockets.mixins.metrics.StatsdMixin`
.. _Next Release: https://github.com/sprockets/sprockets.mixins.metrics/compare/0.0.0...master

View file

@ -7,6 +7,7 @@ License
.. toctree::
:hidden:
api
examples
contributing
history

0
examples/__init__.py Normal file
View file

View file

@ -1 +1,3 @@
-r testing.txt
coverage>=3.7,<4.1
Sphinx

2
requires/testing.txt Normal file
View file

@ -0,0 +1,2 @@
nose>=1.3,<2
tornado>=4.2,<4.3

3
setup.cfg Normal file
View file

@ -0,0 +1,3 @@
[nosetests]
cover-package = sprockets.mixins.metrics
cover-branches = 1

View file

@ -1,2 +1,5 @@
from .statsd import StatsdMixin
version_info = (0, 0, 0)
__version__ = '.'.join(str(v) for v in version_info)
__all__ = ['StatsdMixin']

View file

@ -0,0 +1,82 @@
import socket
class StatsdMixin(object):
"""
Mix this class in to record metrics to a Statsd server.
**Configuration**
:namespace:
Path to prefix metrics with. If undefined, this defaults to
``applications`` + ``self.__class__.__module__``
:host:
Host name of the StatsD server to send metrics to. If undefined,
this defaults to ``127.0.0.1``.
:port:
Port number that the StatsD server is listening on. If undefined,
this defaults to ``8125``.
"""
SETTINGS_KEY = 'sprockets.mixins.metrics.statsd'
"""``self.settings`` key that configures this mix-in."""
def initialize(self):
super(StatsdMixin, self).initialize()
settings = self.settings.setdefault(self.SETTINGS_KEY, {})
if 'socket' not in settings:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
settings['socket'] = sock
if 'namespace' not in settings:
settings['namespace'] = 'applications.{}'.format(
self.__class__.__module__)
settings.setdefault('host', '127.0.0.1')
settings.setdefault('port', '8125')
self.__status_code = None
def set_status(self, status_code, reason=None):
# Extended to track status code to avoid referencing the
# _status internal variable
self.__status_code = status_code
super(StatsdMixin, self).set_status(status_code, reason=reason)
def record_timing(self, milliseconds, *path):
"""
Record a timing.
:param float milliseconds: millisecond timing to record
:param path: elements of the metric path to record
This method records a timing to the application's namespace
followed by a calculated path. Each element of `path` is
converted to a string and normalized before joining the
elements by periods. The normalization process is little
more than replacing periods with dashes.
"""
settings = self.settings[self.SETTINGS_KEY]
normalized = '.'.join(str(p).replace('.', '-') for p in path)
msg = '{0}.{1}:{2}|ms'.format(settings['namespace'], normalized,
milliseconds)
settings['socket'].sendto(msg.encode('ascii'),
(settings['host'], int(settings['port'])))
def on_finish(self):
"""
Records the time taken to process the request.
This method records the number of milliseconds that were used
to process the request (as reported by
:meth:`tornado.web.HTTPRequest.request_time` * 1000) under the
path defined by the class's module, it's name, the request method,
and the status code. The :meth:`.record_timing` method is used
to send the metric, so the configured namespace is used as well.
"""
super(StatsdMixin, self).on_finish()
self.record_timing(self.request.request_time() * 1000,
self.__class__.__name__, self.request.method,
self.__status_code)

102
tests.py
View file

@ -0,0 +1,102 @@
import socket
from tornado import testing, web
from sprockets.mixins import metrics
import examples.statsd
class FakeStatsdServer(object):
"""
Implements something resembling a statsd server.
Received datagrams are saved off in the ``datagrams`` attribute
for later examination.
"""
def __init__(self, iol):
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
socket.IPPROTO_UDP)
self.socket.bind(('127.0.0.1', 0))
self.sockaddr = self.socket.getsockname()
self.datagrams = []
iol.add_handler(self.socket, self._handle_events, iol.READ)
self._iol = iol
def close(self):
if self.socket is not None:
if self._iol is not None:
self._iol.remove_handler(self.socket)
self._iol = None
self.socket.close()
self.socket = None
def _handle_events(self, fd, events):
if fd != self.socket:
return
if self._iol is None:
raise RuntimeError
if events & self._iol.READ:
data, _ = self.socket.recvfrom(4096)
self.datagrams.append(data)
class StatsdMethodTimingTests(testing.AsyncHTTPTestCase):
def get_app(self):
self.application = web.Application([
web.url('/', examples.statsd.SimpleHandler),
])
return self.application
def setUp(self):
self.application = None
super(StatsdMethodTimingTests, self).setUp()
self.statsd = FakeStatsdServer(self.io_loop)
self.application.settings[metrics.StatsdMixin.SETTINGS_KEY] = {
'host': self.statsd.sockaddr[0],
'port': self.statsd.sockaddr[1],
'namespace': 'testing',
}
def tearDown(self):
self.statsd.close()
super(StatsdMethodTimingTests, self).tearDown()
@property
def settings(self):
return self.application.settings[metrics.StatsdMixin.SETTINGS_KEY]
def test_that_http_method_call_is_recorded(self):
response = self.fetch('/')
self.assertEqual(response.code, 204)
expected = 'testing.SimpleHandler.GET.204:'
for bin_msg in self.statsd.datagrams:
text_msg = bin_msg.decode('ascii')
if text_msg.startswith(expected):
_, _, tail = text_msg.partition(':')
measurement, _, measurement_type = tail.partition('|')
self.assertTrue(250.0 <= float(measurement) < 500.0,
'{} looks wrong'.format(measurement))
self.assertTrue(measurement_type, 'ms')
break
else:
self.fail('Expected metric starting with {} in {!r}'.format(
expected, self.statsd.datagrams))
def test_that_cached_socket_is_used(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
self.settings['socket'] = sock
self.fetch('/')
self.assertIs(self.settings['socket'], sock)
def test_that_default_prefix_is_stored(self):
del self.settings['namespace']
self.fetch('/')
self.assertEqual(
self.settings['namespace'],
'applications.' + examples.statsd.SimpleHandler.__module__)