Skip to content
Open
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
4 changes: 3 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions app/metrics.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions docs/METRICS.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -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"]