Merge pull request #15 from sprockets/refactor

Implement collector/periodic callback
This commit is contained in:
Ryan Mclean 2016-03-11 07:21:43 -05:00
commit 29ab41a1ee
8 changed files with 395 additions and 294 deletions

View file

@ -1,14 +1,40 @@
sprockets.mixins.metrics sprockets.mixins.metrics
======================== ========================
Adjust counter and timer metrics in InfluxDB or Graphite using the same API. Adjust counter and timer metrics in `InfluxDB`_ or `StatsD`_ using the same API.
The mix-in is configured through the ``tornado.web.Application`` settings
property using a key defined by the specific mix-in.
Statsd Mixin
------------
The following snippet configures the StatsD mix-in from common environment
variables. This simple handler will emit a timer metric that identifies each
call to the ``get`` method as well as a separate metric for the database query.
.. code-block:: python .. code-block:: python
from sprockets.mixins import mediatype, metrics import os
from sprockets.mixins import mediatype
from sprockets.mixins.metrics import statsd
from tornado import gen, web from tornado import gen, web
import queries import queries
class MyHandler(metrics.StatsdMixin, mediatype.ContentMixin, def make_application():
settings = {
statsd.SETTINGS_KEY: {
'namespace': 'my-application',
'host': os.environ.get('STATSD_HOST', '127.0.0.1'),
'port': os.environ.get('STATSD_PORT', '8125'),
}
}
return web.Application([
# insert handlers here
], **settings)
class MyHandler(statsd.StatsdMixin,
mediatype.ContentMixin,
web.RequestHandler): web.RequestHandler):
def initialize(self): def initialize(self):
@ -22,38 +48,8 @@ Adjust counter and timer metrics in InfluxDB or Graphite using the same API.
obj_id) obj_id)
self.send_response(result) self.send_response(result)
This simple handler will emit a timer metric that identifies each call to the Settings
``get`` method as well as a separate metric for the database query. Switching ^^^^^^^^
from using `statsd`_ to `InfluxDB`_ is simply a matter of switch from the
``metrics.StatsdMixin`` to the ``metrics.InfluxDBMixin``.
The mix-in is configured through the ``tornado.web.Application`` settings
property using a key defined by the specific mix-in.
Statsd Mixin
------------
The following snippet configures the StatsD mix-in from common environment
variables:
.. code-block:: python
import os
from sprockets.mixins import metrics
from tornado import web
def make_application():
settings = {
metrics.StatsdMixin.SETTINGS_KEY: {
'namespace': 'my-application',
'host': os.environ.get('STATSD_HOST', '127.0.0.1'),
'port': os.environ.get('STATSD_PORT', '8125'),
}
}
return web.Application([
# insert handlers here
], **settings)
:namespace: The namespace for the measurements :namespace: The namespace for the measurements
:host: The Statsd host :host: The Statsd host
@ -69,32 +65,55 @@ variables:
import os import os
from sprockets.mixins import metrics from sprockets.mixins.metrics import influxdb
from tornado import web from sprockets.mixins import postgresql
from tornado import gen, web
def make_application(): def make_app(**settings):
settings = { settings[influxdb.SETTINGS_KEY] = {
metrics.InfluxDBMixin.SETTINGS_KEY: { 'measurement': 'rollup',
'measurement': 'my-application',
'database': 'services',
'write_url': 'http://{}:{}/write'.format(
os.environ.get('INFLUX_HOST', '127.0.0.1'),
os.environ.get('INFLUX_PORT', 8086)),
'max_buffer_time': 3,
'max_buffer_length': 100
}
} }
return web.Application([
# insert handlers here
], **settings)
:measurement: The InfluxDB measurement name application = web.Application(
:database: The InfluxDB database to write measurements into [
:write_url: the InfluxDB write URL to send HTTP requests to web.url(r'/', RequestHandler),
:max_buffer_time: The maximum elasped time measurements should remain in ], **settings)
buffer before writing to InfluxDB.
:max_buffer_length: The maximum number of measurements to influxdb.install({'url': 'http://localhost:8086',
buffer before writing to InfluxDB. 'database': 'tornado-app'})
return application
class MyHandler(influxdb.InfluxDBMixin,
postgresql.HandlerMixin,
web.RequestHandler):
@gen.coroutine
def get(self, obj_id):
with self.execution_timer('dbquery', 'get'):
result = yield self.postgresql_session.query(
'SELECT * FROM foo WHERE id=%s', obj_id)
self.send_response(result)
If your application handles signal handling for shutdowns, the
:meth:`~sprockets.mixins.influxdb.shutdown` method will try to cleanly ensure
that any buffered metrics in the InfluxDB collector are written prior to
shutting down. The method returns a :cls:`~tornado.concurrent.TracebackFuture`
that should be waited on prior to shutting down.
Settings
^^^^^^^^
:url: The InfluxDB API URL
:database: the database to write measurements into
:submission_interval: How often to submit metric batches in
milliseconds. Default: ``5000``
:max_batch_size: The number of measurements to be submitted in a
single HTTP request. Default: ``1000``
:tags: Default tags that are to be submitted with each metric. The tag
``hostname`` is added by default along with ``environment`` and ``service``
if the corresponding ``ENVIRONMENT`` or ``SERVICE`` environment variables
are set.
Development Quickstart Development Quickstart
---------------------- ----------------------
@ -104,6 +123,13 @@ Development Quickstart
$ . ./env/bin/activate $ . ./env/bin/activate
(env)$ env/bin/pip install -r requires/development.txt (env)$ env/bin/pip install -r requires/development.txt
(env)$ nosetests (env)$ nosetests
test_metrics_with_buffer_not_flush (tests.InfluxDbTests) ... ok
test_that_cached_db_connection_is_used (tests.InfluxDbTests) ... ok
test_that_counter_is_tracked (tests.InfluxDbTests) ... ok
test_that_execution_timer_is_tracked (tests.InfluxDbTests) ... ok
test_that_http_method_call_details_are_recorded (tests.InfluxDbTests) ... ok
test_that_metric_tag_is_tracked (tests.InfluxDbTests) ... ok
test_that_add_metric_tag_is_ignored (tests.StatsdMethodTimingTests) ... ok
test_that_cached_socket_is_used (tests.StatsdMethodTimingTests) ... ok test_that_cached_socket_is_used (tests.StatsdMethodTimingTests) ... ok
test_that_counter_accepts_increment_value (tests.StatsdMethodTimingTests) ... ok test_that_counter_accepts_increment_value (tests.StatsdMethodTimingTests) ... ok
test_that_counter_increment_defaults_to_one (tests.StatsdMethodTimingTests) ... ok test_that_counter_increment_defaults_to_one (tests.StatsdMethodTimingTests) ... ok
@ -112,12 +138,12 @@ Development Quickstart
test_that_http_method_call_is_recorded (tests.StatsdMethodTimingTests) ... ok test_that_http_method_call_is_recorded (tests.StatsdMethodTimingTests) ... ok
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 6 tests in 1.089s Ran 13 tests in 3.572s
OK OK
(env)$ ./setup.py build_sphinx -q (env)$ ./setup.py build_sphinx -q
running build_sphinx running build_sphinx
(env)$ open build/sphinx/html/index.html (env)$ open build/sphinx/html/index.html
.. _statsd: https://github.com/etsy/statsd .. _StatsD: https://github.com/etsy/statsd
.. _InfluxDB: https://influxdata.com .. _InfluxDB: https://influxdata.com

