Merge pull request #499 from yuvipanda/statsd

Emit metrics via statsd
This commit is contained in:
Min RK
2016-04-05 09:23:20 -07:00
5 changed files with 70 additions and 2 deletions

View File

@@ -12,6 +12,7 @@ import signal
import socket import socket
import sys import sys
import threading import threading
import statsd
from datetime import datetime from datetime import datetime
from distutils.version import LooseVersion as V from distutils.version import LooseVersion as V
from getpass import getuser from getpass import getuser
@@ -59,6 +60,10 @@ from .utils import (
from .auth import Authenticator, PAMAuthenticator from .auth import Authenticator, PAMAuthenticator
from .spawner import Spawner, LocalProcessSpawner from .spawner import Spawner, LocalProcessSpawner
# For faking stats
from .emptyclass import EmptyClass
common_aliases = { common_aliases = {
'log-level': 'Application.log_level', 'log-level': 'Application.log_level',
'f': 'JupyterHub.config_file', 'f': 'JupyterHub.config_file',
@@ -492,6 +497,22 @@ class JupyterHub(Application):
help="Extra log handlers to set on JupyterHub logger", help="Extra log handlers to set on JupyterHub logger",
).tag(config=True) ).tag(config=True)
@property
def statsd(self):
if hasattr(self, '_statsd'):
return self._statsd
if self.statsd_host:
self._statsd = statsd.StatsClient(
self.statsd_host,
self.statsd_port,
self.statsd_prefix
)
return self._statsd
else:
# return an empty mock object!
self._statsd = EmptyClass()
return self._statsd
def init_logging(self): def init_logging(self):
# This prevents double log messages because tornado use a root logger that # This prevents double log messages because tornado use a root logger that
# self.log is a child of. The logging module dipatches log messages to a log # self.log is a child of. The logging module dipatches log messages to a log
@@ -991,6 +1012,7 @@ class JupyterHub(Application):
version_hash=version_hash, version_hash=version_hash,
subdomain_host=subdomain_host, subdomain_host=subdomain_host,
domain=domain, domain=domain,
statsd=self.statsd,
) )
# allow configured settings to have priority # allow configured settings to have priority
settings.update(self.tornado_settings) settings.update(self.tornado_settings)
@@ -1112,6 +1134,8 @@ class JupyterHub(Application):
def update_last_activity(self): def update_last_activity(self):
"""Update User.last_activity timestamps from the proxy""" """Update User.last_activity timestamps from the proxy"""
routes = yield self.proxy.get_routes() routes = yield self.proxy.get_routes()
users_count = 0
active_users_count = 0
for prefix, route in routes.items(): for prefix, route in routes.items():
if 'user' not in route: if 'user' not in route:
# not a user route, ignore it # not a user route, ignore it
@@ -1125,6 +1149,12 @@ class JupyterHub(Application):
except Exception: except Exception:
dt = datetime.strptime(route['last_activity'], ISO8601_s) dt = datetime.strptime(route['last_activity'], ISO8601_s)
user.last_activity = max(user.last_activity, dt) user.last_activity = max(user.last_activity, dt)
# FIXME: Make this configurable duration. 30 minutes for now!
if (datetime.now() - user.last_activity).total_seconds() < 30 * 60:
active_users_count += 1
users_count += 1
self.statsd.gauge('users.running', users_count)
self.statsd.gauge('users.active', active_users_count)
self.db.commit() self.db.commit()
yield self.proxy.check_routes(self.users, routes) yield self.proxy.check_routes(self.users, routes)

