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)