diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..4518757 --- /dev/null +++ b/docs/api.rst @@ -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: diff --git a/docs/history.rst b/docs/history.rst index 9b445f3..d08a13d 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -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 diff --git a/docs/index.rst b/docs/index.rst index 0410f4a..67b2f9f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ License .. toctree:: :hidden: + api examples contributing history diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requires/development.txt b/requires/development.txt index 2806c16..e8a3b99 100644 --- a/requires/development.txt +++ b/requires/development.txt @@ -1 +1,3 @@ +-r testing.txt +coverage>=3.7,<4.1 Sphinx diff --git a/requires/testing.txt b/requires/testing.txt new file mode 100644 index 0000000..fd00fa2 --- /dev/null +++ b/requires/testing.txt @@ -0,0 +1,2 @@ +nose>=1.3,<2 +tornado>=4.2,<4.3 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..635abcc --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[nosetests] +cover-package = sprockets.mixins.metrics +cover-branches = 1 diff --git a/sprockets/mixins/metrics/__init__.py b/sprockets/mixins/metrics/__init__.py index 2e4dd40..29aea51 100644 --- a/sprockets/mixins/metrics/__init__.py +++ b/sprockets/mixins/metrics/__init__.py @@ -1,2 +1,5 @@ +from .statsd import StatsdMixin + version_info = (0, 0, 0) __version__ = '.'.join(str(v) for v in version_info) +__all__ = ['StatsdMixin'] diff --git a/sprockets/mixins/metrics/statsd.py b/sprockets/mixins/metrics/statsd.py new file mode 100644 index 0000000..1b82d39 --- /dev/null +++ b/sprockets/mixins/metrics/statsd.py @@ -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) diff --git a/tests.py b/tests.py index e69de29..65066b2 100644 --- a/tests.py +++ b/tests.py @@ -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__)