Add support for disabling tornado connector.

This commit is contained in:
Dave Shawley 2021-04-12 08:08:07 -04:00
parent a2695e4d1a
commit 235c7bde3c
No known key found for this signature in database
GPG key ID: F41A8A99298F8EED
7 changed files with 173 additions and 84 deletions

View file

@ -1,5 +1,7 @@
`0.0.1`_ (08-Apr-2021) :tag:`Next release <0.0.1...main>`
---------------------- ----------------------------------
- support for sending counters & timers to statsd over a TCP or UDP socket - Added :envvar:`STATSD_ENABLED` environment variable to disable the Tornado integration
.. _0.0.1: https://github.com/sprockets/sprockets-statsd/compare/832f8af7...0.0.1 :tag:`0.0.1 <832f8af7...0.0.1>` (08-Apr-2021)
---------------------------------------------
- Simple support for sending counters & timers to statsd over a TCP or UDP socket

View file

@ -16,5 +16,11 @@ intersphinx_mapping = {
'tornado': ('https://www.tornadoweb.org/en/branch6.0/', None), 'tornado': ('https://www.tornadoweb.org/en/branch6.0/', None),
} }
# https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html
extensions.append('sphinx.ext.extlinks')
extlinks = {
'tag': ("https://github.com/sprockets/sprockets-statsd/compare/%s", "%s"),
}
# https://pypi.org/project/sphinx-autodoc-typehints/ # https://pypi.org/project/sphinx-autodoc-typehints/
extensions.append('sphinx_autodoc_typehints') extensions.append('sphinx_autodoc_typehints')

View file

@ -3,10 +3,10 @@ sprockets-statsd
.. include:: ../README.rst .. include:: ../README.rst
Configuration Tornado configuration
============= =====================
The statsd connection is configured by the ``statsd`` application settings key. The default values can be set by The Tornado statsd connection is configured by the ``statsd`` application settings key. The default values can be set
the following environment variables. by the following environment variables.
.. envvar:: STATSD_HOST .. envvar:: STATSD_HOST
@ -26,6 +26,17 @@ the following environment variables.
The IP protocol to use when connecting to the StatsD server. You can specify either "tcp" or "udp". The The IP protocol to use when connecting to the StatsD server. You can specify either "tcp" or "udp". The
default is "tcp" if it not not configured. default is "tcp" if it not not configured.
.. envvar:: STATSD_ENABLED
Define this variable and set it to a *falsy* value to **disable** the Tornado integration. If you omit
this variable, then the connector is enabled. The following values are considered *truthy*:
- non-zero integer
- case-insensitive match of ``yes``, ``true``, ``t``, or ``on``
All other values are considered *falsy*. You only want to define this environment variables when you
want to explicitly disable an otherwise installed and configured connection.
If you are using the Tornado helper clases, then you can fine tune the metric payloads and the connector by If you are using the Tornado helper clases, then you can fine tune the metric payloads and the connector by
setting additional values in the ``statsd`` key of :attr:`tornado.web.Application.settings`. See the setting additional values in the ``statsd`` key of :attr:`tornado.web.Application.settings`. See the
:class:`sprockets_statsd.tornado.Application` class documentation for a description of the supported settings. :class:`sprockets_statsd.tornado.Application` class documentation for a description of the supported settings.
@ -46,6 +57,9 @@ Tornado helpers
Internals Internals
--------- ---------
.. autoclass:: sprockets_statsd.statsd.AbstractConnector
:members:
.. autoclass:: sprockets_statsd.statsd.Processor .. autoclass:: sprockets_statsd.statsd.Processor
:members: :members:

View file

@ -63,6 +63,7 @@ warning_is_error = 1
[coverage:report] [coverage:report]
exclude_lines = exclude_lines =
pass
pragma: no cover pragma: no cover
raise NotImplementedError raise NotImplementedError
fail_under = 100 fail_under = 100

View file

