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 %}