fix(devolo-exporter): adjust scrape interval and enhance health check functionality

This commit is contained in:
2025-10-14 22:43:02 +02:00
parent d069fe3585
commit 161efa5230
4 changed files with 207 additions and 134 deletions
@@ -22,7 +22,7 @@ scrape_configs:
- labels: - labels:
cluster: "dev-01" cluster: "dev-01"
- job_name: devolo-exporter - job_name: devolo-exporter
scrape_interval: 10s scrape_interval: 30s
static_configs: static_configs:
- targets: - targets:
- "devolo-exporter-620.prometheus.svc.cluster.local:9100" - "devolo-exporter-620.prometheus.svc.cluster.local:9100"
@@ -75,9 +75,9 @@ resource "kubernetes_deployment" "devolo_exporter" {
path = "/healthz" path = "/healthz"
port = var.exporter_port port = var.exporter_port
} }
initial_delay_seconds = 10 initial_delay_seconds = 15
period_seconds = 10 period_seconds = 60
timeout_seconds = 5 timeout_seconds = 10
failure_threshold = 3 failure_threshold = 3
} }
@@ -86,7 +86,7 @@ resource "kubernetes_deployment" "devolo_exporter" {
path = "/healthz" path = "/healthz"
port = var.exporter_port port = var.exporter_port
} }
initial_delay_seconds = 5 initial_delay_seconds = 10
period_seconds = 10 period_seconds = 10
timeout_seconds = 5 timeout_seconds = 5
failure_threshold = 3 failure_threshold = 3
@@ -126,6 +126,9 @@ resource "kubernetes_deployment" "devolo_exporter" {
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-extensions", "--disable-extensions",
"--disable-gpu", "--disable-gpu",
"--enable-low-end-device-mode",
"--memory-pressure-thresholds=moderate:512,critical:256",
"--force-device-scale-factor=1",
"--disable-popup-blocking", "--disable-popup-blocking",
"--disable-prompt-on-repost", "--disable-prompt-on-repost",
"--disable-sync", "--disable-sync",
@@ -144,7 +147,7 @@ resource "kubernetes_deployment" "devolo_exporter" {
"--remote-allow-origins=http://localhost:9222", "--remote-allow-origins=http://localhost:9222",
"--safebrowsing-disable-auto-update", "--safebrowsing-disable-auto-update",
"--user-data-dir=/home/chromium", "--user-data-dir=/home/chromium",
"http://${var.devolo_ip}/#/overview" "about:blank"
] ]
# Optional: expose the DevTools port within the pod (not needed outside) # Optional: expose the DevTools port within the pod (not needed outside)
@@ -223,6 +223,70 @@ def _parse_uptime_seconds(s: str) -> float:
return float("nan") return float("nan")
def _health_check() -> tuple[bool, str]:
"""Quick health check for liveness/readiness.
Returns (ok, message). ok=False with a short message describing the first failure.
Criteria:
- Chromium DevTools endpoint reachable on localhost:RD_PORT
- Device HTTP reachable on http://DEVOLO_IP
"""
# 1) Check Chromium DevTools
try:
r = requests.get(f"http://localhost:{RD_PORT}/json", timeout=2)
if r.status_code >= 400:
return False, f"devtools http {r.status_code}"
targets = r.json()
if not isinstance(targets, list) or not targets:
return False, "devtools no targets"
ws_url = targets[0].get("webSocketDebuggerUrl")
if not ws_url:
return False, "devtools no ws url"
# Try executing a trivial command via WebSocket to ensure DevTools is functional
try:
ws = websocket.create_connection(ws_url, timeout=2)
ws.settimeout(2)
# Enable Runtime and evaluate a simple expression
ws.send(json.dumps({"id": 1, "method": "Runtime.enable"}))
# Drain until id 1 or timeout
while True:
msg = json.loads(ws.recv())
if msg.get("id") == 1:
break
ws.send(json.dumps({
"id": 2,
"method": "Runtime.evaluate",
"params": {"expression": "1+1", "returnByValue": True}
}))
ok_eval = False
while True:
msg = json.loads(ws.recv())
if msg.get("id") == 2:
val = (
msg.get("result", {})
.get("result", {})
.get("value")
)
ok_eval = (val == 2)
break
except Exception as we: # noqa: BLE001
try:
ws.close() # type: ignore[name-defined]
except Exception:
pass
return False, f"devtools ws error: {we}"
finally:
try:
ws.close()
except Exception:
pass
if not ok_eval:
return False, "devtools evaluate failed"
except Exception as e: # noqa: BLE001
return False, f"devtools error: {e}"
class MetricsHandler(BaseHTTPRequestHandler): class MetricsHandler(BaseHTTPRequestHandler):
registry = CollectorRegistry() registry = CollectorRegistry()
dev_tx = Gauge( dev_tx = Gauge(
@@ -343,31 +407,5 @@ def run_server():
pass pass
scraper.close() scraper.close()
def _health_check() -> tuple[bool, str]:
"""Quick health check for liveness/readiness.
Returns (ok, message). ok=False with a short message describing the first failure.
Criteria:
- Chromium DevTools endpoint reachable on localhost:RD_PORT
- Device HTTP reachable on http://DEVOLO_IP
"""
try:
r = requests.get(f"http://localhost:{RD_PORT}/json", timeout=2)
if r.status_code >= 400:
return False, f"devtools http {r.status_code}"
except Exception as e: # noqa: BLE001
return False, f"devtools error: {e}"
try:
r = requests.get(f"http://{DEVOLO_IP}", timeout=3)
if r.status_code >= 400:
return False, f"device http {r.status_code}"
except Exception as e: # noqa: BLE001
return False, f"device error: {e}"
return True, "ok"
if __name__ == "__main__": if __name__ == "__main__":
run_server() run_server()
@@ -15,7 +15,7 @@ from prometheus_client import CollectorRegistry, Gauge, generate_latest, CONTENT
# Configuration via environment # Configuration via environment
DEVOLO_IP = os.getenv("DEVOLO_IP") DEVOLO_IP = os.getenv("DEVOLO_IP","192.168.0.199")
RD_PORT = int(os.getenv("RD_PORT", "9222")) RD_PORT = int(os.getenv("RD_PORT", "9222"))
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9100")) EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9100"))
@@ -28,57 +28,77 @@ class DevoloScraper:
def __init__(self, devolo_ip: str, rd_port: int) -> None: def __init__(self, devolo_ip: str, rd_port: int) -> None:
self.devolo_ip = devolo_ip self.devolo_ip = devolo_ip
self.rd_port = rd_port self.rd_port = rd_port
self.ws = None self._id_lock = threading.Lock()
self.msg_id = 0
self.lock = threading.Lock()
def _next_id(self) -> int: def _open_ephemeral(self, url: str):
with self.lock: """Create a fresh temporary target for the given URL and return (ws, target_id).
self.msg_id += 1
return self.msg_id
def _connect(self) -> None: The caller is responsible for closing the target and the WebSocket.
# Find Chrome DevTools WebSocket endpoint """
targets = requests.get(f"http://localhost:{self.rd_port}/json", timeout=5).json() info = requests.put(
if not targets: f"http://localhost:{self.rd_port}/json/new?{url}", timeout=5
raise RuntimeError("No DevTools targets found") )
ws_url = targets[0]["webSocketDebuggerUrl"] if info.status_code >= 400:
self.ws = websocket.create_connection(ws_url, timeout=10) raise RuntimeError(f"Failed to create DevTools target: http {info.status_code}")
info = info.json()
ws_url = info.get("webSocketDebuggerUrl")
target_id = info.get("id")
if not ws_url or not target_id:
raise RuntimeError("Failed to create DevTools target")
ws = websocket.create_connection(ws_url, timeout=10)
# Enable Runtime domain # Simple send/recv helper with incremental ids per connection
self._send({"method": "Runtime.enable"}) msg_id = 0
# Enable Page domain for navigation
self._send({"method": "Page.enable"})
def _send(self, payload: dict) -> dict: def send(method: str, params: dict | None = None, timeout: int = 10) -> dict:
if self.ws is None: nonlocal msg_id
self._connect() with self._id_lock:
msg_id = self._next_id() msg_id += 1
payload = {"id": msg_id, **payload} cur_id = msg_id
self.ws.send(json.dumps(payload)) ws.settimeout(timeout)
return self._recv_until_id(msg_id) ws.send(json.dumps({"id": cur_id, "method": method, **({"params": params} if params else {})}))
while True:
resp = json.loads(ws.recv())
if resp.get("id") == cur_id:
return resp
def _recv_until_id(self, expected_id: int, timeout: int = 10) -> dict: # Enable minimal domains and disable network cache to reduce memory/caching
self.ws.settimeout(timeout) send("Runtime.enable")
while True: send("Page.enable")
msg = json.loads(self.ws.recv()) try:
if "id" not in msg: send("Network.enable")
# Log events but continue send("Network.setCacheDisabled", {"cacheDisabled": True})
continue except Exception:
if msg["id"] == expected_id: # Network domain might not be available in some builds; ignore
return msg pass
return ws, target_id, send
def _close_target(self, target_id: str) -> None:
try:
requests.get(f"http://localhost:{self.rd_port}/json/close/{target_id}", timeout=5)
except Exception:
pass
def scrape(self) -> list[dict]: def scrape(self) -> list[dict]:
try: try:
# Navigate to the Devolo page # Open a fresh ephemeral tab for each scrape to avoid memory growth
self._send({ ws, target_id, send = self._open_ephemeral(f"http://{self.devolo_ip}/#/overview")
"method": "Page.navigate",
"params": {"url": f"http://{self.devolo_ip}/#/powerline"},
})
time.sleep(2) time.sleep(2)
# Evaluate JavaScript to extract device data and return by value # Navigate to powerline page
script = """ send(
"Page.navigate",
{
"url": f"http://{self.devolo_ip}/#/powerline"
}
)
try:
time.sleep(2)
# Evaluate JavaScript to extract device data and return by value
script = """
Array.from(document.querySelectorAll('tr[id^="plc-"]')).map(tr => ({ Array.from(document.querySelectorAll('tr[id^="plc-"]')).map(tr => ({
id: tr.querySelector('[id^="plc-id-"]')?.innerText?.trim(), id: tr.querySelector('[id^="plc-id-"]')?.innerText?.trim(),
mac: tr.querySelector('[id^="plc-mac-"]')?.innerText?.trim(), mac: tr.querySelector('[id^="plc-mac-"]')?.innerText?.trim(),
@@ -86,54 +106,53 @@ Array.from(document.querySelectorAll('tr[id^="plc-"]')).map(tr => ({
rx: tr.querySelector('[id^="plc-rx-"]')?.innerText?.trim() rx: tr.querySelector('[id^="plc-rx-"]')?.innerText?.trim()
})) }))
""" """
resp = self._send({ resp = send(
"method": "Runtime.evaluate", "Runtime.evaluate",
"params": { {
"expression": script, "expression": script,
"returnByValue": True, "returnByValue": True,
"awaitPromise": True, "awaitPromise": True,
}, },
}) )
value = resp.get("result", {}).get("result", {}).get("value") print("Scrape response:", resp)
if isinstance(value, list): value = resp.get("result", {}).get("result", {}).get("value")
# Normalize values if isinstance(value, list):
out = [] # Normalize values
for it in value: out = []
if not isinstance(it, dict): for it in value:
continue if not isinstance(it, dict):
dev_id = (it.get("id") or "").strip() continue
if not dev_id or "(this device)" in dev_id.lower(): dev_id = (it.get("id") or "").strip()
continue if not dev_id or "(this device)" in dev_id.lower():
out.append( continue
{ out.append(
"id": (it.get("id") or "").strip(), {
"mac": (it.get("mac") or "").strip(), "id": (it.get("id") or "").strip(),
"tx": _to_float(it.get("tx")), "mac": (it.get("mac") or "").strip(),
"rx": _to_float(it.get("rx")), "tx": _to_float(it.get("tx")),
} "rx": _to_float(it.get("rx")),
) }
return out )
return [] return out
except Exception as e: # noqa: BLE001 return []
# Reset connection next time
try:
if self.ws is not None:
self.ws.close()
finally: finally:
self.ws = None try:
ws.close()
except Exception:
pass
self._close_target(target_id)
except Exception as e: # noqa: BLE001
print(f"Scrape error: {e}", file=sys.stderr) print(f"Scrape error: {e}", file=sys.stderr)
return [] return []
def scrape_overview(self) -> dict: def scrape_overview(self) -> dict:
try:
# Open a fresh ephemeral tab for overview
ws, target_id, send = self._open_ephemeral(f"http://{self.devolo_ip}/#/overview")
try: try:
# Navigate to the Overview page time.sleep(2)
self._send({
"method": "Page.navigate",
"params": {"url": f"http://{self.devolo_ip}/#/overview"},
})
time.sleep(2)
overview_js = """ overview_js = """
(() => { (() => {
const byId = (id) => { const byId = (id) => {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -148,26 +167,29 @@ return {
}; };
})() })()
""" """
resp = self._send({ resp = send(
"method": "Runtime.evaluate", "Runtime.evaluate",
"params": { {
"expression": overview_js, "expression": overview_js,
"returnByValue": True, "returnByValue": True,
"awaitPromise": True, "awaitPromise": True,
}, },
}) )
value = resp.get("result", {}).get("result", {}).get("value") value = resp.get("result", {}).get("result", {}).get("value")
return value if isinstance(value, dict) else {} return value if isinstance(value, dict) else {}
except Exception as e: # noqa: BLE001 finally:
print(f"Overview scrape error: {e}", file=sys.stderr) try:
return {} ws.close()
except Exception:
pass
self._close_target(target_id)
except Exception as e: # noqa: BLE001
print(f"Overview scrape error: {e}", file=sys.stderr)
return {}
def close(self) -> None: def close(self) -> None:
if self.ws is not None: # No persistent connection held anymore
try: return None
self.ws.close()
finally:
self.ws = None
def _to_float(v): def _to_float(v):
@@ -206,7 +228,6 @@ def _parse_uptime_seconds(s: str) -> float:
return float("nan") return float("nan")
def _health_check() -> tuple[bool, str]: def _health_check() -> tuple[bool, str]:
"""Quick health check for liveness/readiness. """Quick health check for liveness/readiness.
@@ -220,6 +241,12 @@ def _health_check() -> tuple[bool, str]:
r = requests.get(f"http://localhost:{RD_PORT}/json", timeout=2) r = requests.get(f"http://localhost:{RD_PORT}/json", timeout=2)
if r.status_code >= 400: if r.status_code >= 400:
return False, f"devtools http {r.status_code}" return False, f"devtools http {r.status_code}"
try:
info = r.json()
if not isinstance(info, list) or len(info) == 0:
return False, "devtools invalid json"
except Exception: # noqa: BLE001
return False, "devtools invalid json"
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return False, f"devtools error: {e}" return False, f"devtools error: {e}"
@@ -264,6 +291,11 @@ class MetricsHandler(BaseHTTPRequestHandler):
ok, msg = _health_check() ok, msg = _health_check()
status = 200 if ok else 503 status = 200 if ok else 503
body = ("ok" if ok else msg).encode() body = ("ok" if ok else msg).encode()
if not ok:
try:
print(f"healthz failed: {msg}")
except Exception:
pass
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))