Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<token>`, 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-
Expand Down
49 changes: 49 additions & 0 deletions packages/django/basicbar-integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <token>` (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).
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"CONTENT_TRANSLATION_PROVIDER": "none", # "none" | "libretranslate"
"LIBRETRANSLATE_URL": "",
"LIBRETRANSLATE_API_KEY": "",
# Prometheus endpoint (metrics.MetricsView); empty = off (404)
"METRICS_TOKEN": "",
}


Expand Down
155 changes: 155 additions & 0 deletions packages/django/basicbar-integrations/basicbar_integrations/metrics.py
Original file line number Diff line number Diff line change
@@ -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 <token>``.
"""

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 <token>``.
"""

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
Loading
Loading