13
jupyterhub/emptyclass.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Simple empty class that returns itself for all functions called on it.
This allows us to call any method of any name on this, and it'll return another
instance of itself that'll allow any method to be called on it.
Primarily used to mock out the statsd client when statsd is not being used
"""
class EmptyClass:
def empty_function(self, *args, **kwargs):
return self
def __getattr__(self, attr):
return self.empty_function

View File

@@ -75,6 +75,10 @@ class BaseHandler(RequestHandler):
def proxy(self): def proxy(self):
return self.settings['proxy'] return self.settings['proxy']
@property
def statsd(self):
return self.settings['statsd']
@property @property
def authenticator(self): def authenticator(self):
return self.settings.get('authenticator', None) return self.settings.get('authenticator', None)
@@ -300,6 +304,7 @@ class BaseHandler(RequestHandler):
return return
toc = IOLoop.current().time() toc = IOLoop.current().time()
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic) self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
self.statsd.timing('spawner.success', (toc - tic) * 1000)
yield self.proxy.add_user(user) yield self.proxy.add_user(user)
user.spawner.add_poll_callback(self.user_stopped, user) user.spawner.add_poll_callback(self.user_stopped, user)
@@ -323,6 +328,8 @@ class BaseHandler(RequestHandler):
# schedule finish for when the user finishes spawning # schedule finish for when the user finishes spawning
IOLoop.current().add_future(f, finish_user_spawn) IOLoop.current().add_future(f, finish_user_spawn)
else: else:
toc = IOLoop.current().time()
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status) raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
else: else:
yield finish_user_spawn() yield finish_user_spawn()
@@ -471,6 +478,7 @@ class UserSpawnHandler(BaseHandler):
if current_user.spawner: if current_user.spawner:
if current_user.spawn_pending: if current_user.spawn_pending:
# spawn has started, but not finished # spawn has started, but not finished
self.statsd.incr('redirects.user_spawn_pending', 1)
html = self.render_template("spawn_pending.html", user=current_user) html = self.render_template("spawn_pending.html", user=current_user)
self.finish(html) self.finish(html)
return return
@@ -490,12 +498,15 @@ class UserSpawnHandler(BaseHandler):
if self.subdomain_host: if self.subdomain_host:
target = current_user.host + target target = current_user.host + target
self.redirect(target) self.redirect(target)
self.statsd.incr('redirects.user_after_login')
elif current_user: elif current_user:
# logged in as a different user, redirect # logged in as a different user, redirect
self.statsd.incr('redirects.user_to_user', 1)
target = url_path_join(current_user.url, user_path or '') target = url_path_join(current_user.url, user_path or '')
self.redirect(target) self.redirect(target)
else: else:
# not logged in, clear any cookies and reload # not logged in, clear any cookies and reload
self.statsd.incr('redirects.user_to_login', 1)
self.clear_login_cookie() self.clear_login_cookie()
self.redirect(url_concat( self.redirect(url_concat(
self.settings['login_url'], self.settings['login_url'],
@@ -508,8 +519,12 @@ class CSPReportHandler(BaseHandler):
@web.authenticated @web.authenticated
def post(self): def post(self):
'''Log a content security policy violation report''' '''Log a content security policy violation report'''
self.log.warning("Content security violation: %s", self.log.warning(
self.request.body.decode('utf8', 'replace')) "Content security violation: %s",
self.request.body.decode('utf8', 'replace')
)
# Report it to statsd as well
self.statsd.incr('csp_report')
default_handlers = [ default_handlers = [
(r'/user/([^/]+)(/.*)?', UserSpawnHandler), (r'/user/([^/]+)(/.*)?', UserSpawnHandler),

View File

@@ -20,6 +20,7 @@ class LogoutHandler(BaseHandler):
self.clear_login_cookie(name) self.clear_login_cookie(name)
user.other_user_cookies = set([]) user.other_user_cookies = set([])
self.redirect(self.hub.server.base_url, permanent=False) self.redirect(self.hub.server.base_url, permanent=False)
self.statsd.incr('logout')
class LoginHandler(BaseHandler): class LoginHandler(BaseHandler):
@@ -35,6 +36,7 @@ class LoginHandler(BaseHandler):
) )
def get(self): def get(self):
self.statsd.incr('login.request')
next_url = self.get_argument('next', '') next_url = self.get_argument('next', '')
if not next_url.startswith('/'): if not next_url.startswith('/'):
# disallow non-absolute next URLs (e.g. full URLs) # disallow non-absolute next URLs (e.g. full URLs)
@@ -61,8 +63,13 @@ class LoginHandler(BaseHandler):
for arg in self.request.arguments: for arg in self.request.arguments:
data[arg] = self.get_argument(arg) data[arg] = self.get_argument(arg)
auth_timer = self.statsd.timer('login.authenticate').start()
username = yield self.authenticate(data) username = yield self.authenticate(data)
auth_timer.stop(send=False)
if username: if username:
self.statsd.incr('login.success')
self.statsd.timing('login.authenticate.success', auth_timer.ms)
user = self.user_from_username(username) user = self.user_from_username(username)
already_running = False already_running = False
if user.spawner: if user.spawner:
@@ -78,6 +85,8 @@ class LoginHandler(BaseHandler):
self.redirect(next_url) self.redirect(next_url)
self.log.info("User logged in: %s", username) self.log.info("User logged in: %s", username)
else: else:
self.statsd.incr('login.failure')
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
self.log.debug("Failed login for %s", data.get('username', 'unknown user')) self.log.debug("Failed login for %s", data.get('username', 'unknown user'))
html = self._render( html = self._render(
login_error='Invalid username or password', login_error='Invalid username or password',

View File

@@ -2,5 +2,6 @@ traitlets>=4.1
tornado>=4.1 tornado>=4.1
jinja2 jinja2
pamela pamela
statsd
sqlalchemy>=1.0 sqlalchemy>=1.0
requests requests