From 591a02f102491d429ca593c70391b48db0b70655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Rolf?= Date: Wed, 4 Nov 2026 22:30:06 +0100 Subject: [PATCH 1/2] feat(integrations): token-guarded Prometheus metrics base view New module basicbar_integrations.metrics: Gauges renders gauge families in the Prometheus text format 0.0.4 (dependency-free), MetricsView is a GET/HEAD-only APIView that returns 404 while METRICS_TOKEN is empty and 401 (WWW-Authenticate: Bearer) unless the exact bearer token is sent. Subclasses implement collect(); the package registers no URL. Bumps basicbar-integrations to 0.4.0. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 18 +++ .../django/basicbar-integrations/README.md | 45 ++++++ .../basicbar_integrations/conf.py | 2 + .../basicbar_integrations/metrics.py | 131 +++++++++++++++++ .../tests/test_metrics.py | 137 ++++++++++++++++++ .../basicbar_integrations/tests/urls.py | 3 + .../basicbar-integrations/pyproject.toml | 2 +- 7 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 packages/django/basicbar-integrations/basicbar_integrations/metrics.py create mode 100644 packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee6cbd..e7787c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ 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`. `Gauges.gauge(...)` escaped Label-Werte und weist +ungültige Namen sowie doppelte Familien 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..a3600e1 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`), aus bis `METRICS_TOKEN` gesetzt ist. ## Einbinden @@ -37,5 +39,48 @@ 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`. Nur `GET`/`HEAD`, + immer `Cache-Control: no-store`. +- `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 und doppelte Familien → `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..454c770 --- /dev/null +++ b/packages/django/basicbar-integrations/basicbar_integrations/metrics.py @@ -0,0 +1,131 @@ +# 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.views import APIView + +from . import conf + +CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8" + +_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 and a + repeated family name raise ``ValueError`` (programming errors). + """ + if not _METRIC_NAME.match(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)}") + for label_dict, sample in labels or (): + parts = [] + for key, label_value in label_dict.items(): + if not _LABEL_NAME.match(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 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 = () + # 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..1920a0d --- /dev/null +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py @@ -0,0 +1,137 @@ +# 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 + + +class _View(MetricsView): + """Test subclass mounted at ``/metrics`` in ``tests/urls.py``.""" + + def collect(self, gauges): + gauges.gauge("demo_total", "Demo", 3) + gauges.gauge("demo", "Demo by kind", labels=[({"kind": "a"}, 1)]) + + +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_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): + def _get(self, auth=None): + headers = {"HTTP_AUTHORIZATION": auth} if auth is not None else {} + return self.client.get("/metrics", **headers) + + 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..6d39a3b 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 .test_metrics import _View as MetricsTestView + urlpatterns = [ path("api/", include("basicbar_integrations.urls")), + path("metrics", MetricsTestView.as_view()), ] 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" From b49371dcb4ea9e6bf8e4073273acd9e9444d931e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Rolf?= Date: Wed, 4 Nov 2026 22:33:47 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(integrations):=20metrics=20review=20rou?= =?UTF-8?q?nd=20=E2=80=94=20Accept,=20name=20checks,=20throttles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ignore Accept/?format= via a custom content negotiation, so scrapers' `Accept: text/plain;version=0.0.4` no longer gets 406 (also before the token check). - Validate metric/label names with fullmatch (a trailing newline passed). - Reject duplicate label sets within one family. - throttle_classes = (): project default throttles never hit scrapes. - Move the test subclass to tests/views.py; README: ASCII token, wording. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 +- .../django/basicbar-integrations/README.md | 12 ++- .../basicbar_integrations/metrics.py | 36 +++++++-- .../tests/test_metrics.py | 74 ++++++++++++++++--- .../basicbar_integrations/tests/urls.py | 4 +- .../basicbar_integrations/tests/views.py | 12 +++ 6 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 packages/django/basicbar-integrations/basicbar_integrations/tests/views.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e7787c8..c2a981b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,10 @@ 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`. `Gauges.gauge(...)` escaped Label-Werte und weist -ungültige Namen sowie doppelte Familien mit `ValueError` ab. Details im +`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`. diff --git a/packages/django/basicbar-integrations/README.md b/packages/django/basicbar-integrations/README.md index a3600e1..b4438ae 100644 --- a/packages/django/basicbar-integrations/README.md +++ b/packages/django/basicbar-integrations/README.md @@ -21,7 +21,7 @@ Provider-SDKs. (`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`), aus bis `METRICS_TOKEN` gesetzt ist. + `Gauges`); ohne `METRICS_TOKEN` antwortet er mit `404`. ## Einbinden @@ -64,11 +64,15 @@ 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`. Nur `GET`/`HEAD`, - immer `Cache-Control: no-store`. + 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 und doppelte Familien → `ValueError`. + 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): diff --git a/packages/django/basicbar-integrations/basicbar_integrations/metrics.py b/packages/django/basicbar-integrations/basicbar_integrations/metrics.py index 454c770..ca97b0a 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/metrics.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/metrics.py @@ -18,15 +18,17 @@ 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" -_METRIC_NAME = re.compile(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$") +# 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_]*$") +_LABEL_NAME = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*") Number = float | int @@ -70,10 +72,11 @@ def gauge( """Add one gauge family. ``value`` adds an unlabelled sample, ``labels`` an iterable of - ``(label_dict, value)`` pairs. Invalid metric/label names and a - repeated family name raise ``ValueError`` (programming errors). + ``(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.match(name): + 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}") @@ -83,10 +86,15 @@ def 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.match(key): + 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)}") @@ -97,6 +105,19 @@ def render(self) -> str: 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`. @@ -106,6 +127,9 @@ class MetricsView(APIView): 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") diff --git a/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py index 1920a0d..8fe31f0 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_metrics.py @@ -4,13 +4,7 @@ from basicbar_integrations.metrics import Gauges, MetricsView - -class _View(MetricsView): - """Test subclass mounted at ``/metrics`` in ``tests/urls.py``.""" - - def collect(self, gauges): - gauges.gauge("demo_total", "Demo", 3) - gauges.gauge("demo", "Demo by kind", labels=[({"kind": "a"}, 1)]) +# The view under test is ``tests.views.DemoMetricsView``, mounted at /metrics. class GaugesRenderTests(SimpleTestCase): @@ -55,6 +49,27 @@ def test_invalid_name_raises(self): 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) @@ -73,10 +88,51 @@ def test_empty_render(self): class MetricsViewTests(TestCase): - def _get(self, auth=None): - headers = {"HTTP_AUTHORIZATION": auth} if auth is not None else {} + 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) diff --git a/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py b/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py index 6d39a3b..c29b511 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/urls.py @@ -3,9 +3,9 @@ from django.urls import include, path -from .test_metrics import _View as MetricsTestView +from .views import DemoMetricsView urlpatterns = [ path("api/", include("basicbar_integrations.urls")), - path("metrics", MetricsTestView.as_view()), + 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)])