View file

@ -1,11 +1,10 @@
import os
import signal import signal
from sprockets.mixins import metrics from sprockets.mixins.metrics import influxdb
from tornado import concurrent, gen, ioloop, web from tornado import concurrent, gen, ioloop, web
class SimpleHandler(metrics.InfluxDBMixin, web.RequestHandler): class SimpleHandler(influxdb.InfluxDBMixin, web.RequestHandler):
""" """
Simply emits a few metrics around the GET method. Simply emits a few metrics around the GET method.
@ -65,17 +64,14 @@ def make_application():
by the ``service`` setting. by the ``service`` setting.
""" """
influx_url = 'http://{}:{}/write'.format(
os.environ.get('INFLUX_HOST', '127.0.0.1'),
os.environ.get('INFLUX_PORT', 8086))
settings = { settings = {
metrics.InfluxDBMixin.SETTINGS_KEY: { influxdb.SETTINGS_KEY: {
'measurement': 'cli', 'measurement': 'example',
'database': 'testing',
'write_url': influx_url,
} }
} }
return web.Application([web.url('/', SimpleHandler)], **settings) application = web.Application([web.url('/', SimpleHandler)], **settings)
influxdb.install(application, **{'database': 'testing'})
return application
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -1,10 +1,10 @@
import signal import signal
from sprockets.mixins import metrics from sprockets.mixins.metrics import statsd
from tornado import concurrent, gen, ioloop, web from tornado import concurrent, gen, ioloop, web
class SimpleHandler(metrics.StatsdMixin, web.RequestHandler): class SimpleHandler(statsd.StatsdMixin, web.RequestHandler):
""" """
Simply emits a timing metric around the method call. Simply emits a timing metric around the method call.
@ -53,7 +53,7 @@ def make_application():
""" """
settings = { settings = {
metrics.StatsdMixin.SETTINGS_KEY: { statsd.SETTINGS_KEY: {
'namespace': 'webapps', 'namespace': 'webapps',
'host': '127.0.0.1', 'host': '127.0.0.1',
'port': 8125, 'port': 8125,

View file

@ -1,13 +1,3 @@
try: version_info = (2, 0, 0)
from .influxdb import InfluxDBMixin
from .statsd import StatsdMixin
except ImportError as error:
def InfluxDBMixin(*args, **kwargs):
raise error
def StatsdMixin(*args, **kwargs):
raise error
version_info = (1, 1, 1)
__version__ = '.'.join(str(v) for v in version_info) __version__ = '.'.join(str(v) for v in version_info)
__all__ = ['__version__', 'version_info', 'InfluxDBMixin', 'StatsdMixin'] __all__ = ['__version__', 'version_info']

View file

@ -1,119 +1,31 @@
import contextlib import contextlib
import logging
import os
import socket import socket
import time import time
from tornado import httpclient, ioloop from tornado import concurrent, httpclient, ioloop
from sprockets.mixins.metrics import __version__
class InfluxDBConnection(object): LOGGER = logging.getLogger(__name__)
"""Connection to an InfluxDB instance.
:param str write_url: the URL to send HTTP requests to SETTINGS_KEY = 'sprockets.mixins.metrics.influxdb'
:param str database: the database to write measurements into """``self.settings`` key that configures this mix-in."""
:param tornado.ioloop.IOLoop: the IOLoop to spawn callbacks on.
If this parameter is :data:`None`, then the active IOLoop,
as determined by :meth:`tornado.ioloop.IOLoop.instance`,
is used.
:param int max_buffer_time: the maximum elasped time measurements
should remain in buffer before writing to InfluxDB.
:param int max_buffer_length: the maximum number of measurements to
buffer before writing to InfluxDB.
An instance of this class is stored in the application settings _USER_AGENT = 'sprockets.mixins.metrics/v{}'.format(__version__)
and used to asynchronously send measurements to InfluxDB instance.
Each measurement is sent by spawning a context-free callback on
the IOloop.
"""
MAX_BUFFER_TIME = 5
MAX_BUFFER_LENGTH = 100
def __init__(self, write_url, database, io_loop=None,
max_buffer_time=None, max_buffer_length=None):
self.io_loop = ioloop.IOLoop.instance() if io_loop is None else io_loop
self.client = httpclient.AsyncHTTPClient()
self.write_url = '{}?db={}'.format(write_url, database)
self._buffer = []
if max_buffer_time is None:
max_buffer_time = self.MAX_BUFFER_TIME
if max_buffer_length is None:
max_buffer_length = self.MAX_BUFFER_LENGTH
self._max_buffer_time = float(max_buffer_time)
self._max_buffer_length = int(max_buffer_length)
self._last_write = self.io_loop.time()
def submit(self, measurement, tags, values):
"""Write the data using the HTTP API
:param str measurement: The required measurement name
:param list tags: The measurement tags
:param list values: The recorded measurements
"""
body = '{},{} {} {:d}'.format(measurement, ','.join(tags),
','.join(values),
int(time.time() * 1000000000))
self._buffer.append(body)
if self._should_write:
self._write()
def _write(self):
"""Write the measurement"""
body = '\n'.join(self._buffer)
request = httpclient.HTTPRequest(self.write_url, method='POST',
body=body.encode('utf-8'))
ioloop.IOLoop.current().spawn_callback(self.client.fetch, request)
self._last_write = self.io_loop.time()
del self._buffer[:]
@property
def _should_write(self):
"""Returns ``True`` if the buffered measurements should be sent"""
if len(self._buffer) >= self._max_buffer_length:
return True
if self.io_loop.time() >= (self._last_write + self._max_buffer_time):
return True
return False
class InfluxDBMixin(object): class InfluxDBMixin(object):
""" """Mix this class in to record measurements to a InfluxDB server."""
Mix this class in to record measurements to a InfluxDB server.
**Configuration**
:database:
InfluxDB database to write measurements to. This is passed
as the ``db`` query parameter when writing to Influx.
https://docs.influxdata.com/influxdb/v0.9/guides/writing_data/
:write_url:
The URL that the InfluxDB write endpoint is available on.
This is used as-is to write data into Influx.
"""
SETTINGS_KEY = 'sprockets.mixins.metrics.influxdb'
"""``self.settings`` key that configures this mix-in."""
def initialize(self):
super(InfluxDBMixin, self).initialize()
if self.SETTINGS_KEY in self.settings:
settings = self.settings[self.SETTINGS_KEY]
if 'db_connection' not in settings:
settings['db_connection'] = InfluxDBConnection(
settings['write_url'], settings['database'],
max_buffer_time=settings.get('max_buffer_time'),
max_buffer_length=settings.get('max_buffer_length'))
def __init__(self, application, request, **kwargs):
super(InfluxDBMixin, self).__init__(application, request, **kwargs)
self.__metrics = [] self.__metrics = []
self.__tags = { self.__tags = {
'host': socket.gethostname(),
'handler': '{}.{}'.format(self.__module__, 'handler': '{}.{}'.format(self.__module__,
self.__class__.__name__), self.__class__.__name__),
'method': self.request.method, 'method': request.method,
} }
def set_metric_tag(self, tag, value): def set_metric_tag(self, tag, value):
@ -139,7 +51,8 @@ class InfluxDBMixin(object):
A timing is a named duration value. A timing is a named duration value.
""" """
self.__metrics.append('{}={}'.format('.'.join(path), duration)) self.__metrics.append('{}={}'.format(
self.application.influxdb.escape_str('.'.join(path)), duration))
def increase_counter(self, *path, **kwargs): def increase_counter(self, *path, **kwargs):
""" """
@ -152,8 +65,9 @@ class InfluxDBMixin(object):
Counters are simply values that are summed in a query. Counters are simply values that are summed in a query.
""" """
self.__metrics.append('{}={}'.format('.'.join(path), self.__metrics.append('{}={}'.format(
kwargs.get('amount', 1))) self.application.influxdb.escape_str('.'.join(path)),
kwargs.get('amount', 1)))
@contextlib.contextmanager @contextlib.contextmanager
def execution_timer(self, *path): def execution_timer(self, *path):
@ -171,15 +85,247 @@ class InfluxDBMixin(object):
try: try:
yield yield
finally: finally:
fini = max(time.time(), start) self.record_timing(max(time.time(), start) - start, *path)
self.record_timing(fini - start, *path)
def on_finish(self): def on_finish(self):
super(InfluxDBMixin, self).on_finish() super(InfluxDBMixin, self).on_finish()
self.set_metric_tag('status_code', self._status_code) self.set_metric_tag('status_code', self._status_code)
self.record_timing(self.request.request_time(), 'duration') self.record_timing(self.request.request_time(), 'duration')
self.settings[self.SETTINGS_KEY]['db_connection'].submit( self.application.influxdb.submit(
self.settings[self.SETTINGS_KEY]['measurement'], self.settings[SETTINGS_KEY]['measurement'],
('{}={}'.format(k, v) for k, v in self.__tags.items()), self.__tags,
self.__metrics, self.__metrics)
)
class InfluxDBCollector(object):
"""Collects and submits stats to InfluxDB on a periodic callback.
:param str url: The InfluxDB API URL
:param str database: the database to write measurements into
:param tornado.ioloop.IOLoop: the IOLoop to spawn callbacks on.
If this parameter is :data:`None`, then the active IOLoop,
as determined by :meth:`tornado.ioloop.IOLoop.instance`,
is used.
:param int submission_interval: How often to submit metric batches in
milliseconds. Default: ``5000``
:param max_batch_size: The number of measurements to be submitted in a
single HTTP request. Default: ``1000``
:param dict tags: Default tags that are to be submitted with each metric.
This class should be constructed using the
:meth:`~sprockets.mixins.influxdb.install` method. When installed, it is
attached to the :class:`~tornado.web.Application` instance for your web
application and schedules a periodic callback to submit metrics to InfluxDB
in batches.
"""
SUBMISSION_INTERVAL = 5000
MAX_BATCH_SIZE = 1000
WARN_THRESHOLD = 25000
def __init__(self, url='http://localhost:8086', database='sprockets',
io_loop=None, submission_interval=SUBMISSION_INTERVAL,
max_batch_size=MAX_BATCH_SIZE, tags=None):
self._buffer = list()
self._database = database
self._influxdb_url = '{}?db={}'.format(url, database)
self._interval = submission_interval or self.SUBMISSION_INTERVAL
self._io_loop = io_loop or ioloop.IOLoop.current()
self._max_batch_size = max_batch_size or self.MAX_BATCH_SIZE
self._pending = 0
self._tags = tags or {}
self._client = httpclient.AsyncHTTPClient(force_instance=True,
io_loop=self._io_loop)
self._client.configure(None, defaults={'user_agent': _USER_AGENT})
# Add the periodic callback for submitting metrics
LOGGER.info('Starting PeriodicCallback for writing InfluxDB metrics')
self._callback = ioloop.PeriodicCallback(self._write_metrics,
self._interval)
self._callback.start()
@staticmethod
def escape_str(value):
"""Escape the value with InfluxDB's wonderful escaping logic:
"Measurement names, tag keys, and tag values must escape any spaces or
commas using a backslash (\). For example: \ and \,. All tag values are
stored as strings and should not be surrounded in quotes."
:param str value: The value to be escaped
:rtype: str
"""
return str(value).replace(' ', '\ ').replace(',', '\,')
@property
def database(self):
"""Return the configured database name.
:rtype: str
"""
return self._database
def shutdown(self):
"""Invoke on shutdown of your application to stop the periodic
callbacks and flush any remaining metrics.
Returns a future that is complete when all pending metrics have been
submitted.
:rtype: :class:`~tornado.concurrent.TracebackFuture()`
"""
future = concurrent.TracebackFuture()
self._callback.stop()
self._write_metrics()
self._shutdown_wait(future)
return future
def submit(self, measurement, tags, values):
"""Add a measurement to the buffer that will be submitted to InfluxDB
on the next periodic callback for writing metrics.
:param str measurement: The measurement name
:param dict tags: The measurement tags
:param list values: The recorded measurements
"""
self._buffer.append('{},{} {} {:d}'.format(
self.escape_str(measurement),
self._get_tag_string(tags),
','.join(values),
int(time.time() * 1000000000)))
if len(self._buffer) > self.WARN_THRESHOLD:
LOGGER.warning('InfluxDB metric buffer is > %i (%i)',
self.WARN_THRESHOLD, len(self._buffer))
def _get_tag_string(self, tags):
"""Return the tags to be submitted with a measurement combining the
default tags that were passed in when constructing the class along
with any measurement specific tags passed into the
:meth:`~InfluxDBConnection.submit` method. Tags will be properly
escaped and formatted for submission.
:param dict tags: Measurement specific tags
:rtype: str
"""
values = dict(self._tags)
values.update(tags)
return ','.join(['{}={}'.format(self.escape_str(k), self.escape_str(v))
for k, v in values.items()])
def _on_write_response(self, response):
"""This is invoked by the Tornado IOLoop when the HTTP request to
InfluxDB has returned with a result.
:param response: The response from InfluxDB
:type response: :class:`~tornado.httpclient.HTTPResponse`
"""
self._pending -= 1
LOGGER.debug('InfluxDB batch response: %s', response.code)
if response.error:
LOGGER.error('InfluxDB batch submission error: %s', response.error)
def _shutdown_wait(self, future):
"""Pause briefly allowing any pending metric writes to complete before
shutting down.
:param future tornado.concurrent.TracebackFuture: The future to resulve
when the shutdown is complete.
"""
if not self._pending:
future.set_result(True)
return
LOGGER.debug('Waiting for pending metric writes')
self._io_loop.add_timeout(self._io_loop.time() + 0.1,
self._shutdown_wait,
(future,))
def _write_metrics(self):
"""Submit the metrics in the buffer to InfluxDB. This is invoked
by the periodic callback scheduled when the class is created.
It will submit batches until the buffer is empty.
"""
if not self._buffer:
return
LOGGER.debug('InfluxDB buffer has %i items', len(self._buffer))
while self._buffer:
body = '\n'.join(self._buffer[:self._max_batch_size])
self._buffer = self._buffer[self._max_batch_size:]
self._pending += 1
self._client.fetch(self._influxdb_url, method='POST',
body=body.encode('utf-8'),
raise_error=False,
callback=self._on_write_response)
LOGGER.debug('Submitted all InfluxDB metrics for writing')
def install(application, **kwargs):
"""Call this to install the InfluxDB collector into a Tornado application.
:param tornado.web.Application application: the application to
install the collector into.
:param kwargs: keyword parameters to pass to the
:class:`InfluxDBCollector` initializer.
:returns: :data:`True` if the client was installed by this call
and :data:`False` otherwise.
Optional configuration values:
- **url** The InfluxDB API URL. If URL is not specified, the
``INFLUX_HOST`` and ``INFLUX_PORT`` environment variables will be used
to construct the URL to pass into the :class:`InfluxDBCollector`.
- **database** the database to write measurements into.
The default is ``sprockets``.
- **io_loop** A :class:`~tornado.ioloop.IOLoop` to use
- **submission_interval** How often to submit metric batches in
milliseconds. Default: ``5000``
- **max_batch_size** The number of measurements to be submitted in a
single HTTP request. Default: ``1000``
- **tags** Default tags that are to be submitted with each metric.
"""
if getattr(application, 'influxdb', None) is not None:
LOGGER.warning('InfluxDBCollector is already installed')
return False
# Get config values
url = 'http://{}:{}/write'.format(os.environ.get('INFLUX_HOST'),
os.environ.get('INFLUX_PORT'))
kwargs.setdefault('url', url)
# Build the full tag dict and replace what was passed in
tags = {'hostname': socket.gethostname()}
if os.environ.get('ENVIRONMENT'):
tags['environment'] = os.environ.get('ENVIRONMENT')
if os.environ.get('SERVICE'):
tags['service'] = os.environ.get('SERVICE')
tags.update(kwargs.get('tags', {}))
kwargs['tags'] = tags
# Create and start the collector
setattr(application, 'influxdb', InfluxDBCollector(**kwargs))
return True
def shutdown(application):
"""Invoke to shutdown the InfluxDB collector, writing any pending
measurements to InfluxDB before stopping.
:param tornado.web.Application application: the application to
install the collector into.
:rtype: tornado.concurrent.TracebackFuture or None
"""
collector = getattr(application, 'influxdb', None)
if collector:
return collector.shutdown()

View file

@ -2,6 +2,9 @@ import contextlib
import socket import socket
import time import time
SETTINGS_KEY = 'sprockets.mixins.metrics.statsd'
"""``self.settings`` key that configures this mix-in."""
class StatsdMixin(object): class StatsdMixin(object):
""" """
@ -22,13 +25,9 @@ class StatsdMixin(object):
this defaults to ``8125``. this defaults to ``8125``.
""" """
SETTINGS_KEY = 'sprockets.mixins.metrics.statsd'
"""``self.settings`` key that configures this mix-in."""
def initialize(self): def initialize(self):
super(StatsdMixin, self).initialize() super(StatsdMixin, self).initialize()
settings = self.settings.setdefault(self.SETTINGS_KEY, {}) settings = self.settings.setdefault(SETTINGS_KEY, {})
if 'socket' not in settings: if 'socket' not in settings:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
settings['socket'] = sock settings['socket'] = sock
@ -97,8 +96,7 @@ class StatsdMixin(object):
try: try:
yield yield
finally: finally:
fini = max(start, time.time()) self.record_timing(max(start, time.time()) - start, *path)
self.record_timing(fini - start, *path)
def on_finish(self): def on_finish(self):
""" """
@ -119,12 +117,12 @@ class StatsdMixin(object):
def _build_path(self, path): def _build_path(self, path):
"""Return a normalized path.""" """Return a normalized path."""
return '{}.{}'.format(self.settings[self.SETTINGS_KEY]['namespace'], return '{}.{}'.format(self.settings[SETTINGS_KEY]['namespace'],
'.'.join(str(p).replace('.', '-') for p in path)) '.'.join(str(p).replace('.', '-') for p in path))
def _send(self, path, value, stat_type): def _send(self, path, value, stat_type):
"""Send a metric to Statsd.""" """Send a metric to Statsd."""
settings = self.settings[self.SETTINGS_KEY] settings = self.settings[SETTINGS_KEY]
msg = '{0}:{1}|{2}'.format(path, value, stat_type) msg = '{0}:{1}|{2}'.format(path, value, stat_type)
settings['socket'].sendto(msg.encode('ascii'), settings['socket'].sendto(msg.encode('ascii'),
(settings['host'], int(settings['port']))) (settings['host'], int(settings['port'])))

View file

@ -1,10 +1,11 @@
import collections
import logging import logging
import re import re
import socket import socket
from tornado import gen, web from tornado import gen, web
from sprockets.mixins.metrics import influxdb
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
STATS_PATTERN = re.compile(r'(?P<path>[^:]*):(?P<value>[^|]*)\|(?P<type>.*)$') STATS_PATTERN = re.compile(r'(?P<path>[^:]*):(?P<value>[^|]*)\|(?P<type>.*)$')
@ -124,12 +125,13 @@ class FakeInfluxHandler(web.RequestHandler):
# inspect measurements # inspect measurements
""" """
def initialize(self): def initialize(self):
super(FakeInfluxHandler, self).initialize() super(FakeInfluxHandler, self).initialize()
self.logger = LOGGER.getChild(__name__) self.logger = LOGGER.getChild(__name__)
if not hasattr(self.application, 'influx_db'): if not hasattr(self.application, 'influx_db'):
self.application.influx_db = collections.defaultdict(list) self.application.influx_db = {}
if self.application.influxdb.database not in self.application.influx_db:
self.application.influx_db[self.application.influxdb.database] = []
def post(self): def post(self):
db = self.get_query_argument('db') db = self.get_query_argument('db')
@ -141,7 +143,7 @@ class FakeInfluxHandler(web.RequestHandler):
self.set_status(204) self.set_status(204)
@staticmethod @staticmethod
def get_messages(application, database, test_case): def get_messages(application, test_case):
""" """
Wait for measurements to show up and return them. Wait for measurements to show up and return them.
@ -161,10 +163,10 @@ class FakeInfluxHandler(web.RequestHandler):
are not received in a reasonable number of runs. are not received in a reasonable number of runs.
""" """
for _ in range(0, 10): for _ in range(0, 15):
if hasattr(application, 'influx_db'): if hasattr(application, 'influx_db'):
if application.influx_db[database]: if application.influx_db.get(application.influxdb.database):
return application.influx_db[database] return application.influx_db[application.influxdb.database]
test_case.io_loop.add_future(gen.sleep(0.1), test_case.io_loop.add_future(gen.sleep(0.1),
lambda _: test_case.stop()) lambda _: test_case.stop())
test_case.wait() test_case.wait()

107
tests.py
View file

@ -6,26 +6,23 @@ import uuid
from tornado import gen, testing, web from tornado import gen, testing, web
import mock import mock
from sprockets.mixins import metrics from sprockets.mixins.metrics import influxdb, statsd
from sprockets.mixins.metrics.testing import ( from sprockets.mixins.metrics.testing import FakeInfluxHandler, FakeStatsdServer
FakeInfluxHandler, FakeStatsdServer)
import examples.influxdb import examples.influxdb
import examples.statsd import examples.statsd
class CounterBumper(metrics.StatsdMixin, web.RequestHandler): class CounterBumper(statsd.StatsdMixin, web.RequestHandler):
@gen.coroutine @gen.coroutine
def get(self, counter, time): def get(self, counter, value):
path = counter.split('.') with self.execution_timer(*counter.split('.')):
with self.execution_timer(*path): yield gen.sleep(float(value))
yield gen.sleep(float(time))
self.set_status(204) self.set_status(204)
self.finish() self.finish()
def post(self, counter, amount): def post(self, counter, amount):
path = counter.split('.') self.increase_counter(*counter.split('.'), amount=int(amount))
self.increase_counter(*path, amount=int(amount))
self.set_status(204) self.set_status(204)
@ -48,7 +45,7 @@ class StatsdMethodTimingTests(testing.AsyncHTTPTestCase):
self.application = None self.application = None
super(StatsdMethodTimingTests, self).setUp() super(StatsdMethodTimingTests, self).setUp()
self.statsd = FakeStatsdServer(self.io_loop) self.statsd = FakeStatsdServer(self.io_loop)
self.application.settings[metrics.StatsdMixin.SETTINGS_KEY] = { self.application.settings[statsd.SETTINGS_KEY] = {
'host': self.statsd.sockaddr[0], 'host': self.statsd.sockaddr[0],
'port': self.statsd.sockaddr[1], 'port': self.statsd.sockaddr[1],
'namespace': 'testing', 'namespace': 'testing',
@ -60,7 +57,7 @@ class StatsdMethodTimingTests(testing.AsyncHTTPTestCase):
@property @property
def settings(self): def settings(self):
return self.application.settings[metrics.StatsdMixin.SETTINGS_KEY] return self.application.settings[statsd.SETTINGS_KEY]
def test_that_http_method_call_is_recorded(self): def test_that_http_method_call_is_recorded(self):
response = self.fetch('/') response = self.fetch('/')
@ -120,23 +117,28 @@ class InfluxDbTests(testing.AsyncHTTPTestCase):
web.url(r'/', examples.influxdb.SimpleHandler), web.url(r'/', examples.influxdb.SimpleHandler),
web.url(r'/write', FakeInfluxHandler), web.url(r'/write', FakeInfluxHandler),
]) ])
influxdb.install(self.application, **{'database': 'requests',
'submission_interval': 1,
'url': self.get_url('/write')})
self.application.influx_db = {}
return self.application return self.application
def setUp(self): def setUp(self):
self.application = None self.application = None
super(InfluxDbTests, self).setUp() super(InfluxDbTests, self).setUp()
self.application.settings[metrics.InfluxDBMixin.SETTINGS_KEY] = { self.application.settings[influxdb.SETTINGS_KEY] = {
'measurement': 'my-service', 'measurement': 'my-service'
'write_url': self.get_url('/write'),
'database': 'requests',
'max_buffer_length': 0
} }
logging.getLogger(FakeInfluxHandler.__module__).setLevel(logging.DEBUG) logging.getLogger(FakeInfluxHandler.__module__).setLevel(logging.DEBUG)
@gen.coroutine
def tearDown(self):
yield influxdb.shutdown(self.application)
super(InfluxDbTests, self).tearDown()
@property @property
def influx_messages(self): def influx_messages(self):
return FakeInfluxHandler.get_messages(self.application, return FakeInfluxHandler.get_messages(self.application, self)
'requests', self)
def test_that_http_method_call_details_are_recorded(self): def test_that_http_method_call_details_are_recorded(self):
start = int(time.time()) start = int(time.time())
@ -149,7 +151,7 @@ class InfluxDbTests(testing.AsyncHTTPTestCase):
self.assertEqual(tag_dict['handler'], self.assertEqual(tag_dict['handler'],
'examples.influxdb.SimpleHandler') 'examples.influxdb.SimpleHandler')
self.assertEqual(tag_dict['method'], 'GET') self.assertEqual(tag_dict['method'], 'GET')
self.assertEqual(tag_dict['host'], socket.gethostname()) self.assertEqual(tag_dict['hostname'], socket.gethostname())
self.assertEqual(tag_dict['status_code'], '204') self.assertEqual(tag_dict['status_code'], '204')
value_dict = dict(a.split('=') for a in fields.split(',')) value_dict = dict(a.split('=') for a in fields.split(','))
@ -190,7 +192,7 @@ class InfluxDbTests(testing.AsyncHTTPTestCase):
list(self.application.influx_db['requests']))) list(self.application.influx_db['requests'])))
def test_that_cached_db_connection_is_used(self): def test_that_cached_db_connection_is_used(self):
cfg = self.application.settings[metrics.InfluxDBMixin.SETTINGS_KEY] cfg = self.application.settings[influxdb.SETTINGS_KEY]
conn = mock.Mock() conn = mock.Mock()
cfg['db_connection'] = conn cfg['db_connection'] = conn
response = self.fetch('/') response = self.fetch('/')
@ -211,68 +213,9 @@ class InfluxDbTests(testing.AsyncHTTPTestCase):
self.fail('Expected to find "request" metric in {!r}'.format( self.fail('Expected to find "request" metric in {!r}'.format(
list(self.application.influx_db['requests']))) list(self.application.influx_db['requests'])))
def test_metrics_with_buffer_flush_on_max_time(self):
max_buffer_time = 1
self.application.settings[metrics.InfluxDBMixin.SETTINGS_KEY] = {
'measurement': 'my-service',
'write_url': self.get_url('/write'),
'database': 'requests',
'max_buffer_time': max_buffer_time
}
# 2 requests
response = self.fetch('/')
self.assertEqual(response.code, 204)
time.sleep(max_buffer_time+1)
response = self.fetch('/')
self.assertEqual(response.code, 204)
self.assertEqual(2, len(self.influx_messages))
for key, fields, timestamp in self.influx_messages:
if key.startswith('my-service,'):
value_dict = dict(a.split('=') for a in fields.split(','))
self.assertEqual(int(value_dict['slept']), 42)
break
else:
self.fail('Expected to find "request" metric in {!r}'.format(
list(self.application.influx_db['requests'])))
def test_metrics_with_buffer_flush_on_max_length(self):
max_buffer_length = 2
self.application.settings[metrics.InfluxDBMixin.SETTINGS_KEY] = {
'measurement': 'my-service',
'write_url': self.get_url('/write'),
'database': 'requests',
'max_buffer_time': 100,
'max_buffer_length': max_buffer_length
}
# 3 requests with buffer length = 2,
# so only 2 metrics should be flushed
response = self.fetch('/')
self.assertEqual(response.code, 204)
response = self.fetch('/')
self.assertEqual(response.code, 204)
response = self.fetch('/')
self.assertEqual(response.code, 204)
self.assertEqual(max_buffer_length, len(self.influx_messages))
for key, fields, timestamp in self.influx_messages:
if key.startswith('my-service,'):
value_dict = dict(a.split('=') for a in fields.split(','))
self.assertEqual(int(value_dict['slept']), 42)
break
else:
self.fail('Expected to find "request" metric in {!r}'.format(
list(self.application.influx_db['requests'])))
def test_metrics_with_buffer_not_flush(self): def test_metrics_with_buffer_not_flush(self):
self.application.settings[metrics.InfluxDBMixin.SETTINGS_KEY] = { self.application.settings[influxdb] = {
'measurement': 'my-service', 'measurement': 'my-service'
'write_url': self.get_url('/write'),
'database': 'requests',
'max_buffer_time': 100,
'max_buffer_length': 100
} }
# 2 requests # 2 requests