Files
mqtt-broker-stack/backend/app/metrics.py
T
2026-09-20 07:55:18 +02:00

123 lines
4.3 KiB
Python

"""$SYS broker telemetry (spec sections 33, 55).
The dynsec channel's MQTT connection subscribes to $SYS/# and forwards
every message here (see DynsecClient.set_event_sink). Values are parsed
ONCE at ingest; snapshot() returns the latest known state. The broker
publishes $SYS on the configured sys_interval (10 s in the bootstrap
config), so a fresh backend needs a few seconds before the first
snapshot is populated — every field is therefore Optional and the
`available` flag says whether ANY $SYS message has been seen.
"""
from __future__ import annotations
import threading
from datetime import UTC, datetime
from typing import Any, Callable
SYS_PREFIX = "$SYS/broker/"
_lock = threading.Lock()
_values: dict[str, Any] = {}
_last_update: datetime | None = None
def _as_int(payload: str) -> object:
try:
return int(payload)
except ValueError:
return payload
def _as_float(payload: str) -> object:
try:
return float(payload)
except ValueError:
return payload
# $SYS topic -> (snapshot field, parser). Field names mirror
# BrokerStatusOut (schemas.py). NOTE: "retained messages/count"
# genuinely contains a space in the Mosquitto topic name.
_TOPIC_FIELDS: dict[str, tuple[str, Callable[[str], object]]] = {
f"{SYS_PREFIX}version": ("version", str),
f"{SYS_PREFIX}uptime": ("uptime", str),
f"{SYS_PREFIX}timestamp": ("build_timestamp", str),
# clients/disconnected = persistent clients currently offline,
# i.e. the "stored sessions" of the dashboard (spec section 33).
f"{SYS_PREFIX}clients/connected": ("clients_connected", _as_int),
f"{SYS_PREFIX}clients/disconnected": ("clients_disconnected", _as_int),
f"{SYS_PREFIX}clients/maximum": ("clients_maximum", _as_int),
f"{SYS_PREFIX}clients/total": ("clients_total", _as_int),
f"{SYS_PREFIX}clients/expired": ("clients_expired", _as_int),
f"{SYS_PREFIX}messages/received": ("messages_received", _as_int),
f"{SYS_PREFIX}messages/sent": ("messages_sent", _as_int),
f"{SYS_PREFIX}messages/stored": ("messages_stored", _as_int),
f"{SYS_PREFIX}messages/dropped": ("messages_dropped", _as_int),
f"{SYS_PREFIX}publish/messages/received": ("publish_received", _as_int),
f"{SYS_PREFIX}publish/messages/sent": ("publish_sent", _as_int),
f"{SYS_PREFIX}publish/messages/dropped": ("publish_dropped", _as_int),
f"{SYS_PREFIX}bytes/received": ("bytes_received", _as_int),
f"{SYS_PREFIX}bytes/sent": ("bytes_sent", _as_int),
f"{SYS_PREFIX}subscriptions/count": ("subscriptions_count", _as_int),
f"{SYS_PREFIX}retained messages/count": ("retained_count", _as_int),
# load/* are per-second averages over the last minute ("x.xx").
f"{SYS_PREFIX}load/messages/received/1min": (
"load_messages_received_1min",
_as_float,
),
f"{SYS_PREFIX}load/messages/sent/1min": (
"load_messages_sent_1min",
_as_float,
),
f"{SYS_PREFIX}load/publish/received/1min": (
"load_publish_received_1min",
_as_float,
),
f"{SYS_PREFIX}load/publish/sent/1min": (
"load_publish_sent_1min",
_as_float,
),
f"{SYS_PREFIX}load/bytes/received/1min": (
"load_bytes_received_1min",
_as_float,
),
f"{SYS_PREFIX}load/bytes/sent/1min": (
"load_bytes_sent_1min",
_as_float,
),
}
_FIELDS = [field for field, _ in _TOPIC_FIELDS.values()]
def handle_sys_message(topic: str, payload: bytes) -> None:
"""Ingest one $SYS message (wired as the dynsec client's event sink)."""
entry = _TOPIC_FIELDS.get(topic)
if entry is None:
return # unknown $SYS topic — deliberately ignored
field, parser = entry
text = payload.decode("utf-8", errors="replace").strip()
global _last_update
with _lock:
_values[field] = parser(text)
_last_update = datetime.now(UTC)
def reset() -> None:
"""Clear all telemetry (used by tests)."""
global _last_update
with _lock:
_values.clear()
_last_update = None
def snapshot() -> dict[str, Any]:
"""Latest known $SYS state; every field None until first publish."""
with _lock:
values = dict(_values)
last_update = _last_update
snap: dict[str, Any] = {field: values.get(field) for field in _FIELDS}
snap["available"] = last_update is not None
snap["last_update"] = last_update
return snap