feat(bcda): /metrics for the BCDA manager — OTel instruments on a second port, scraped by Prometheus
Some checks failed
CI / lint (push) Successful in 51s
CI / test (push) Failing after 2m28s
CI / notebooks-smoke (push) Successful in 1m37s
Infra CI / zotero (push) Successful in 16s
Infra CI / mc (push) Failing after 32s
Infra CI / bcda (push) Successful in 1m31s
Infra CI / notebooks (push) Successful in 55s
Infra CI / docs (push) Successful in 1m40s
Infra CI / api (push) Successful in 1m4s
Infra CI / llm (push) Successful in 53s
CI / lint (pull_request) Successful in 45s
CI / test (pull_request) Failing after 2m33s
CI / notebooks-smoke (pull_request) Successful in 1m39s
Infra CI / notebooks (pull_request) Successful in 1m5s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m30s
Infra CI / zotero (pull_request) Successful in 22s
Infra CI / llm (pull_request) Successful in 21s
Infra CI / mc (pull_request) Failing after 40s
Infra CI / docs (pull_request) Successful in 15s
Infra CI / api (pull_request) Successful in 15s
Infra CI / bcda (pull_request) Successful in 15s

bcda.ui.metrics follows the #579 convention: perf.meter hands back an
OTel meter + PrometheusMetricReader when STACK_TELEMETRY is on and a
no-op MockMeter otherwise. Streamlit cannot mount a route and only
executes the app script when a browser session opens, so the registry is
published from a second port (BCDA_METRICS_PORT, 8502) by a new
`python -m bcda.ui` entrypoint that starts the metrics server first and
then boots Streamlit's CLI in the same process; the image CMD and
`stack bcda ui` use it. Prometheus scrapes bcda:8502/metrics directly
(infra/prometheus/targets/services.yml) like api and llm.

Instruments: stack_bcda_runs_total{kind,state}, stack_bcda_runs_inflight
{kind}, stack_bcda_run_seconds{kind} (recorded by the runner, which now
labels runs export / adopt / load / vendor_check / other),
stack_bcda_page_views_total{page}, and two observable gauges read at
scrape time, stack_bcda_store_jobs{status} and stack_bcda_store_bytes.
Attribute values are closed sets. The image installs the perf extra;
the compose service joins the observability network with
OTEL_SERVICE_NAME=bcda. polars joins the bcda extra: the views import it,
and it had only been a dev dependency, so the first metrics image
crash-looped on `No module named polars` the moment the entrypoint
imported the app at process start.

Verified in the stack: bcda:8502/metrics serves stack_bcda_* series,
the prometheus target is up and stack_bcda_store_jobs answers a query.

refs #1139
This commit is contained in:
kert
2026-10-25 14:40:31 -04:00
parent 191121b542
commit e31282c02d
15 changed files with 457 additions and 17 deletions

View File

