From 12b946acf3ab7ea1b69b42c79ca1f7b4c2350268 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Fri, 14 Feb 2020 16:12:13 +0200
Subject: [PATCH] Experimental Prometheus metrics endpoint. cc: #300
---
CHANGELOG.md | 3 ++-
hc/api/models.py | 8 ++++--
hc/front/tests/test_metrics.py | 43 ++++++++++++++++++++++++++++++
hc/front/urls.py | 1 +
hc/front/views.py | 47 +++++++++++++++++++++++++++++++++
templates/accounts/project.html | 4 +++
6 files changed, 103 insertions(+), 3 deletions(-)
create mode 100644 hc/front/tests/test_metrics.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c57187a5..99abd110 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.
-## v1.14.0 - Unreleased
+## v1.14.0-dev - Unreleased
### Improvements
- Improved UI to invite users from account's other projects (#258)
+- Experimental Prometheus metrics endpoint (#300)
### Bug Fixes
- The "render_docs" command checks if markdown and pygments is installed (#329)
diff --git a/hc/api/models.py b/hc/api/models.py
index a5ee60ad..013d745e 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -199,6 +199,11 @@ class Check(models.Model):
codes = self.channel_set.order_by("code").values_list("code", flat=True)
return ",".join(map(str, codes))
+ @property
+ def unique_key(self):
+ code_half = self.code.hex[:16]
+ return hashlib.sha1(code_half.encode()).hexdigest()
+
def to_dict(self, readonly=False):
result = {
@@ -216,8 +221,7 @@ class Check(models.Model):
result["last_duration"] = int(self.last_duration.total_seconds())
if readonly:
- code_half = self.code.hex[:16]
- result["unique_key"] = hashlib.sha1(code_half.encode()).hexdigest()
+ result["unique_key"] = self.unique_key
else:
update_rel_url = reverse("hc-api-update", args=[self.code])
pause_rel_url = reverse("hc-api-pause", args=[self.code])
diff --git a/hc/front/tests/test_metrics.py b/hc/front/tests/test_metrics.py
new file mode 100644
index 00000000..27204285
--- /dev/null
+++ b/hc/front/tests/test_metrics.py
@@ -0,0 +1,43 @@
+from hc.api.models import Check
+from hc.test import BaseTestCase
+
+
+class MetricsTestCase(BaseTestCase):
+ def setUp(self):
+ super(MetricsTestCase, self).setUp()
+ self.project.api_key_readonly = "R" * 32
+ self.project.save()
+
+ self.check = Check(project=self.project, name="Alice Was Here")
+ self.check.tags = "foo"
+ self.check.save()
+
+ key = "R" * 32
+ self.url = "/projects/%s/checks/metrics/?api_key=%s" % (self.project.code, key)
+
+ def test_it_works(self):
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, 'name="Alice Was Here"')
+ self.assertContains(r, 'tags="foo"')
+ self.assertContains(r, 'tag="foo"')
+ self.assertContains(r, "hc_checks_total 1")
+
+ def test_it_escapes_newline(self):
+ self.check.name = "Line 1\nLine2"
+ self.check.tags = "A\\C"
+ self.check.save()
+
+ r = self.client.get(self.url)
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, "Line 1\\nLine2")
+ self.assertContains(r, "A\\\\C")
+
+ def test_it_checks_api_key_length(self):
+ r = self.client.get(self.url + "R")
+ self.assertEqual(r.status_code, 400)
+
+ def test_it_checks_api_key(self):
+ url = "/projects/%s/checks/metrics/?api_key=%s" % (self.project.code, "X" * 32)
+ r = self.client.get(url)
+ self.assertEqual(r.status_code, 403)
diff --git a/hc/front/urls.py b/hc/front/urls.py
index c2aac38d..84203970 100644
--- a/hc/front/urls.py
+++ b/hc/front/urls.py
@@ -70,6 +70,7 @@ urlpatterns = [
path("projects//checks/add/", views.add_check, name="hc-add-check"),
path("checks/cron_preview/", views.cron_preview),
path("projects//checks/status/", views.status, name="hc-status"),
+ path("projects//checks/metrics/", views.metrics, name="hc-metrics"),
path("checks//", include(check_urls)),
path("integrations/", include(channel_urls)),
path("docs/", views.serve_doc, name="hc-docs"),
diff --git a/hc/front/views.py b/hc/front/views.py
index ac052302..2271773f 100644
--- a/hc/front/views.py
+++ b/hc/front/views.py
@@ -1540,3 +1540,50 @@ def add_msteams(request):
ctx = {"page": "channels", "project": request.project, "form": form}
return render(request, "integrations/add_msteams.html", ctx)
+
+
+def metrics(request, code):
+ api_key = request.GET.get("api_key", "")
+ if len(api_key) != 32:
+ return HttpResponseBadRequest()
+
+ q = Project.objects.filter(code=code, api_key_readonly=api_key)
+ try:
+ project = q.get()
+ except Project.DoesNotExist:
+ return HttpResponseForbidden()
+
+ checks = Check.objects.filter(project_id=project.id).order_by("id")
+
+ def esc(s):
+ return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
+
+ def output(checks):
+ yield "# HELP hc_check_up Whether the check is currently up (1 for yes, 0 for no).\n"
+ yield "# TYPE hc_check_up gauge\n"
+
+ TMPL = """hc_check_up{name="%s", tags="%s", unique_key="%s"} %d\n"""
+ for check in checks:
+ value = 0 if check.get_status(with_started=False) == "down" else 1
+ yield TMPL % (esc(check.name), esc(check.tags), check.unique_key, value)
+
+ tags_statuses, num_down = _tags_statuses(checks)
+ yield "\n"
+ yield "# HELP hc_tag_up Whether all checks with this tag are up (1 for yes, 0 for no).\n"
+ yield "# TYPE hc_tag_up gauge\n"
+ TMPL = """hc_tag_up{tag="%s"} %d\n"""
+ for tag in sorted(tags_statuses):
+ value = 0 if tags_statuses[tag] == "down" else 1
+ yield TMPL % (esc(tag), value)
+
+ yield "\n"
+ yield "# HELP hc_checks_total The total number of checks.\n"
+ yield "# TYPE hc_checks_total gauge\n"
+ yield "hc_checks_total %d\n" % len(checks)
+ yield "\n"
+
+ yield "# HELP hc_checks_down_total The number of checks currently down.\n"
+ yield "# TYPE hc_checks_down_total gauge\n"
+ yield "hc_checks_down_total %d\n" % num_down
+
+ return HttpResponse(output(checks), content_type="text/plain")
diff --git a/templates/accounts/project.html b/templates/accounts/project.html
index c34216a4..75d325a2 100644
--- a/templates/accounts/project.html
+++ b/templates/accounts/project.html
@@ -43,6 +43,10 @@
API key (read-only):
{{ project.api_key_readonly }}
+
+ Prometheus metrics endpoint:
+ here
+
{% endif %}