@ -4,7 +4,71 @@ import socket
import typing import typing
class Connector: class AbstractConnector:
"""StatsD connector that does not send metrics or connect.
Use this connector when you want to maintain the application
interface without doing any real work.
"""
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
def inject_metric(self, path: str, value: str, type_code: str) -> None:
pass
def incr(self, path: str, value: int = 1) -> None:
"""Increment a counter metric.
:param path: counter to increment
:param value: amount to increment the counter by
"""
self.inject_metric(f'counters.{path}', str(value), 'c')
def decr(self, path: str, value: int = 1) -> None:
"""Decrement a counter metric.
:param path: counter to decrement
:param value: amount to decrement the counter by
This is equivalent to ``self.incr(path, -value)``.
"""
self.inject_metric(f'counters.{path}', str(-value), 'c')
def gauge(self, path: str, value: int, delta: bool = False) -> None:
"""Manipulate a gauge metric.
:param path: gauge to adjust
:param value: value to send
:param delta: is this an adjustment of the gauge?
If the `delta` parameter is ``False`` (or omitted), then
`value` is the new value to set the gauge to. Otherwise,
`value` is an adjustment for the current gauge.
"""
if delta:
payload = f'{value:+d}'
else:
payload = str(value)
self.inject_metric(f'gauges.{path}', payload, 'g')
def timing(self, path: str, seconds: float) -> None:
"""Send a timer metric.
:param path: timer to append a value to
:param seconds: number of **seconds** to record
"""
self.inject_metric(f'timers.{path}', str(seconds * 1000.0), 'ms')
class Connector(AbstractConnector):
"""Sends metrics to a statsd server. """Sends metrics to a statsd server.
:param host: statsd server to send metrics to :param host: statsd server to send metrics to
@ -69,6 +133,7 @@ class Connector:
*, *,
prefix: str = '', prefix: str = '',
**kwargs: typing.Any) -> None: **kwargs: typing.Any) -> None:
super().__init__()
self.logger = logging.getLogger(__package__).getChild('Connector') self.logger = logging.getLogger(__package__).getChild('Connector')
self.prefix = f'{prefix}.' if prefix else prefix self.prefix = f'{prefix}.' if prefix else prefix
self.processor = Processor(host=host, port=port, **kwargs) self.processor = Processor(host=host, port=port, **kwargs)
@ -95,53 +160,6 @@ class Connector:
""" """
await self.processor.stop() await self.processor.stop()
def incr(self, path: str, value: int = 1) -> None:
"""Increment a counter metric.
:param path: counter to increment
:param value: amount to increment the counter by
"""
self.inject_metric(f'counters.{path}', str(value), 'c')
def decr(self, path: str, value: int = 1) -> None:
"""Decrement a counter metric.
:param path: counter to decrement
:param value: amount to decrement the counter by
This is equivalent to ``self.incr(path, -value)``.
"""
self.inject_metric(f'counters.{path}', str(-value), 'c')
def gauge(self, path: str, value: int, delta: bool = False) -> None:
"""Manipulate a gauge metric.
:param path: gauge to adjust
:param value: value to send
:param delta: is this an adjustment of the gauge?
If the `delta` parameter is ``False`` (or omitted), then
`value` is the new value to set the gauge to. Otherwise,
`value` is an adjustment for the current gauge.
"""
if delta:
payload = f'{value:+d}'
else:
payload = str(value)
self.inject_metric(f'gauges.{path}', payload, 'g')
def timing(self, path: str, seconds: float) -> None:
"""Send a timer metric.
:param path: timer to append a value to
:param seconds: number of **seconds** to record
"""
self.inject_metric(f'timers.{path}', str(seconds * 1000.0), 'ms')
def inject_metric(self, path: str, value: str, type_code: str) -> None: def inject_metric(self, path: str, value: str, type_code: str) -> None:
"""Send a metric to the statsd server. """Send a metric to the statsd server.

View file