@@ -685,10 +685,17 @@ services:
container_name: bcda container_name: bcda
networks: networks:
- gateway - gateway
- observability
environment: environment:
- BCDA_CLIENT_ID=${BCDA_CLIENT_ID:-} - BCDA_CLIENT_ID=${BCDA_CLIENT_ID:-}
- BCDA_CLIENT_SECRET=${BCDA_CLIENT_SECRET:-} - BCDA_CLIENT_SECRET=${BCDA_CLIENT_SECRET:-}
- BCDA_BASE_URL=${BCDA_BASE_URL:-https://sandbox.bcda.cms.gov} - BCDA_BASE_URL=${BCDA_BASE_URL:-https://sandbox.bcda.cms.gov}
# /metrics on :8502 scraped by prometheus (infra/prometheus/targets);
# traces to the collector like api and llm.
- STACK_TELEMETRY=true
- BCDA_METRICS_PORT=8502
- OTEL_SERVICE_NAME=bcda
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./dev:/app/dev:ro - ./dev:/app/dev:ro

View File

@@ -82,3 +82,7 @@ Tests: service with `httpx.MockTransport`, runner for thread-routed logs and sta
### Containerised (2026-10-25) ### Containerised (2026-10-25)
`infra/images/bcda.Dockerfile` (same base and install pattern as the api image; `uv sync --extra cli --extra bcda`, Streamlit settings in `infra/images/bcda/streamlit.toml`, health at `/_stcore/health`), compose service `bcda` on the gateway network with `./data` read-write (exports, aco.duckdb, replica publish) and `./dev` read-only (vendored upstream, crosswalk seeds, vendor script), Traefik route `bcda.<domain>` behind git-sso, a dashboard tile, the `bcda` subdomain in stack.toml (CoreDNS hosts and the SSO cert list follow) and an `[images.bcda]` entry so the deploy workflow builds, scans and pushes it on changes to `src/**`, `pyproject.toml` or the image files. Public sandbox credentials are built in; `BCDA_CLIENT_ID` / `BCDA_CLIENT_SECRET` / `BCDA_BASE_URL` in `.env` override. `infra/images/bcda.Dockerfile` (same base and install pattern as the api image; `uv sync --extra cli --extra bcda`, Streamlit settings in `infra/images/bcda/streamlit.toml`, health at `/_stcore/health`), compose service `bcda` on the gateway network with `./data` read-write (exports, aco.duckdb, replica publish) and `./dev` read-only (vendored upstream, crosswalk seeds, vendor script), Traefik route `bcda.<domain>` behind git-sso, a dashboard tile, the `bcda` subdomain in stack.toml (CoreDNS hosts and the SSO cert list follow) and an `[images.bcda]` entry so the deploy workflow builds, scans and pushes it on changes to `src/**`, `pyproject.toml` or the image files. Public sandbox credentials are built in; `BCDA_CLIENT_ID` / `BCDA_CLIENT_SECRET` / `BCDA_BASE_URL` in `.env` override.
### `/metrics` (2026-10-25)
`bcda.ui.metrics` follows the #579 convention (`perf.meter` → OTel meter + Prometheus reader when `STACK_TELEMETRY` is on, a no-op `MockMeter` otherwise). Streamlit cannot mount a route, so the manager publishes the process's Prometheus registry from a second port (`BCDA_METRICS_PORT`, 8502) via `prometheus_client.start_http_server`, and Prometheus scrapes `bcda:8502/metrics` directly like `api` and `llm`. Instruments: `stack_bcda_runs_total{kind,state}`, `stack_bcda_runs_inflight{kind}`, `stack_bcda_run_seconds{kind}`, `stack_bcda_page_views_total{page}`, and two observable gauges read at scrape time, `stack_bcda_store_jobs{status}` and `stack_bcda_store_bytes`. Run kinds are a closed set (export, adopt, load, vendor_check, other). The image installs the `perf` extra; the compose service joins the observability network and sets `OTEL_SERVICE_NAME=bcda`.

View File

@@ -3,7 +3,8 @@
# connector pipeline, crosswalk and vendored-upstream tooling # connector pipeline, crosswalk and vendored-upstream tooling
# (src/bcda/ui, `stack bcda ui`). Same base and install pattern as # (src/bcda/ui, `stack bcda ui`). Same base and install pattern as
# api.Dockerfile; the bcda extra brings streamlit, the cli bundle brings # api.Dockerfile; the bcda extra brings streamlit, the cli bundle brings
# aco (flatten + load + pipeline) and conf. # aco (flatten + load + pipeline) and conf, perf brings the OTel meter
# and Prometheus reader behind /metrics.
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app WORKDIR /app
@@ -19,7 +20,7 @@ ENV UV_PYTHON_PREFERENCE=only-system \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=.venv \ UV_PROJECT_ENVIRONMENT=.venv \
UV_INDEX_URL=${PYPI_INDEX_URL} UV_INDEX_URL=${PYPI_INDEX_URL}
RUN uv sync --no-dev --extra cli --extra bcda && uv pip install -e . RUN uv sync --no-dev --extra cli --extra bcda --extra perf && uv pip install -e .
COPY stack.toml ./ COPY stack.toml ./
@@ -27,10 +28,12 @@ COPY stack.toml ./
# stats, bound to all interfaces, websocket-friendly behind Traefik). # stats, bound to all interfaces, websocket-friendly behind Traefik).
COPY infra/images/bcda/streamlit.toml /app/.streamlit/config.toml COPY infra/images/bcda/streamlit.toml /app/.streamlit/config.toml
EXPOSE 8501 # 8501 Streamlit · 8502 Prometheus registry (/metrics, perf meter via STACK_TELEMETRY)
EXPOSE 8501 8502
# /_stcore/health answers "ok" once the Streamlit server is up. # /_stcore/health answers "ok" once the Streamlit server is up.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -sf --max-time 4 http://localhost:8501/_stcore/health || exit 1 CMD curl -sf --max-time 4 http://localhost:8501/_stcore/health || exit 1
CMD ["uv", "run", "--no-sync", "streamlit", "run", "src/bcda/ui/app.py"] # python -m bcda.ui starts the /metrics server, then streamlit in-process.
CMD ["uv", "run", "--no-sync", "python", "-m", "bcda.ui"]

View File

@@ -58,3 +58,11 @@
labels: labels:
job: llm job: llm
__metrics_path__: /metrics __metrics_path__: /metrics
# BCDA manager — Streamlit cannot mount a route, so bcda.ui.metrics serves
# the same OTel Prometheus registry from a second port: stack_bcda_runs_*,
# stack_bcda_page_views_total, stack_bcda_store_*.
- targets: ['bcda:8502']
labels:
job: bcda
__metrics_path__: /metrics

View File

@@ -42,6 +42,7 @@ bcda = [
"narwhals>=2.17.0", "narwhals>=2.17.0",
"pyarrow>=23.0.1", "pyarrow>=23.0.1",
"streamlit>=1.40", "streamlit>=1.40",
"polars>=1.0",
] ]
bib = [ bib = [
"stack[conf]", "stack[conf]",

36
src/bcda/ui/__main__.py Normal file
View File

@@ -0,0 +1,36 @@
"""``python -m bcda.ui`` — start the BCDA manager with its metrics endpoint.
Streamlit executes ``app.py`` only when a browser session opens, so a
``metrics.serve()`` call inside the page never runs on a fresh
container and Prometheus sees a closed port. This entrypoint starts the
``/metrics`` server first (a no-op when ``STACK_TELEMETRY`` is off), then
boots Streamlit's CLI in the same process so page views and background
runs land in the same registry.
python -m bcda.ui # :8501, metrics on BCDA_METRICS_PORT
python -m bcda.ui --server.port 9000 # extra args go to `streamlit run`
"""
from __future__ import annotations
import sys
from pathlib import Path
def main(argv: list[str] | None = None) -> int:
from streamlit.web import cli as stcli
from bcda.ui import app, metrics
metrics.serve()
args = list(sys.argv[1:] if argv is None else argv)
sys.argv = ["streamlit", "run", str(Path(app.__file__)), *args]
try:
stcli.main()
except SystemExit as exc: # click exits through SystemExit
return int(exc.code or 0)
return 0
if __name__ == "__main__": # pragma: no cover — module entrypoint
sys.exit(main())

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
import streamlit as st import streamlit as st
from bcda.ui import views from bcda.ui import metrics, views
PAGES = [ PAGES = [
st.Page(views.connect, title="Connect", icon="🔌", default=True), st.Page(views.connect, title="Connect", icon="🔌", default=True),
@@ -23,6 +23,7 @@ PAGES = [
def main() -> None: def main() -> None:
st.set_page_config(page_title="BCDA manager", page_icon="🏥", layout="wide") st.set_page_config(page_title="BCDA manager", page_icon="🏥", layout="wide")
metrics.serve() # /metrics on BCDA_METRICS_PORT when STACK_TELEMETRY is on; no-op otherwise
conn = st.session_state.get("conn") conn = st.session_state.get("conn")
with st.sidebar: with st.sidebar:
st.markdown("### BCDA manager") st.markdown("### BCDA manager")

188
src/bcda/ui/metrics.py Normal file
View File

@@ -0,0 +1,188 @@
"""OpenTelemetry instruments for the BCDA manager, served on ``/metrics``.
Follows the P26/#579 convention (``perf.meter`` returns a real OTel meter
when ``STACK_TELEMETRY`` is on and a zero-cost ``MockMeter`` otherwise;
the ``PrometheusMetricReader`` fills the in-process ``prometheus_client``
registry). Streamlit cannot mount a route, so :func:`serve` publishes
that registry from a second port in the same process — Prometheus
scrapes ``bcda:8502/metrics`` directly, like ``api`` and ``llm``.
Instruments (all ``stack_bcda_…``)::
stack_bcda_runs_total{kind,state} background runs finished: done / failed
stack_bcda_runs_inflight{kind} runs executing right now
stack_bcda_run_seconds{kind} wall time of a run
stack_bcda_page_views_total{page} page renders
stack_bcda_store_jobs{status} store jobs by current status (gauge)
stack_bcda_store_bytes bytes held in exports/ (gauge)
Attribute values are closed sets — run kind (export, adopt, load,
vendor_check, other), state, page name, store status — never job keys
or MBIs, so the series count stays bounded.
"""
from __future__ import annotations
import logging
import os
import threading
from typing import Any
METER_NAME = "stack.bcda"
DEFAULT_PORT = 8502
KINDS = ("export", "adopt", "load", "vendor_check", "other")
log = logging.getLogger(__name__)
_instruments: _Instruments | None = None
_build_lock = threading.Lock()
_server_port: int | None = None
class _Instruments:
def __init__(self) -> None:
from perf import meter
m: Any = meter(METER_NAME)
self.runs_total = m.create_counter(
"stack_bcda_runs_total",
unit="1",
description="Background runs finished, by kind and state",
)
self.runs_inflight = m.create_up_down_counter(
"stack_bcda_runs_inflight",
unit="1",
description="Background runs executing right now",
)
self.run_seconds = m.create_histogram(
"stack_bcda_run_seconds",
unit="s",
description="Wall time of a background run",
)
self.page_views = m.create_counter(
"stack_bcda_page_views_total", unit="1", description="Manager page renders"
)
# Observable gauges read the store on each scrape; the callback is
# installed by observe_store() once a store path is known.
self._store_callbacks: list[Any] = []
self.store_jobs = m.create_observable_gauge(
"stack_bcda_store_jobs",
callbacks=[self._observe_jobs],
unit="1",
description="Store jobs by current status",
)
self.store_bytes = m.create_observable_gauge(
"stack_bcda_store_bytes",
callbacks=[self._observe_bytes],
unit="By",
description="Bytes held in the store's exports directory",
)
self._store_path: Any = None
# ── gauge callbacks (run at scrape time) ─────────────────
def _observe_jobs(self, options: Any = None) -> list[Any]:
from opentelemetry.metrics import Observation
return [
Observation(n, {"status": status})
for status, n in store_job_counts(self._store_path).items()
]
def _observe_bytes(self, options: Any = None) -> list[Any]:
from opentelemetry.metrics import Observation
return [Observation(store_bytes(self._store_path))]
def _get() -> _Instruments:
global _instruments
if _instruments is None:
with _build_lock:
if _instruments is None:
_instruments = _Instruments()
return _instruments
def _kind(kind: str) -> str:
return kind if kind in KINDS else "other"
# ── store observation helpers (pure, used by the callbacks and tests) ──
def store_job_counts(path: Any = None) -> dict[str, int]:
"""``{status: count}`` over the store's current job states."""
try:
from bcda.ui import service as svc
store = svc.make_store(path)
counts: dict[str, int] = {}
for job in store.list_jobs():
counts[job["status"]] = counts.get(job["status"], 0) + 1
return counts
except Exception: # noqa: BLE001 — a scrape must never raise
return {}
def store_bytes(path: Any = None) -> int:
try:
from bcda.ui import service as svc
return sum(d["bytes"] for d in svc.export_dirs(path))
except Exception: # noqa: BLE001
return 0
# ── recording API (called by runner and views) ──────────────
def observe_store(path: Any) -> None:
"""Point the store gauges at *path* (``None`` = the configured store)."""
_get()._store_path = path
def run_started(kind: str) -> None:
_get().runs_inflight.add(1, {"kind": _kind(kind)})
def run_finished(kind: str, state: str, seconds: float) -> None:
inst = _get()
k = _kind(kind)
inst.runs_inflight.add(-1, {"kind": k})
inst.runs_total.add(1, {"kind": k, "state": state})
inst.run_seconds.record(max(0.0, seconds), {"kind": k})
def page_view(page: str) -> None:
_get().page_views.add(1, {"page": page})
# ── the /metrics server ──────────────────────────────────────
def serve(port: int | None = None) -> int | None:
"""Publish the process's Prometheus registry on ``:<port>/metrics``.
No-op (returns ``None``) when telemetry is off. Idempotent: the
first call starts the server, later calls return its port. Reads
``BCDA_METRICS_PORT`` when *port* is not given.
"""
global _server_port
from perf import _enabled, init
if not _enabled():
return None
if _server_port is not None:
return _server_port
init() # meter provider + Prometheus reader (idempotent)
from prometheus_client import start_http_server
port = int(
port if port is not None else os.environ.get("BCDA_METRICS_PORT", DEFAULT_PORT)
)
server, _thread = start_http_server(port)
_server_port = server.server_address[1]
_get() # build the instrument set so the gauges exist before the first scrape
log.info("bcda metrics on :%d/metrics", _server_port)
return _server_port

View File

@@ -34,6 +34,7 @@ class Run:
id: str id: str
name: str name: str
kind: str = "other"
state: str = "queued" state: str = "queued"
started: float = field(default_factory=time.time) started: float = field(default_factory=time.time)
finished: float | None = None finished: float | None = None
@@ -91,11 +92,22 @@ class Runner:
# ── API ────────────────────────────────────────────────── # ── API ──────────────────────────────────────────────────
def submit( def submit(
self, name: str, fn: Callable[..., Any], *args: Any, **kwargs: Any self,
name: str,
fn: Callable[..., Any],
*args: Any,
kind: str = "other",
**kwargs: Any,
) -> Run: ) -> Run:
"""Start *fn* on a daemon thread; return its Run immediately.""" """Start *fn* on a daemon thread; return its Run immediately.
``kind`` labels the run for metrics (export, adopt, load,
vendor_check, other) — see :mod:`bcda.ui.metrics`.
"""
from bcda.ui import metrics
self._ensure_handler() self._ensure_handler()
run = Run(id=uuid.uuid4().hex[:8], name=name) run = Run(id=uuid.uuid4().hex[:8], name=name, kind=kind)
with self._lock: with self._lock:
self._runs[run.id] = run self._runs[run.id] = run
@@ -105,6 +117,7 @@ class Runner:
self._by_thread[run.thread_id] = run self._by_thread[run.thread_id] = run
run.state = "running" run.state = "running"
run.append(f"started: {name}") run.append(f"started: {name}")
metrics.run_started(kind)
# The state flips last: ``wait()`` and the page read ``state`` # The state flips last: ``wait()`` and the page read ``state``
# without a lock, so result, log and finish time must already be # without a lock, so result, log and finish time must already be
# in place when they see done/failed. # in place when they see done/failed.
@@ -120,6 +133,7 @@ class Runner:
run.finished = time.time() run.finished = time.time()
run.state = "failed" run.state = "failed"
finally: finally:
metrics.run_finished(kind, run.state, run.elapsed)
with self._lock: with self._lock:
self._by_thread.pop(run.thread_id, None) self._by_thread.pop(run.thread_id, None)

View File

@@ -14,6 +14,7 @@ from typing import Any
import polars as pl import polars as pl
import streamlit as st import streamlit as st
from bcda.ui import metrics
from bcda.ui import service as svc from bcda.ui import service as svc
from bcda.ui.runner import runner from bcda.ui.runner import runner
@@ -81,6 +82,7 @@ def runs_panel() -> None:
def connect() -> None: def connect() -> None:
metrics.page_view("connect")
st.title("Connect") st.title("Connect")
st.caption( st.caption(
"BCDA v3 (v1/v2 end 2027-07-30). `/auth` and `/data` are shared by every version; " "BCDA v3 (v1/v2 end 2027-07-30). `/auth` and `/data` are shared by every version; "
@@ -169,6 +171,7 @@ def connect() -> None:
def exports() -> None: def exports() -> None:
metrics.page_view("exports")
st.title("Exports") st.title("Exports")
conn = _require_conn() conn = _require_conn()
if conn is None: if conn is None:
@@ -228,6 +231,7 @@ def exports() -> None:
svc.export_per_type, svc.export_per_type,
store, store,
client, client,
kind="export",
types=types, types=types,
endpoint=endpoint, endpoint=endpoint,
since=since, since=since,
@@ -240,6 +244,7 @@ def exports() -> None:
svc.start_export, svc.start_export,
store, store,
client, client,
kind="export",
endpoint=endpoint, endpoint=endpoint,
types=types, types=types,
since=since, since=since,
@@ -290,6 +295,7 @@ def exports() -> None:
_store(), _store(),
_client(), _client(),
job["job_url"], job["job_url"],
kind="adopt",
types=[t for t in job["types"].split(",") if t and t != "(all)"] types=[t for t in job["types"].split(",") if t and t != "(all)"]
or None, or None,
) )
@@ -317,6 +323,7 @@ def exports() -> None:
def store() -> None: def store() -> None:
metrics.page_view("store")
st.title("Store") st.title("Store")
path = svc.store_path() path = svc.store_path()
st.caption( st.caption(
@@ -365,6 +372,7 @@ def store() -> None:
def load() -> None: def load() -> None:
metrics.page_view("load")
st.title("Flatten + load") st.title("Flatten + load")
st.caption( st.caption(
"Newest export per resource type → `bcda.*` in aco.duckdb (v2 or v3 dialect per resource, " "Newest export per resource type → `bcda.*` in aco.duckdb (v2 or v3 dialect per resource, "
@@ -381,6 +389,7 @@ def load() -> None:
run = runner.submit( run = runner.submit(
"flatten + load", "flatten + load",
svc.load_and_publish, svc.load_and_publish,
kind="load",
run_pipeline=run_pipeline, run_pipeline=run_pipeline,
skip_flatten=skip_flatten, skip_flatten=skip_flatten,
) )
@@ -392,6 +401,7 @@ def load() -> None:
def data() -> None: def data() -> None:
metrics.page_view("data")
st.title("Data") st.title("Data")
try: try:
con = svc.replica_connect() con = svc.replica_connect()
@@ -439,6 +449,7 @@ def data() -> None:
def dictionary() -> None: def dictionary() -> None:
metrics.page_view("dictionary")
st.title("Data dictionary + CCLF crosswalk") st.title("Data dictionary + CCLF crosswalk")
try: try:
con = svc.replica_connect() con = svc.replica_connect()
@@ -515,6 +526,7 @@ def dictionary() -> None:
def upstream() -> None: def upstream() -> None:
metrics.page_view("upstream")
st.title("Upstream: CMSgov/bcda-app + bcda.cms.gov") st.title("Upstream: CMSgov/bcda-app + bcda.cms.gov")
status = svc.vendor_status() status = svc.vendor_status()
if not status.get("available"): if not status.get("available"):
@@ -535,7 +547,7 @@ def upstream() -> None:
with st.expander("Files"): with st.expander("Files"):
st.dataframe(pl.DataFrame(status["files"]), hide_index=True, width="stretch") st.dataframe(pl.DataFrame(status["files"]), hide_index=True, width="stretch")
if st.button("Check upstream for drift (network)", key="btn_vendor_check"): if st.button("Check upstream for drift (network)", key="btn_vendor_check"):
run = runner.submit("vendor drift check", svc.vendor_check) run = runner.submit("vendor drift check", svc.vendor_check, kind="vendor_check")
st.toast(f"Checking upstream as run {run.id}") st.toast(f"Checking upstream as run {run.id}")
st.caption( st.caption(
"A weekly workflow runs the same check and files an issue on drift; refresh with `vendor_bcda_app.py --sync` then `gen_bcda_v3_crosswalk.py`." "A weekly workflow runs the same check and files an issue on drift; refresh with `vendor_bcda_app.py --sync` then `gen_bcda_v3_crosswalk.py`."

View File

@@ -13,7 +13,6 @@ sandbox entities (default *Large Model Entity*).
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
import typer import typer
@@ -50,18 +49,14 @@ def ui(
port: int = typer.Option(8501, help="Port for the Streamlit server."), port: int = typer.Option(8501, help="Port for the Streamlit server."),
headless: bool = typer.Option(True, help="Do not open a browser."), headless: bool = typer.Option(True, help="Do not open a browser."),
) -> None: ) -> None:
"""Launch the BCDA manager (Streamlit).""" """Launch the BCDA manager (Streamlit; /metrics when STACK_TELEMETRY is on)."""
import subprocess import subprocess
import sys import sys
from bcda.ui import app as ui_app
cmd = [ cmd = [
sys.executable, sys.executable,
"-m", "-m",
"streamlit", "bcda.ui",
"run",
str(Path(ui_app.__file__)),
"--server.port", "--server.port",
str(port), str(port),
"--server.headless", "--server.headless",

View File

@@ -0,0 +1,41 @@
"""python -m bcda.ui — metrics first, then Streamlit in-process."""
from __future__ import annotations
import sys
from bcda.ui import __main__ as entry
from bcda.ui import metrics
def test_main_serves_metrics_then_boots_streamlit(monkeypatch) -> None:
calls: list = []
monkeypatch.setattr(metrics, "serve", lambda: calls.append("serve") or 8502)
class FakeCli:
@staticmethod
def main() -> None:
calls.append(("argv", list(sys.argv)))
raise SystemExit(0)
monkeypatch.setattr("streamlit.web.cli.main", FakeCli.main)
assert entry.main(["--server.port", "9000"]) == 0
assert calls[0] == "serve"
argv = calls[1][1]
assert argv[:2] == ["streamlit", "run"] and argv[2].endswith("bcda/ui/app.py")
assert argv[3:] == ["--server.port", "9000"]
def test_main_passes_exit_code_and_default_argv(monkeypatch) -> None:
monkeypatch.setattr(metrics, "serve", lambda: None)
monkeypatch.setattr(sys, "argv", ["bcda.ui", "--server.headless", "true"])
def exits() -> None:
raise SystemExit(3)
monkeypatch.setattr("streamlit.web.cli.main", exits)
assert entry.main() == 3
assert sys.argv[3:] == ["--server.headless", "true"]
monkeypatch.setattr("streamlit.web.cli.main", lambda: None)
assert entry.main([]) == 0

View File

@@ -0,0 +1,127 @@
"""bcda.ui.metrics — instruments, store gauges and the /metrics server."""
from __future__ import annotations
import socket
import urllib.request
import pytest
from bcda.store import Store
from bcda.ui import metrics
from bcda.ui.runner import Runner
@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()
)
assert 'stack_bcda_runs_total{kind="export",state="done"} 1.0' in body
assert 'stack_bcda_page_views_total{page="exports"} 1.0' in body
assert 'stack_bcda_store_jobs{status="complete"} 1.0' in body
assert "stack_bcda_store_bytes 0.0" in body
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()

View File

@@ -247,5 +247,5 @@ def test_ui_launches_streamlit(monkeypatch) -> None:
result = runner.invoke(app, ["bcda", "ui", "--port", "9999", "--no-headless"]) result = runner.invoke(app, ["bcda", "ui", "--port", "9999", "--no-headless"])
assert result.exit_code == 0 and "http://localhost:9999" in result.output assert result.exit_code == 0 and "http://localhost:9999" in result.output
cmd = call.call_args.args[0] cmd = call.call_args.args[0]
assert cmd[1:4] == ["-m", "streamlit", "run"] and cmd[4].endswith("bcda/ui/app.py") assert cmd[1:3] == ["-m", "bcda.ui"]
assert cmd[cmd.index("--server.headless") + 1] == "false" assert cmd[cmd.index("--server.headless") + 1] == "false"

3
uv.lock generated
View File

@@ -3959,6 +3959,7 @@ all = [
{ name = "opentelemetry-instrumentation-fastapi" }, { name = "opentelemetry-instrumentation-fastapi" },
{ name = "opentelemetry-sdk" }, { name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-semantic-conventions" },
{ name = "polars" },
{ name = "psutil" }, { name = "psutil" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pyarrow" }, { name = "pyarrow" },
@@ -3995,6 +3996,7 @@ bcda = [
{ name = "fsspec" }, { name = "fsspec" },
{ name = "httpx" }, { name = "httpx" },
{ name = "narwhals" }, { name = "narwhals" },
{ name = "polars" },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "streamlit" }, { name = "streamlit" },
@@ -4209,6 +4211,7 @@ requires-dist = [
{ name = "opentelemetry-sdk", marker = "extra == 'perf'", specifier = ">=1.25.0" }, { name = "opentelemetry-sdk", marker = "extra == 'perf'", specifier = ">=1.25.0" },
{ name = "opentelemetry-semantic-conventions", marker = "extra == 'perf'", specifier = ">=0.46b0" }, { name = "opentelemetry-semantic-conventions", marker = "extra == 'perf'", specifier = ">=0.46b0" },
{ name = "pdfminer-six", marker = "extra == 'prisma'", specifier = ">=20221105" }, { name = "pdfminer-six", marker = "extra == 'prisma'", specifier = ">=20221105" },
{ name = "polars", marker = "extra == 'bcda'", specifier = ">=1.0" },
{ name = "psutil", marker = "extra == 'perf'", specifier = ">=5.9.0" }, { name = "psutil", marker = "extra == 'perf'", specifier = ">=5.9.0" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'llm'", specifier = ">=3.2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'llm'", specifier = ">=3.2.0" },
{ name = "pyarrow", marker = "extra == 'aco'", specifier = ">=23.0.1" }, { name = "pyarrow", marker = "extra == 'aco'", specifier = ">=23.0.1" },