The live checkout sat on feat/bcda-v3, so everything merged to main
today (UI fixes, Traefik theme change) only reached the running
services by hand-copied files. It now tracks main; this brings the
branch's 18 commits (BCDA v3 client, crosswalk-driven flatteners,
vendored upstream, institutional provider universe, sandbox notebook)
along with it.
Conflicts:
- .gitea/workflows/{harden,rebuild-all}.yml: regenerated with
gen_config.py from the merged stack.toml + backends/gitea.py — bcda
joins the per-image build → scan → push Harden job, pinned trivy/crane.
- tests/dev/test_gen_config_deploy.py: both sides fixed the GIT_* env
leak; kept main's shared _clean_env() (also used by the detector run).
- uv.lock auto-merged; `uv lock --locked` passes, a relock changes no
version, and trivy finds 0 vulnerabilities in the merged lock.
Two bcda tests needed fixing on the merged tree:
- test_completed_jobs used dated fixtures against completed_jobs()'s
default 24 h window, so it failed a day after it was written; it
now asks for max_age_hours=None (it tests ordering + version filter).
- test_ui_metrics matched whole Prometheus lines; the OTel exporter
(0.66, from the CVE bump) adds otel_scope_* labels to every series,
so it now matches on the labels the stack owns.
Full suite on the merged tree: 15,856 passed before those two fixes;
tests/bcda 426/426 after.
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""bcda.ui.metrics — instruments, store gauges and the /metrics server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import socket
|
|
import urllib.request
|
|
|
|
import pytest
|
|
|
|
from bcda.store import Store
|
|
from bcda.ui import metrics
|
|
from bcda.ui.runner import Runner
|
|
|
|
|
|
def _sample(body: str, name: str, **labels: str) -> float | None:
|
|
"""Value of the first *name* series whose labels include *labels*."""
|
|
for line in body.splitlines():
|
|
if not line.startswith((name + "{", name + " ")):
|
|
continue
|
|
braces = line[len(name) : line.index("}")] if "{" in line else ""
|
|
got = dict(re.findall(r'(\w+)="([^"]*)"', braces))
|
|
if all(got.get(k) == v for k, v in labels.items()):
|
|
return float(line.rsplit(" ", 1)[1])
|
|
return None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fresh_instruments(monkeypatch):
|
|
"""Rebuild the instrument set per test; keep any server from leaking state."""
|
|
monkeypatch.setattr(metrics, "_instruments", None)
|
|
yield
|
|
monkeypatch.setattr(metrics, "_instruments", None)
|
|
|
|
|
|
def test_mock_meter_path_is_free(monkeypatch) -> None:
|
|
monkeypatch.setenv("STACK_TELEMETRY", "false")
|
|
metrics.run_started("export")
|
|
metrics.run_finished("export", "done", 1.5)
|
|
metrics.run_finished("bogus kind", "failed", -1) # clamps and folds to "other"
|
|
metrics.page_view("connect")
|
|
metrics.observe_store(None)
|
|
assert metrics.serve(port=0) is None
|
|
assert metrics._kind("load") == "load" and metrics._kind("nope") == "other"
|
|
|
|
|
|
def test_store_observations(tmp_path, monkeypatch) -> None:
|
|
assert (
|
|
metrics.store_job_counts(tmp_path) == {} and metrics.store_bytes(tmp_path) == 0
|
|
)
|
|
store = Store(path=str(tmp_path))
|
|
store._append_job(key="A", status="complete")
|
|
store._append_job(key="B", status="failed")
|
|
store._append_job(key="C", status="complete")
|
|
d = tmp_path / "exports" / "A"
|
|
d.mkdir(parents=True)
|
|
(d / "Patient.ndjson").write_bytes(b"x" * 10)
|
|
assert metrics.store_job_counts(tmp_path) == {"complete": 2, "failed": 1}
|
|
assert metrics.store_bytes(tmp_path) == 10
|
|
# errors never propagate to a scrape
|
|
from bcda.ui import service as svc
|
|
|
|
def boom(*a, **k): # noqa: ANN002, ANN003, ANN202
|
|
raise OSError("disk gone")
|
|
|
|
monkeypatch.setattr(svc, "make_store", boom)
|
|
monkeypatch.setattr(svc, "export_dirs", boom)
|
|
assert (
|
|
metrics.store_job_counts(tmp_path) == {} and metrics.store_bytes(tmp_path) == 0
|
|
)
|
|
|
|
|
|
def test_runner_records_runs(monkeypatch) -> None:
|
|
seen: list = []
|
|
monkeypatch.setattr(
|
|
metrics, "run_started", lambda kind: seen.append(("start", kind))
|
|
)
|
|
monkeypatch.setattr(
|
|
metrics,
|
|
"run_finished",
|
|
lambda kind, state, secs: seen.append(("end", kind, state, secs >= 0)),
|
|
)
|
|
r = Runner()
|
|
r.wait(r.submit("ok", lambda: 1, kind="load").id)
|
|
|
|
def boom() -> None:
|
|
raise RuntimeError("x")
|
|
|
|
r.wait(r.submit("bad", boom).id)
|
|
assert seen == [
|
|
("start", "load"),
|
|
("end", "load", "done", True),
|
|
("start", "other"),
|
|
("end", "other", "failed", True),
|
|
]
|
|
assert r.all()[0].kind == "other" and r.all()[1].kind == "load"
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def test_serve_publishes_registry(monkeypatch, tmp_path) -> None:
|
|
"""With telemetry on, /metrics carries stack_bcda_* series incl. the store gauges."""
|
|
import perf
|
|
from perf import _meter
|
|
|
|
monkeypatch.setenv("STACK_TELEMETRY", "true")
|
|
monkeypatch.setattr(perf, "_initialised", False)
|
|
monkeypatch.setattr(_meter, "_provider_ready", False)
|
|
monkeypatch.setattr(metrics, "_server_port", None)
|
|
store = Store(path=str(tmp_path))
|
|
store._append_job(key="A", status="complete")
|
|
|
|
port = metrics.serve(port=_free_port())
|
|
assert port and metrics.serve() == port # idempotent
|
|
metrics.observe_store(tmp_path)
|
|
metrics.run_started("export")
|
|
metrics.run_finished("export", "done", 2.0)
|
|
metrics.page_view("exports")
|
|
|
|
body = (
|
|
urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=5)
|
|
.read()
|
|
.decode()
|
|
)
|
|
# The OTel Prometheus exporter (>= 0.66) adds otel_scope_* labels to
|
|
# every series, so match the labels we own, not the whole label set.
|
|
assert _sample(body, "stack_bcda_runs_total", kind="export", state="done") == 1.0
|
|
assert _sample(body, "stack_bcda_page_views_total", page="exports") == 1.0
|
|
assert _sample(body, "stack_bcda_store_jobs", status="complete") == 1.0
|
|
assert _sample(body, "stack_bcda_store_bytes") == 0.0
|
|
assert "stack_bcda_run_seconds" in body
|
|
|
|
# the default port comes from the environment
|
|
monkeypatch.setattr(metrics, "_server_port", None)
|
|
env_port = _free_port()
|
|
monkeypatch.setenv("BCDA_METRICS_PORT", str(env_port))
|
|
assert metrics.serve() == env_port
|
|
perf.shutdown()
|