@ -13,7 +13,7 @@ class Application(web.Application):
"""Mix this into your application to add a statsd connection. """Mix this into your application to add a statsd connection.
.. attribute:: statsd_connector .. attribute:: statsd_connector
:type: sprockets_statsd.statsd.Connector :type: sprockets_statsd.statsd.AbstractConnector
Connection to the StatsD server that is set between calls Connection to the StatsD server that is set between calls
to :meth:`.start_statsd` and :meth:`.stop_statsd`. to :meth:`.start_statsd` and :meth:`.stop_statsd`.
@ -21,6 +21,8 @@ class Application(web.Application):
This mix-in is configured by the ``statsd`` settings key. The This mix-in is configured by the ``statsd`` settings key. The
value is a dictionary with the following keys. value is a dictionary with the following keys.
+-------------------+---------------------------------------------+
| enabled | should the statsd connector be enabled? |
+-------------------+---------------------------------------------+ +-------------------+---------------------------------------------+
| host | the statsd host to send metrics to | | host | the statsd host to send metrics to |
+-------------------+---------------------------------------------+ +-------------------+---------------------------------------------+
@ -38,6 +40,13 @@ class Application(web.Application):
| | connection | | | connection |
+-------------------+---------------------------------------------+ +-------------------+---------------------------------------------+
**enabled** defaults to the :envvar:`STATSD_ENABLED` environment
variable coerced to a :class:`bool`. If this variable is not set,
then the statsd connector *WILL BE* enabled. Set this to a *falsy*
value to disable the connector. The following values are considered
*truthy*: a non-zero integer or a case-insensitive match of "on",
"t", "true", or "yes". All other values are considered *falsy*.
**host** defaults to the :envvar:`STATSD_HOST` environment variable. **host** defaults to the :envvar:`STATSD_HOST` environment variable.
If this value is not set, then the statsd connector *WILL NOT* be If this value is not set, then the statsd connector *WILL NOT* be
enabled. enabled.
@ -72,10 +81,12 @@ class Application(web.Application):
processor quickly responds to connection faults. processor quickly responds to connection faults.
""" """
statsd_connector: typing.Optional[statsd.Connector] statsd_connector: typing.Optional[statsd.AbstractConnector]
def __init__(self, *args: typing.Any, **settings: typing.Any): def __init__(self, *args: typing.Any, **settings: typing.Any):
statsd_settings = settings.setdefault('statsd', {}) statsd_settings = settings.setdefault('statsd', {})
statsd_settings.setdefault('enabled',
os.environ.get('STATSD_ENABLED', 'yes'))
statsd_settings.setdefault('host', os.environ.get('STATSD_HOST')) statsd_settings.setdefault('host', os.environ.get('STATSD_HOST'))
statsd_settings.setdefault('port', statsd_settings.setdefault('port',
os.environ.get('STATSD_PORT', '8125')) os.environ.get('STATSD_PORT', '8125'))
@ -98,6 +109,8 @@ class Application(web.Application):
super().__init__(*args, **settings) super().__init__(*args, **settings)
self.settings['statsd']['enabled'] = _parse_bool(
self.settings['statsd']['enabled'])
self.settings['statsd']['port'] = int(self.settings['statsd']['port']) self.settings['statsd']['port'] = int(self.settings['statsd']['port'])
self.statsd_connector = None self.statsd_connector = None
@ -111,17 +124,22 @@ class Application(web.Application):
""" """
if self.statsd_connector is None: if self.statsd_connector is None:
kwargs = self.settings['statsd'].copy() if not self.settings['statsd']['enabled']:
protocol = kwargs.pop('protocol', None) self.statsd_connector = statsd.AbstractConnector()
if protocol == 'tcp':
kwargs['ip_protocol'] = socket.IPPROTO_TCP
elif protocol == 'udp':
kwargs['ip_protocol'] = socket.IPPROTO_UDP
else: else:
raise RuntimeError(f'statsd configuration error: {protocol} ' kwargs = self.settings['statsd'].copy()
f'is not a valid protocol') kwargs.pop('enabled', None) # consume this one
protocol = kwargs.pop('protocol', None)
if protocol == 'tcp':
kwargs['ip_protocol'] = socket.IPPROTO_TCP
elif protocol == 'udp':
kwargs['ip_protocol'] = socket.IPPROTO_UDP
else:
raise RuntimeError(
f'statsd configuration error: {protocol} is not '
f'a valid protocol')
self.statsd_connector = statsd.Connector(**kwargs)
self.statsd_connector = statsd.Connector(**kwargs)
await self.statsd_connector.start() await self.statsd_connector.start()
async def stop_statsd(self) -> None: async def stop_statsd(self) -> None:
@ -195,3 +213,10 @@ class RequestHandler(web.RequestHandler):
self.record_timing(self.request.request_time(), self.record_timing(self.request.request_time(),
self.__class__.__name__, self.request.method, self.__class__.__name__, self.request.method,
self.get_status()) self.get_status())
def _parse_bool(value: str) -> bool:
try:
return int(value) != 0
except ValueError:
return value.lower() in {'true', 't', 'yes', 'on'}

View file

