fix(devolo-exporter): adjust scrape interval and enhance health check functionality
This commit is contained in:
@@ -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)))
|
||||||
|
|||||||
Reference in New Issue
Block a user