mirror of
https://github.com/sprockets/sprockets.mixins.metrics.git
synced 2024-12-01 03:00:23 +00:00
362 lines
13 KiB
Python
362 lines
13 KiB
Python
import contextlib
|
|
import logging
|
|
import os
|
|
import socket
|
|
import time
|
|
|
|
from tornado import concurrent, httpclient, ioloop
|
|
|
|
from sprockets.mixins.metrics import __version__
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
SETTINGS_KEY = 'sprockets.mixins.metrics.influxdb'
|
|
"""``self.settings`` key that configures this mix-in."""
|
|
|
|
_USER_AGENT = 'sprockets.mixins.metrics/v{}'.format(__version__)
|
|
|
|
|
|
class InfluxDBMixin(object):
|
|
"""Mix this class in to record measurements to a InfluxDB server."""
|
|
|
|
def __init__(self, application, request, **kwargs):
|
|
self.__metrics = []
|
|
self.__tags = {
|
|
'handler': '{}.{}'.format(self.__module__,
|
|
self.__class__.__name__),
|
|
'method': request.method,
|
|
}
|
|
|
|
# Call to super().__init__() needs to be *AFTER* we create our
|
|
# properties since it calls initialize() which may want to call
|
|
# methods like ``set_metric_tag``
|
|
super(InfluxDBMixin, self).__init__(application, request, **kwargs)
|
|
|
|
def set_metric_tag(self, tag, value):
|
|
"""
|
|
Add a tag to the measurement key.
|
|
|
|
:param str tag: name of the tag to set
|
|
:param str value: value to assign
|
|
|
|
This will overwrite the current value assigned to a tag
|
|
if one exists.
|
|
|
|
"""
|
|
self.__tags[tag] = value
|
|
|
|
def record_timing(self, duration, *path):
|
|
"""
|
|
Record a timing.
|
|
|
|
:param float duration: timing to record in seconds
|
|
:param path: elements of the metric path to record
|
|
|
|
A timing is a named duration value.
|
|
|
|
"""
|
|
self.__metrics.append('{}={}'.format(
|
|
self.application.influxdb.escape_str('.'.join(path)), duration))
|
|
|
|
def increase_counter(self, *path, **kwargs):
|
|
"""
|
|
Increase a counter.
|
|
|
|
:param path: elements of the path to record
|
|
:keyword int amount: value to record. If omitted, the counter
|
|
value is one.
|
|
|
|
Counters are simply values that are summed in a query.
|
|
|
|
"""
|
|
self.__metrics.append('{}={}'.format(
|
|
self.application.influxdb.escape_str('.'.join(path)),
|
|
kwargs.get('amount', 1)))
|
|
|
|
@contextlib.contextmanager
|
|
def execution_timer(self, *path):
|
|
"""
|
|
Record the time it takes to run an arbitrary code block.
|
|
|
|
:param path: elements of the metric path to record
|
|
|
|
This method returns a context manager that records the amount
|
|
of time spent inside of the context and records a value
|
|
named `path` using (:meth:`record_timing`).
|
|
|
|
"""
|
|
start = time.time()
|
|
try:
|
|
yield
|
|
finally:
|
|
self.record_timing(max(time.time(), start) - start, *path)
|
|
|
|
def on_finish(self):
|
|
super(InfluxDBMixin, self).on_finish()
|
|
self.set_metric_tag('status_code', self.get_status())
|
|
self.record_timing(self.request.request_time(), 'duration')
|
|
self.application.influxdb.submit(
|
|
self.settings[SETTINGS_KEY]['measurement'],
|
|
self.__tags,
|
|
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.
|
|
:param str auth_username: Optional username for authenticated requests.
|
|
:param str auth_password: Optional password for authenticated requests.
|
|
|
|
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,
|
|
auth_username=None, auth_password=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 {}
|
|
|
|
# Configure the default
|
|
defaults = {'user_agent': _USER_AGENT}
|
|
if auth_username and auth_password:
|
|
LOGGER.debug('Adding authentication info to defaults (%s)',
|
|
auth_username)
|
|
defaults['auth_username'] = auth_username
|
|
defaults['auth_password'] = auth_password
|
|
|
|
self._client = httpclient.AsyncHTTPClient(force_instance=True,
|
|
defaults=defaults,
|
|
io_loop=self._io_loop)
|
|
|
|
# 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.
|
|
- **auth_username** A username to use for InfluxDB authentication
|
|
- **auth_password** A password to use for InfluxDB authentication
|
|
|
|
If ``auth_password`` is specified as an environment variable, it will be
|
|
masked in the Python process.
|
|
|
|
"""
|
|
if getattr(application, 'influxdb', None) is not None:
|
|
LOGGER.warning('InfluxDBCollector is already installed')
|
|
return False
|
|
|
|
# Get config values
|
|
url = '{}://{}:{}/write'.format(os.environ.get('INFLUX_SCHEME', 'http'),
|
|
os.environ.get('INFLUX_HOST', 'localhost'),
|
|
os.environ.get('INFLUX_PORT', 8086))
|
|
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
|
|
|
|
# Check if auth variables are set as env vars and set them if so
|
|
if os.environ.get('INFLUX_USER'):
|
|
kwargs.setdefault('auth_username', os.environ.get('INFLUX_USER'))
|
|
kwargs.setdefault('auth_password',
|
|
os.environ.get('INFLUX_PASSWORD', ''))
|
|
|
|
# Don't leave the environment variable out there with the password
|
|
if os.environ.get('INFLUX_PASSWORD'):
|
|
os.environ['INFLUX_PASSWORD'] = 'X' * len(kwargs['auth_password'])
|
|
|
|
# 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()
|