Flask and StatsD revisited

A couple of months ago I wrote about automatically emitting statsd metrics for Flask views using a Werkzeug middleware.

There was one rather large caveat though: how I dealt with dynamic URL parameters. Optimally I would have matched the incoming request against the available URL rules or routes in Flask, and used the URL rule metadata to generate a stable metric name. To save time I relied upon the fact that all dynamic URL parts in our API were at the time UUID’s.

Since then I’ve found the time to update it and use the URL map. So here’s the updated gist:

import inspect
from functools import partial
import re
import resource
import time
def get_cpu_time():
resources = resource.getrusage(resource.RUSAGE_SELF)
# add up user time (ru_utime) and system time (ru_stime)
return resources[0] + resources[1]
class StatsD(object):
'''
A statsd wrapper object which automatically adds extra tags.
'''
overloaded_methods = ['timing', 'count', 'gauge', 'increment']
def __init__(self, statsd, tags=None):
self.statsd = statsd
for name, method in inspect.getmembers(statsd, predicate=inspect.ismethod):
if name in self.overloaded_methods:
setattr(self, name, partial(self.wrapper, _orig=method))
else:
setattr(self, name, method)
if tags is None:
tags = []
self.tags = tags
def _merge_tags(self, tags):
return [tag for tag in set(self.tags + tags)]
def wrapper(self, *args, **kwargs):
method = kwargs['_orig']
del kwargs['_orig']
if 'tags' in kwargs:
kwargs['tags'] = self._merge_tags(kwargs['tags'])
elif self.tags:
kwargs['tags'] = self.tags
return method(*args, **kwargs)
class TimingStats(object):
def __init__(self, statsd, name=None, sample_rate=1, tags=None):
self.statsd = statsd
if tags is None:
tags = []
self.tags = tags
self.name = name
self.sample_rate = sample_rate
def __enter__(self):
self.start_time = time.time()
self.start_cpu_time = get_cpu_time()
return self
@property
def time(self):
return self.end_time - self.start_time
@property
def cpu_time(self):
return self.end_cpu_time - self.start_cpu_time
def __exit__(self, exc_type, exc_value, traceback):
self.end_time = time.time()
self.end_cpu_time = get_cpu_time()
self.statsd.timing(self.name,
self.time,
sample_rate=self.sample_rate,
tags=self.tags)
self.statsd.timing('{}.cpu'.format(self.name),
self.cpu_time,
sample_rate=self.sample_rate,
tags=self.tags)
class StatsdMiddleware(object):
def __init__(self, app, statsd, prefix=None):
self.app = app
self.wsgi_app = app.wsgi_app
self.statsd = statsd
self.map = app.url_map.bind('')
self.prefix = prefix
def _metric_name(self, path, method):
path = path.split('?')[0]
match = self.map.match(path, method)
return '{}{}'.format(self.prefix and '{}.'.format(self.prefix) or '', match[0])
def __call__(self, environ, start_response):
def start_response_wrapper(*args, **kwargs):
status = args[0].split(' ')[0]
self.status = status
return start_response(*args, **kwargs)
try:
metric_name = self._metric_name(environ['PATH_INFO'], environ['REQUEST_METHOD'])
with TimingStats(self.statsd, metric_name) as metric:
response = self.wsgi_app(environ, start_response_wrapper)
metric.tags.append('http_status_code:{}'.format(self.status))
metric.tags.append('http_method:{}'.format(environ['REQUEST_METHOD']))
self.statsd.timing('{}.api.request'.format(self.app.name), metric.time, tags=metric.tags)
self.statsd.timing('{}.api.request.cpu'.format(self.app.name), metric.cpu_time, tags=metric.tags)
except Exception:
# this should only happen if the URL is not supported by our app,
# in which case we'll just let the app handle the 404 normally
return self.wsgi_app(environ, start_response_wrapper)
return response

I’ve also updated the original Flask and StatsD post itself, and the example github repo.

On a humorous note, let me tell you it definitely took me a few seconds to realize why I was suddenly seeing phpmyadmin.<...> metrics in the Datadog metric explorer. Then I remembered the technical debt incurred by my initial approach. It’s now been a little while since we fixed it, and it sure feels good to share the improved solution :-)