diff --git a/app/main.py b/app/main.py index 0d957bd..28b155c 100644 --- a/app/main.py +++ b/app/main.py @@ -2,10 +2,12 @@ from pydantic import BaseModel from typing import List import uvicorn -from .models import Database, Device +from .models import Database +from .metrics import create_metrics_router app = FastAPI(title="RPi Network Monitoring System") db = Database('data/rpi_nms.db') +app.include_router(create_metrics_router(db)) class DeviceOut(BaseModel): id: int diff --git a/app/metrics.py b/app/metrics.py new file mode 100644 index 0000000..621bbaa --- /dev/null +++ b/app/metrics.py @@ -0,0 +1,30 @@ +"""Prometheus metrics for device state in the local monitoring database.""" +from fastapi import APIRouter, Response +from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, Gauge, generate_latest + +from .models import Database + + +def create_metrics_router(db: Database) -> APIRouter: + """Bind a /metrics endpoint to the database used by the API. + + Metrics reflect a fresh SQLite snapshot on every scrape. A private + registry avoids exposing unrelated process-wide metrics or stale gauges. + """ + router = APIRouter() + + @router.get("/metrics", include_in_schema=False) + def metrics(): + with db.conn() as conn: + total, alive = conn.execute( + "SELECT COUNT(*), COALESCE(SUM(CASE WHEN alive != 0 THEN 1 ELSE 0 END), 0)" + " FROM devices" + ).fetchone() + + registry = CollectorRegistry() + Gauge("rpi_devices_total", "Number of monitored devices", registry=registry).set(total) + Gauge("rpi_devices_alive", "Number of reachable devices", registry=registry).set(alive) + Gauge("rpi_devices_down", "Number of unreachable devices", registry=registry).set(total - alive) + return Response(content=generate_latest(registry), media_type=CONTENT_TYPE_LATEST) + + return router diff --git a/docs/METRICS.md b/docs/METRICS.md new file mode 100644 index 0000000..364f66d --- /dev/null +++ b/docs/METRICS.md @@ -0,0 +1,29 @@ +# Prometheus device metrics + +The FastAPI backend exposes `GET /metrics` in the Prometheus text format. +Each scrape queries the configured device database afresh; metrics are not +incremented for incoming HTTP requests, and there is no separate collector +process to run. + +| Metric | Meaning | +| --- | --- | +| `rpi_devices_total` | Total monitored devices | +| `rpi_devices_alive` | Devices with `alive != 0` | +| `rpi_devices_down` | Devices marked unreachable (`total - alive`) | + +Install the dependencies with `python -m pip install -r requirements.txt`, +start the API with `python -m uvicorn app.main:app`, then check: + + curl http://localhost:8000/metrics + +Prometheus scrape configuration (when Prometheus can reach the API): + + scrape_configs: + - job_name: "rpi-monitoring" + static_configs: + - targets: ["localhost:8000"] + +Metrics currently expose aggregate device counts only; they contain no IP +addresses, credentials or other per-device identifying labels. The endpoint +is unauthenticated for scraping, so place the app behind an appropriate +network boundary when deployed outside a trusted local network. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b3a33bb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.100,<1 +uvicorn>=0.20,<1 +pydantic>=1.10,<3 +python-dotenv>=1,<2 +PyJWT>=2.8,<3 +passlib[bcrypt]>=1.7,<2 +prometheus-client>=0.20,<1 +httpx>=0.24,<1 +pytest>=7,<10 diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..bdb837f --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,76 @@ +"""Functional regression tests for Prometheus scraping without live devices.""" +from fastapi import FastAPI +from fastapi.testclient import TestClient +from prometheus_client.parser import text_string_to_metric_families + +from app.metrics import create_metrics_router +from app.models import Database + + +def create_client(tmp_path): + db = Database(str(tmp_path / "metrics.db")) + db.init_db() + app = FastAPI() + app.include_router(create_metrics_router(db)) + return db, TestClient(app) + + +def parse_gauges(response): + return { + sample.name: sample.value + for metric in text_string_to_metric_families(response.text) + for sample in metric.samples + if sample.name.startswith("rpi_devices_") + } + + +def test_metrics_empty_database(tmp_path): + _, client = create_client(tmp_path) + response = client.get("/metrics") + assert response.status_code == 200 + assert "text/plain" in response.headers["content-type"] + assert parse_gauges(response) == { + "rpi_devices_total": 0, + "rpi_devices_alive": 0, + "rpi_devices_down": 0, + } + + +def test_metrics_mixed_device_states(tmp_path): + db, client = create_client(tmp_path) + db.upsert_device("192.0.2.1", "alpha", True) + db.upsert_device("192.0.2.2", "beta", False) + db.upsert_device("192.0.2.3", "gamma", True) + response = client.get("/metrics") + assert response.status_code == 200 + assert parse_gauges(response) == { + "rpi_devices_total": 3, + "rpi_devices_alive": 2, + "rpi_devices_down": 1, + } + + +def test_metrics_refresh_after_state_change(tmp_path): + db, client = create_client(tmp_path) + db.upsert_device("192.0.2.1", "alpha", True) + assert parse_gauges(client.get("/metrics"))["rpi_devices_alive"] == 1 + db.upsert_device("192.0.2.1", "alpha", False) + assert parse_gauges(client.get("/metrics")) == { + "rpi_devices_total": 1, + "rpi_devices_alive": 0, + "rpi_devices_down": 1, + } + + +def test_metrics_concurrent_scrapes_are_independent(tmp_path): + db, client = create_client(tmp_path) + db.upsert_device("192.0.2.1", "alpha", True) + for _ in range(3): + response = client.get("/metrics") + assert response.status_code == 200 + assert parse_gauges(response)["rpi_devices_total"] == 1 + + +def test_metrics_not_listed_in_openapi(tmp_path): + _, client = create_client(tmp_path) + assert "/metrics" not in client.get("/openapi.json").json()["paths"]