diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee6cbd..c2a981b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ Changelog ist die Upgrade-Anleitung für die Tools. ## [Unreleased] +### basicbar-integrations (→ wird `integrations/v0.4.0`) + +**Neues Modul `metrics`: token-geschützter Prometheus-Endpunkt.** +`MetricsView` (DRF-`APIView`, nur `GET`/`HEAD`) rendert Gauges im +Prometheus-Textformat 0.0.4, ohne zusätzliche Abhängigkeit. Das Tool leitet +ab, implementiert `collect(gauges)` und hängt den View selbst ein (außerhalb +von `/api/`, das Paket registriert keine URL). Neues Setting `METRICS_TOKEN` +(Default `""`): leer → `404`; sonst nur mit exakt `Authorization: Bearer +`, sonst `401` mit `WWW-Authenticate: Bearer`. Antworten tragen +`Cache-Control: no-store`; `Accept` wird ignoriert (kein `406`), Default- +Throttles des Projekts greifen nicht. Das Token muss ASCII sein. +`Gauges.gauge(...)` escaped Label-Werte und weist ungültige Namen sowie +doppelte Familien und Label-Sets mit `ValueError` ab. Details im +README („Prometheus-Metriken“). + +Migration: nur Version heben; Metriken sind opt-in über `METRICS_TOKEN`. +Wer sie nutzt: Subclass + URL anlegen, `METRICS_TOKEN` in beiden +Compose-Dateien durchreichen und `/metrics` im Caddyfile an `app:8000` +weiterleiten. + ### basicbar-auth (→ wird `auth/v0.2.2`) **Username-Fallback übernimmt keine lokalen oder LTI-Konten mehr** (Security- diff --git a/packages/django/basicbar-integrations/README.md b/packages/django/basicbar-integrations/README.md index 6700e7b..b4438ae 100644 --- a/packages/django/basicbar-integrations/README.md +++ b/packages/django/basicbar-integrations/README.md @@ -20,6 +20,8 @@ Provider-SDKs. - `translation_sync` — Helfer für den Übersetzungs-Sync-Zustand pro Feld (`record_synced`, `stale_map`, `modeltranslation_values`, …). - `html_sanitize` — die eine HTML-Allowlist für Rich-Content (nh3-basiert). +- `metrics` — token-geschützter Prometheus-Endpunkt (`MetricsView` + + `Gauges`); ohne `METRICS_TOKEN` antwortet er mit `404`. ## Einbinden @@ -37,5 +39,52 @@ def whoami(request): return JsonResponse({**whoami_payload(request), **capabilities_payload()}) ``` +## Prometheus-Metriken + +`metrics.MetricsView` liefert Gauges im Prometheus-Textformat (0.0.4), ohne +`prometheus_client`. Das Tool leitet ab, implementiert `collect()` und hängt +den View selbst ein — das Paket registriert keine URL: + +```python +# lending/metrics.py +from basicbar_integrations.metrics import Gauges, MetricsView + +class ToolMetricsView(MetricsView): + def collect(self, gauges: Gauges) -> None: + gauges.gauge("tool_items_total", "Anzahl Gegenstände", Item.objects.count()) + gauges.gauge( + "tool_items_by_status", + "Gegenstände nach Status", + labels=[({"status": s}, n) for s, n in counts_by_status()], + ) + +# config/urls.py — außerhalb von /api/ +path("metrics", ToolMetricsView.as_view()), +``` + +- `METRICS_TOKEN` (Env, Default `""`): leer → `404`. Sonst nur mit exakt + `Authorization: Bearer ` (Vergleich per `hmac.compare_digest`), + andernfalls `401` mit `WWW-Authenticate: Bearer`. Das Token muss ASCII + sein (Django dekodiert Header als Latin-1 — z. B. + `python -c "import secrets; print(secrets.token_urlsafe(32))"`). Nur + `GET`/`HEAD`, immer `Cache-Control: no-store`; der `Accept`-Header wird + ignoriert (immer Textformat), Projekt-Default-Throttles greifen nicht. +- `Gauges.gauge(name, help_text, value=None, labels=None)`: `value` ergibt + ein Sample ohne Labels, `labels` eine Liste von `(label_dict, wert)`. + Ungültige Metrik-/Label-Namen, doppelte Familien und doppelte Label-Sets + innerhalb einer Familie → `ValueError`. + Label-Werte werden escaped; ganzzahlige Werte erscheinen als `int`. +- Keine personenbezogenen Daten in Labels — nur Aggregate. +- Caddy: `/metrics` an das Backend durchreichen (vor dem SPA-Fallback): + + ```caddy + handle /metrics { + reverse_proxy app:8000 + } + ``` + +- Prometheus: `authorization: { type: Bearer, credentials_file: … }` im + Scrape-Job, `metrics_path: /metrics`. + Alle Settings haben Defaults („aus“) — ein Tool ohne Konfiguration startet unverändert. Tests: `python runtests.py` (oder über die Repo-CI). diff --git a/packages/django/basicbar-integrations/basicbar_integrations/conf.py b/packages/django/basicbar-integrations/basicbar_integrations/conf.py index 2aab588..f46d4f9 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/conf.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/conf.py @@ -23,6 +23,8 @@ "CONTENT_TRANSLATION_PROVIDER": "none", # "none" | "libretranslate" "LIBRETRANSLATE_URL": "", "LIBRETRANSLATE_API_KEY": "", + # Prometheus endpoint (metrics.MetricsView); empty = off (404) + "METRICS_TOKEN": "", } diff --git a/packages/django/basicbar-integrations/basicbar_integrations/metrics.py b/packages/django/basicbar-integrations/basicbar_integrations/metrics.py new file mode 100644 index 0000000..ca97b0a --- /dev/null +++ b/packages/django/basicbar-integrations/basicbar_integrations/metrics.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""Token-guarded Prometheus endpoint (text exposition format 0.0.4). + +Dependency-free: the package renders gauges itself instead of pulling in +``prometheus_client`` — the tools only expose a handful of snapshot values +computed from the database on each scrape, no process-state counters. + +A tool subclasses :class:`MetricsView`, implements :meth:`MetricsView.collect` +and mounts it itself (outside ``/api/``). The endpoint is off (404) until +``METRICS_TOKEN`` is set; scrapers then send ``Authorization: Bearer ``. +""" + +import hmac +import math +import re +from collections.abc import Iterable + +from django.http import HttpResponse +from rest_framework.negotiation import BaseContentNegotiation +from rest_framework.views import APIView + +from . import conf + +CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8" + +# Used with ``fullmatch`` (``$`` alone would accept a trailing newline). +_METRIC_NAME = re.compile(r"[a-zA-Z_:][a-zA-Z0-9_:]*") +# Label names may not contain ":" in Prometheus' data model. +_LABEL_NAME = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") + +Number = float | int + + +def _format_value(value: Number) -> str: + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, int): + return str(value) + value = float(value) + if math.isnan(value): + return "NaN" + if math.isinf(value): + return "+Inf" if value > 0 else "-Inf" + if value.is_integer(): + return str(int(value)) + return repr(value) + + +def _escape_label_value(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + +def _escape_help(text: str) -> str: + return text.replace("\\", "\\\\").replace("\n", "\\n") + + +class Gauges: + """Collects gauge samples and renders them in Prometheus text format.""" + + def __init__(self) -> None: + self._families: dict[str, list[str]] = {} + + def gauge( + self, + name: str, + help_text: str, + value: Number | None = None, + labels: Iterable[tuple[dict[str, str], Number]] | None = None, + ) -> None: + """Add one gauge family. + + ``value`` adds an unlabelled sample, ``labels`` an iterable of + ``(label_dict, value)`` pairs. Invalid metric/label names, a + repeated family name and a repeated label set within the family + raise ``ValueError`` (programming errors). + """ + if not _METRIC_NAME.fullmatch(name): + raise ValueError(f"Invalid metric name: {name!r}") + if name in self._families: + raise ValueError(f"Duplicate metric family: {name!r}") + lines = [ + f"# HELP {name} {_escape_help(help_text)}", + f"# TYPE {name} gauge", + ] + if value is not None: + lines.append(f"{name} {_format_value(value)}") + seen: set[frozenset[tuple[str, str]]] = set() + for label_dict, sample in labels or (): + label_set = frozenset((k, str(v)) for k, v in label_dict.items()) + if label_set in seen: + raise ValueError(f"Duplicate label set in {name!r}: {label_dict!r}") + seen.add(label_set) + parts = [] + for key, label_value in label_dict.items(): + if not _LABEL_NAME.fullmatch(key): + raise ValueError(f"Invalid label name: {key!r}") + parts.append(f'{key}="{_escape_label_value(str(label_value))}"') + lines.append(f"{name}{{{','.join(parts)}}} {_format_value(sample)}") + self._families[name] = lines + + def render(self) -> str: + """All families in insertion order, with a trailing newline.""" + return "".join("\n".join(lines) + "\n" for lines in self._families.values()) + + +class _IgnoreAccept(BaseContentNegotiation): + """Never 406: scrapers send ``Accept: text/plain;version=0.0.4`` (or + OpenMetrics), which DRF's JSON/browsable renderers would reject before + the token check. The view returns plain ``HttpResponse`` objects anyway; + only DRF's own errors (405) use the first renderer.""" + + def select_parser(self, request, parsers): + return parsers[0] if parsers else None + + def select_renderer(self, request, renderers, format_suffix=None): + return renderers[0], renderers[0].media_type + + +class MetricsView(APIView): + """``GET`` the tool's gauges; subclasses implement :meth:`collect`. + + 404 while ``METRICS_TOKEN`` is empty, 401 (``WWW-Authenticate: Bearer``) + unless the request carries exactly ``Authorization: Bearer ``. + """ + + authentication_classes = () + permission_classes = () + # A consumer's DEFAULT_THROTTLE_CLASSES must never rate-limit scrapes. + throttle_classes = () + content_negotiation_class = _IgnoreAccept + # No OPTIONS: DRF would answer it with view metadata, bypassing the token. + http_method_names = ("get", "head") + + def collect(self, gauges: Gauges) -> None: + """Add the tool's gauges to ``gauges``.""" + raise NotImplementedError + + def get(self, request) -> HttpResponse: + token = conf.get("METRICS_TOKEN") + if not token: + response = HttpResponse(status=404) + elif not hmac.compare_digest( + request.META.get("HTTP_AUTHORIZATION", "").encode(), + f"Bearer {token}".encode(), + ): + response = HttpResponse(status=401) + response["WWW-Authenticate"] = "Bearer" + else: + gauges = Gauges() + self.collect(gauges) + response = HttpResponse(gauges.render(), content_type=CONTENT_TYPE) + response["Cache-Control"] = "no-store" + return response diff --git a/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py new file mode 100644 index 0000000..8fe31f0 --- /dev/null +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) +from django.test import SimpleTestCase, TestCase, override_settings + +from basicbar_integrations.metrics import Gauges, MetricsView + +# The view under test is ``tests.views.DemoMetricsView``, mounted at /metrics. + + +class GaugesRenderTests(SimpleTestCase): + def test_render_unlabelled(self): + g = Gauges() + g.gauge("demo_total", "Demo", 3) + self.assertEqual( + g.render(), + "# HELP demo_total Demo\n# TYPE demo_total gauge\ndemo_total 3\n", + ) + + def test_render_labelled(self): + g = Gauges() + g.gauge("demo", "Demo", labels=[({"kind": "a"}, 1), ({"kind": "b"}, 0)]) + out = g.render() + self.assertIn('demo{kind="a"} 1\n', out) + self.assertIn('demo{kind="b"} 0\n', out) + self.assertTrue(out.startswith("# HELP demo Demo\n# TYPE demo gauge\n")) + + def test_label_escaping(self): + g = Gauges() + g.gauge("demo", "Demo", labels=[({"kind": 'a"b\nc'}, 1)]) + self.assertIn('demo{kind="a\\"b\\nc"} 1\n', g.render()) + + def test_backslash_escaping(self): + g = Gauges() + g.gauge("demo", "Demo", labels=[({"kind": "a\\b"}, 1)]) + self.assertIn('demo{kind="a\\\\b"} 1\n', g.render()) + + def test_float_and_int(self): + g = Gauges() + g.gauge("a", "A", 2.5) + g.gauge("b", "B", 2.0) + out = g.render() + self.assertIn("\na 2.5\n", out) + self.assertIn("\nb 2\n", out) + + def test_invalid_name_raises(self): + g = Gauges() + with self.assertRaises(ValueError): + g.gauge("1bad", "Bad", 1) + with self.assertRaises(ValueError): + g.gauge("demo", "Demo", labels=[({"kind-x": "a"}, 1)]) + + def test_trailing_newline_in_names_raises(self): + g = Gauges() + with self.assertRaises(ValueError): + g.gauge("demo\n", "Demo", 1) + with self.assertRaises(ValueError): + g.gauge("demo", "Demo", labels=[({"k\n": "a"}, 1)]) + + def test_duplicate_label_set_raises(self): + g = Gauges() + with self.assertRaises(ValueError): + g.gauge( + "demo", + "Demo", + labels=[({"a": "1", "b": "2"}, 1), ({"b": "2", "a": "1"}, 2)], + ) + + def test_distinct_label_sets_ok(self): + g = Gauges() + g.gauge("demo", "Demo", labels=[({"a": "1"}, 1), ({"a": "2"}, 2)]) + self.assertIn('demo{a="2"} 2\n', g.render()) + + def test_duplicate_family_raises(self): + g = Gauges() + g.gauge("demo", "Demo", 1) + with self.assertRaises(ValueError): + g.gauge("demo", "Demo", 2) + + def test_families_in_insertion_order(self): + g = Gauges() + g.gauge("zeta", "Z", 1) + g.gauge("alpha", "A", 1) + out = g.render() + self.assertLess(out.index("zeta"), out.index("alpha")) + + def test_empty_render(self): + self.assertEqual(Gauges().render(), "") + + +class MetricsViewTests(TestCase): + ACCEPTS = ( + "text/plain", + "text/plain;version=0.0.4", + "application/openmetrics-text;version=1.0.0,text/plain;version=0.0.4;q=0.5", + "application/json", + ) + + def _get(self, auth=None, **headers): + if auth is not None: + headers["HTTP_AUTHORIZATION"] = auth + return self.client.get("/metrics", **headers) + + def test_accept_header_ignored_without_token(self): + for accept in self.ACCEPTS: + with self.subTest(accept=accept): + self.assertEqual(self._get(HTTP_ACCEPT=accept).status_code, 404) + + @override_settings(METRICS_TOKEN="tok") + def test_accept_header_ignored_with_token(self): + for accept in self.ACCEPTS: + with self.subTest(accept=accept): + response = self._get("Bearer tok", HTTP_ACCEPT=accept) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], self.CONTENT_TYPE) + self.assertIn("demo_total 3", response.content.decode()) + self.assertEqual( + self._get("Bearer x", HTTP_ACCEPT=accept).status_code, 401 + ) + + @override_settings(METRICS_TOKEN="tok") + def test_format_query_param_ignored(self): + response = self.client.get( + "/metrics?format=json", HTTP_AUTHORIZATION="Bearer tok" + ) + self.assertEqual(response.status_code, 200) + self.assertIn("demo_total 3", response.content.decode()) + + def test_consumer_throttles_do_not_apply(self): + # DRF binds DEFAULT_THROTTLE_CLASSES at class creation, so a project + # default would be inherited; the view must opt out explicitly. + self.assertEqual(MetricsView.throttle_classes, ()) + self.assertEqual(MetricsView().get_throttles(), []) + + CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8" + + def test_404_without_token(self): + self.assertEqual(self._get("Bearer ").status_code, 404) + self.assertEqual(self._get().status_code, 404) + + @override_settings(METRICS_TOKEN="tok") + def test_401_missing_header(self): + response = self._get() + self.assertEqual(response.status_code, 401) + self.assertEqual(response["WWW-Authenticate"], "Bearer") + + @override_settings(METRICS_TOKEN="tok") + def test_401_wrong_token(self): + response = self._get("Bearer nope") + self.assertEqual(response.status_code, 401) + self.assertEqual(response["WWW-Authenticate"], "Bearer") + + @override_settings(METRICS_TOKEN="tok") + def test_header_variants_are_401(self): + for header in ("Bearer tok ", "bearer tok", "tok", "Basic tok"): + with self.subTest(header=header): + self.assertEqual(self._get(header).status_code, 401) + + @override_settings(METRICS_TOKEN="tok") + def test_200_with_token(self): + response = self._get("Bearer tok") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response["Content-Type"], "text/plain; version=0.0.4; charset=utf-8" + ) + self.assertEqual(response["Cache-Control"], "no-store") + body = response.content.decode() + self.assertIn("# TYPE demo_total gauge", body) + self.assertIn('demo{kind="a"} 1', body) + + @override_settings(METRICS_TOKEN="tok") + def test_post_is_405(self): + response = self.client.post("/metrics", HTTP_AUTHORIZATION="Bearer tok") + self.assertEqual(response.status_code, 405) + + @override_settings(METRICS_TOKEN="tok") + def test_options_is_405(self): + self.assertEqual(self.client.options("/metrics").status_code, 405) + + @override_settings(METRICS_TOKEN="tok") + def test_head_requires_token(self): + self.assertEqual(self.client.head("/metrics").status_code, 401) + ok = self.client.head("/metrics", HTTP_AUTHORIZATION="Bearer tok") + self.assertEqual(ok.status_code, 200) + + @override_settings(METRICS_TOKEN="tok") + def test_401_has_no_store(self): + self.assertEqual(self._get()["Cache-Control"], "no-store") + + +class AbstractCollectTests(SimpleTestCase): + def test_collect_is_abstract(self): + with self.assertRaises(NotImplementedError): + MetricsView().collect(Gauges()) diff --git a/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py b/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py index ef7714c..c29b511 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py @@ -3,6 +3,9 @@ from django.urls import include, path +from .views import DemoMetricsView + urlpatterns = [ path("api/", include("basicbar_integrations.urls")), + path("metrics", DemoMetricsView.as_view()), ] diff --git a/packages/django/basicbar-integrations/basicbar_integrations/tests/views.py b/packages/django/basicbar-integrations/basicbar_integrations/tests/views.py new file mode 100644 index 0000000..07cde6d --- /dev/null +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/views.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""Test-only view subclasses, mounted in ``tests/urls.py``.""" + +from basicbar_integrations.metrics import MetricsView + + +class DemoMetricsView(MetricsView): + def collect(self, gauges): + gauges.gauge("demo_total", "Demo", 3) + gauges.gauge("demo", "Demo by kind", labels=[({"kind": "a"}, 1)]) diff --git a/packages/django/basicbar-integrations/pyproject.toml b/packages/django/basicbar-integrations/pyproject.toml index 001b9f3..7f2f4b6 100644 --- a/packages/django/basicbar-integrations/pyproject.toml +++ b/packages/django/basicbar-integrations/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basicbar-integrations" -version = "0.3.0" +version = "0.4.0" description = "Optionale Integrationen der virtUOS -bar-Tools: LibreTranslate, LiteLLM, HTML-Allowlist, Übersetzungs-Sync" readme = "README.md" requires-python = ">=3.12"