diff --git a/doc/admin/config.rst b/doc/admin/config.rst index 9bac015770..9286042736 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -174,6 +174,20 @@ Example:: .. WARNING:: Never set this to ``True`` in production! +Metrics +------- + +If you want to fetch internally collected prometheus-style metrics you need to configure the credentials for the +metrics endpoint and enable it:: + + [metrics] + enabled=true + user=your_user + passphrase=mysupersecretpassphrase + +Currently, metrics-collection requires a redis server to be available. + + Memcached --------- diff --git a/src/pretix/base/metrics.py b/src/pretix/base/metrics.py new file mode 100755 index 0000000000..21efe96192 --- /dev/null +++ b/src/pretix/base/metrics.py @@ -0,0 +1,150 @@ +from django.conf import settings + +if settings.HAS_REDIS: + import django_redis + redis = django_redis.get_redis_connection("redis") + +REDIS_KEY_PREFIX = "pretix_metrics_" + + +class Metric(object): + """ + Base Metrics Object + """ + + def __init__(self, name, helpstring, labelnames=None): + self.name = name + self.helpstring = helpstring + self.labelnames = labelnames or [] + + def __repr__(self): + return self.name + "{" + ",".join(self.labelnames) + "}" + + def _check_label_consistency(self, labels): + """ + Checks if the given labels provides exactly the labels that are required. + """ + + # test if every required label is provided + for labelname in self.labelnames: + if labelname not in labels: + raise ValueError("Label {0} not specified.".format(labelname)) + + # now test if no further labels are required + if len(labels) != len(self.labelnames): + raise ValueError("Unknown labels used: {}".format(", ".join(set(labels) - set(self.labelnames)))) + + def _construct_metric_identifier(self, metricname, labels=None): + """ + Constructs the scrapable metricname usable in the output format. + """ + if not labels: + return metricname + else: + named_labels = [] + for labelname in self.labelnames: + named_labels.append('{}="{}",'.format(labelname, labels[labelname])) + + return metricname + "{" + ",".join(named_labels) + "}" + + def _inc_in_redis(self, key, amount): + """ + Increments given key in Redis. + """ + rkey = REDIS_KEY_PREFIX + key + if settings.HAS_REDIS: + redis.incrbyfloat(rkey, amount) + + def _set_in_redis(self, key, value): + """ + Sets given key in Redis. + """ + rkey = REDIS_KEY_PREFIX + key + if settings.HAS_REDIS: + redis.set(rkey, value) + + +class Counter(Metric): + """ + Counter Metric Object + Counters can only be increased, they can neither be set to a specific value + nor decreased. + """ + + def inc(self, amount=1, **kwargs): + """ + Increments Counter by given amount for the labels specified in kwargs. + """ + if amount < 0: + raise ValueError("Counter cannot be increased by negative values.") + + self._check_label_consistency(kwargs) + + fullmetric = self._construct_metric_identifier(self.name, kwargs) + self._inc_in_redis(fullmetric, amount) + + +class Gauge(Metric): + """ + Gauge Metric Object + Gauges can be set to a specific value, increased and decreased. + """ + + def set(self, value=1, **kwargs): + """ + Sets Gauge to a specific value for the labels specified in kwargs. + """ + self._check_label_consistency(kwargs) + + fullmetric = self._construct_metric_identifier(self.name, kwargs) + self._set_in_redis(fullmetric, value) + + def inc(self, amount=1, **kwargs): + """ + Increments Gauge by given amount for the labels specified in kwargs. + """ + if amount < 0: + raise ValueError("Amount must be greater than zero. Otherwise use dec().") + + self._check_label_consistency(kwargs) + + fullmetric = self._construct_metric_identifier(self.name, kwargs) + self._inc_in_redis(fullmetric, amount) + + def dec(self, amount=1, **kwargs): + """ + Decrements Gauge by given amount for the labels specified in kwargs. + """ + if amount < 0: + raise ValueError("Amount must be greater than zero. Otherwise use inc().") + + self._check_label_consistency(kwargs) + + fullmetric = self._construct_metric_identifier(self.name, kwargs) + self._inc_in_redis(fullmetric, amount * -1) + + +def metric_values(): + """ + Produces the scrapable textformat to be presented to the monitoring system + """ + if not settings.HAS_REDIS: + return "" + + metrics = {} + + for key in redis.scan_iter(match=REDIS_KEY_PREFIX + "*"): + dkey = key.decode("utf-8") + _, _, output_key = dkey.split("_", 2) + value = float(redis.get(dkey).decode("utf-8")) + + metrics[output_key] = value + + return metrics + + +""" +Provided metrics +""" +http_requests_total = Counter("http_requests_total", "Total number of HTTP requests made.", ["code", "handler", "method"]) +# usage: http_requests_total.inc(code="200", handler="/foo", method="GET") diff --git a/src/pretix/base/views/metrics.py b/src/pretix/base/views/metrics.py new file mode 100644 index 0000000000..12512149c5 --- /dev/null +++ b/src/pretix/base/views/metrics.py @@ -0,0 +1,43 @@ +from django.conf import settings +from django.http import HttpResponse + +from .. import metrics + + +def unauthed_response(): + content = "ForbiddenYou are not authorized to view this page." + response = HttpResponse(content, content_type="text/html") + response["WWW-Authenticate"] = 'Basic realm="metrics"' + response.status_code = 401 + return response + + +def serve_metrics(request): + if not settings.METRICS_ENABLED: + return unauthed_response() + + # check if the user is properly authorized: + if "HTTP_AUTHORIZATION" not in request.META: + return unauthed_response() + + method, credentials = request.META["HTTP_AUTHORIZATION"].split(" ", 1) + if method.lower() != "basic": + return unauthed_response() + + user, passphrase = credentials.strip().decode("base64").split(":", 1) + + if user != settings.METRICS_USER: + return unauthed_response() + if passphrase != settings.METRICS_PASSPHRASE: + return unauthed_response() + + # ok, the request passed the authentication-barrier, let's hand out the metrics: + m = metrics.metric_values() + + output = [] + for metric, value in m: + output.append("{} {}".format(metric, str(value))) + + content = "\n".join(output) + + return HttpResponse(content) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index edc9eceab9..6594083366 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -90,6 +90,10 @@ EMAIL_SUBJECT_PREFIX = '[pretix] ' ADMINS = [('Admin', n) for n in config.get('mail', 'admins', fallback='').split(",") if n] +METRICS_ENABLED = config.get('metrics', 'enabled', fallback=False) +METRICS_USER = config.get('metrics', 'user', fallback="metrics") +METRICS_PASSPHRASE = config.get('metrics', 'passphrase', fallback="") + CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', diff --git a/src/pretix/urls.py b/src/pretix/urls.py index 1a7416c704..607f942396 100644 --- a/src/pretix/urls.py +++ b/src/pretix/urls.py @@ -4,7 +4,7 @@ from django.conf.urls import include, url import pretix.control.urls import pretix.presale.urls -from .base.views import cachedfiles, health, js_catalog, redirect +from .base.views import cachedfiles, health, js_catalog, metrics, redirect base_patterns = [ url(r'^download/(?P[^/]+)/$', cachedfiles.DownloadView.as_view(), @@ -13,6 +13,8 @@ base_patterns = [ name='healthcheck'), url(r'^redirect/$', redirect.redir_view, name='redirect'), url(r'^jsi18n/(?P[a-zA-Z-_]+)/$', js_catalog.js_catalog, name='javascript-catalog'), + url(r'^metrics$', metrics.serve_metrics, + name='metrics'), ] control_patterns = [ diff --git a/src/tests/base/test_metrics.py b/src/tests/base/test_metrics.py new file mode 100644 index 0000000000..fb2cf53cdb --- /dev/null +++ b/src/tests/base/test_metrics.py @@ -0,0 +1,140 @@ +# pytest + +import base64 + +import pytest +from django.test import override_settings + +from pretix.base import metrics +from pretix.base.views import metrics as metricsview + + +class FakeRedis(object): + + def __init__(self): + self.storage = {} + + def incrbyfloat(self, rkey, amount): + if rkey in self.storage: + self.storage[rkey] += amount + else: + self.set(rkey, amount) + + def set(self, rkey, value): + self.storage[rkey] = value + + def get(self, rkey): + # bytes-conversion here for emulating redis behavior without making incr too hard + return bytes(self.storage[rkey], encoding='utf-8') + + +@override_settings(HAS_REDIS=True) +def test_counter(monkeypatch): + + fake_redis = FakeRedis() + + monkeypatch.setattr(metrics, "redis", fake_redis, raising=False) + + # now test + fullname_GET = metrics.http_requests_total._construct_metric_identifier('http_requests_total', {"code": "200", "handler": "/foo", "method": "GET"}) + fullname_POST = metrics.http_requests_total._construct_metric_identifier('http_requests_total', {"code": "200", "handler": "/foo", "method": "POST"}) + metrics.http_requests_total.inc(code="200", handler="/foo", method="GET") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_GET] == 1 + metrics.http_requests_total.inc(code="200", handler="/foo", method="GET") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_GET] == 2 + metrics.http_requests_total.inc(7, code="200", handler="/foo", method="GET") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_GET] == 9 + metrics.http_requests_total.inc(7, code="200", handler="/foo", method="POST") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_GET] == 9 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_POST] == 7 + + with pytest.raises(ValueError): + metrics.http_requests_total.inc(-4, code="200", handler="/foo", method="POST") + + with pytest.raises(ValueError): + metrics.http_requests_total.inc(-4, code="200", handler="/foo", method="POST", too="much") + + # test dimensionless counters + dimless_counter = metrics.Counter("dimless_counter", "this is a helpstring") + fullname_dimless = dimless_counter._construct_metric_identifier('dimless_counter') + dimless_counter.inc(20) + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_dimless] == 20 + + +@override_settings(HAS_REDIS=True) +def test_gauge(monkeypatch): + + fake_redis = FakeRedis() + + monkeypatch.setattr(metrics, "redis", fake_redis, raising=False) + + test_gauge = metrics.Gauge("my_gauge", "this is a helpstring", ["dimension"]) + + # now test + fullname_one = test_gauge._construct_metric_identifier('my_gauge', {"dimension": "one"}) + fullname_two = test_gauge._construct_metric_identifier('my_gauge', {"dimension": "two"}) + fullname_three = test_gauge._construct_metric_identifier('my_gauge', {"dimension": "three"}) + + test_gauge.inc(dimension="one") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 1 + test_gauge.inc(7, dimension="one") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 8 + test_gauge.dec(2, dimension="one") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6 + test_gauge.set(3, dimension="two") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 3 + test_gauge.set(4, dimension="two") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4 + test_gauge.dec(7, dimension="three") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_three] == -7 + test_gauge.inc(14, dimension="three") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_three] == 7 + test_gauge.set(17, dimension="three") + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4 + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_three] == 17 + + with pytest.raises(ValueError): + test_gauge.inc(-17, dimension="three") + + with pytest.raises(ValueError): + test_gauge.dec(-17, dimension="three") + + with pytest.raises(ValueError): + test_gauge.set(7, unknown_label="foo") + + with pytest.raises(ValueError): + test_gauge.set(7, dimension="one", too="much") + + # test dimensionless gauges + dimless_gauge = metrics.Gauge("dimless_gauge", "this is a helpstring") + fullname_dimless = dimless_gauge._construct_metric_identifier('dimless_gauge') + dimless_gauge.set(20) + assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_dimless] == 20 + + +@pytest.mark.django_db +@override_settings(HAS_REDIS=True, METRICS_USER="foo", METRICS_PASSPHRASE="bar") +def test_metrics_view(monkeypatch, client): + + fake_redis = FakeRedis() + monkeypatch.setattr(metricsview.metrics, "redis", fake_redis, raising=False) + + counter_value = 3 + fullname = metrics.http_requests_total._construct_metric_identifier('http_requests_total', {"code": "200", "handler": "/foo", "method": "GET"}) + metricsview.metrics.http_requests_total.inc(counter_value, code="200", handler="/foo", method="GET") + + # test unauthorized-page + assert "You are not authorized" in metricsview.serve_metrics(None).content.decode('utf-8') + assert "You are not authorized" in client.get('/metrics').content.decode('utf-8') + assert "{} {}".format(fullname, counter_value) not in client.get('/metrics') + + # test metrics-view + basic_auth = {"HTTP_AUTHORIZATION": base64.b64encode(bytes("foo:bar", "utf-8"))} + assert "{} {}".format(fullname, counter_value) not in client.get("/metrics", headers=basic_auth)