@ -6,6 +6,7 @@ import typing
from tornado import testing, web from tornado import testing, web
import sprockets_statsd.statsd
import sprockets_statsd.tornado import sprockets_statsd.tornado
from tests import helpers from tests import helpers
@ -49,21 +50,22 @@ class ApplicationTests(AsyncTestCaseWithTimeout):
else: else:
os.environ.pop(name, None) os.environ.pop(name, None)
def setenv(self, name, value): def setenv(self, **variables):
self._environ.setdefault(name, os.environ.pop(name, None)) for name, value in variables.items():
os.environ[name] = value self._environ.setdefault(name, os.environ.pop(name, None))
os.environ[name] = value
def unsetenv(self, name): def unsetenv(self, *names):
self._environ.setdefault(name, os.environ.pop(name, None)) for name in names:
self._environ.setdefault(name, os.environ.pop(name, None))
def test_statsd_setting_defaults(self): def test_statsd_setting_defaults(self):
self.unsetenv('STATSD_HOST') self.unsetenv('STATSD_ENABLED', 'STATSD_HOST', 'STATSD_PORT',
self.unsetenv('STATSD_PORT') 'STATSD_PREFIX', 'STATSD_PROTOCOL')
self.unsetenv('STATSD_PREFIX')
self.unsetenv('STATSD_PROTOCOL')
app = sprockets_statsd.tornado.Application(statsd={'prefix': ''}) app = sprockets_statsd.tornado.Application(statsd={'prefix': ''})
self.assertIn('statsd', app.settings) self.assertIn('statsd', app.settings)
self.assertTrue(app.settings['statsd']['enabled'])
self.assertIsNone(app.settings['statsd']['host'], self.assertIsNone(app.settings['statsd']['host'],
'default host value should be None') 'default host value should be None')
self.assertEqual(8125, app.settings['statsd']['port']) self.assertEqual(8125, app.settings['statsd']['port'])
@ -71,13 +73,15 @@ class ApplicationTests(AsyncTestCaseWithTimeout):
self.assertEqual('tcp', app.settings['statsd']['protocol']) self.assertEqual('tcp', app.settings['statsd']['protocol'])
def test_that_statsd_settings_read_from_environment(self): def test_that_statsd_settings_read_from_environment(self):
self.setenv('STATSD_HOST', 'statsd') self.setenv(STATSD_ENABLED='no',
self.setenv('STATSD_PORT', '5218') STATSD_HOST='statsd',
self.setenv('STATSD_PREFIX', 'my-service') STATSD_PORT='5218',
self.setenv('STATSD_PROTOCOL', 'udp') STATSD_PREFIX='my-service',
STATSD_PROTOCOL='udp')
app = sprockets_statsd.tornado.Application() app = sprockets_statsd.tornado.Application()
self.assertIn('statsd', app.settings) self.assertIn('statsd', app.settings)
self.assertFalse(app.settings['statsd']['enabled'])
self.assertEqual('statsd', app.settings['statsd']['host']) self.assertEqual('statsd', app.settings['statsd']['host'])
self.assertEqual(5218, app.settings['statsd']['port']) self.assertEqual(5218, app.settings['statsd']['port'])
self.assertEqual('my-service', app.settings['statsd']['prefix']) self.assertEqual('my-service', app.settings['statsd']['prefix'])
@ -99,18 +103,22 @@ class ApplicationTests(AsyncTestCaseWithTimeout):
app.settings['statsd']['prefix']) app.settings['statsd']['prefix'])
def test_overridden_settings(self): def test_overridden_settings(self):
self.setenv('STATSD_HOST', 'statsd') self.setenv(STATSD_ENABLED='0',
self.setenv('STATSD_PORT', '9999') STATSD_HOST='statsd',
self.setenv('STATSD_PREFIX', 'service') STATSD_PORT='9999',
self.setenv('STATSD_PROTOCOL', 'tcp') STATSD_PREFIX='service',
STATSD_PROTOCOL='tcp')
app = sprockets_statsd.tornado.Application( app = sprockets_statsd.tornado.Application(
statsd={ statsd={
'enabled': 'true',
'host': 'statsd.example.com', 'host': 'statsd.example.com',
'port': 5218, 'port': 5218,
'prefix': 'myapp', 'prefix': 'myapp',
'protocol': 'udp', 'protocol': 'udp',
}) })
self.assertEqual('statsd.example.com', app.settings['statsd']['host']) self.assertEqual('statsd.example.com', app.settings['statsd']['host'])
self.assertTrue(app.settings['statsd']['enabled'])
self.assertEqual(5218, app.settings['statsd']['port']) self.assertEqual(5218, app.settings['statsd']['port'])
self.assertEqual('myapp', app.settings['statsd']['prefix']) self.assertEqual('myapp', app.settings['statsd']['prefix'])
self.assertEqual('udp', app.settings['statsd']['protocol']) self.assertEqual('udp', app.settings['statsd']['protocol'])
@ -231,6 +239,21 @@ class ApplicationTests(AsyncTestCaseWithTimeout):
self.assertEqual(app.statsd_connector.prefix, '') self.assertEqual(app.statsd_connector.prefix, '')
self.run_coroutine(app.stop_statsd()) self.run_coroutine(app.stop_statsd())
def test_disabling_statsd_connector(self):
app = sprockets_statsd.tornado.Application(
environment='development',
service='my-service',
version='1.0.0',
statsd={
'enabled': 'no',
'host': 'localhost',
},
)
self.run_coroutine(app.start_statsd())
self.assertIsNotNone(app.statsd_connector)
self.assertIsInstance(app.statsd_connector,
sprockets_statsd.statsd.AbstractConnector)
class RequestHandlerTests(AsyncTestCaseWithTimeout, testing.AsyncHTTPTestCase): class RequestHandlerTests(AsyncTestCaseWithTimeout, testing.AsyncHTTPTestCase):
def setUp(self): def setUp(self):