Files
PolyWeather/src/utils/metrics.py
T
2026-03-20 23:00:07 +08:00

140 lines
4.8 KiB
Python

from __future__ import annotations
import threading
from typing import Dict, Iterable, List, Optional, Tuple
LabelTuple = Tuple[Tuple[str, str], ...]
class _MetricsRegistry:
def __init__(self) -> None:
self._lock = threading.Lock()
self._counters: Dict[Tuple[str, LabelTuple], float] = {}
self._gauges: Dict[Tuple[str, LabelTuple], float] = {}
self._histograms: Dict[Tuple[str, LabelTuple], Dict[str, float]] = {}
@staticmethod
def _normalize_labels(labels: Dict[str, object]) -> LabelTuple:
return tuple(
sorted((str(key), str(value)) for key, value in labels.items() if value is not None)
)
def inc_counter(self, name: str, amount: float = 1.0, **labels: object) -> None:
key = (name, self._normalize_labels(labels))
with self._lock:
self._counters[key] = self._counters.get(key, 0.0) + amount
def set_gauge(self, name: str, value: float, **labels: object) -> None:
key = (name, self._normalize_labels(labels))
with self._lock:
self._gauges[key] = value
def observe(self, name: str, value: float, **labels: object) -> None:
key = (name, self._normalize_labels(labels))
with self._lock:
bucket = self._histograms.setdefault(
key,
{"count": 0.0, "sum": 0.0, "max": 0.0},
)
bucket["count"] += 1.0
bucket["sum"] += value
bucket["max"] = max(bucket["max"], value)
def snapshot(self) -> Dict[str, object]:
with self._lock:
return {
"counters": dict(self._counters),
"gauges": dict(self._gauges),
"histograms": {
key: dict(value) for key, value in self._histograms.items()
},
}
def export_prometheus(self) -> str:
snap = self.snapshot()
lines: List[str] = []
for name, labels, value in _iter_metrics(snap["counters"]):
lines.append(_prom_line(name, value, labels))
for name, labels, value in _iter_metrics(snap["gauges"]):
lines.append(_prom_line(name, value, labels))
for (name, labels), stats in sorted(snap["histograms"].items()):
lines.append(_prom_line(f"{name}_count", stats["count"], labels))
lines.append(_prom_line(f"{name}_sum", stats["sum"], labels))
lines.append(_prom_line(f"{name}_max", stats["max"], labels))
return "\n".join(lines) + ("\n" if lines else "")
def _iter_metrics(entries: Dict[Tuple[str, LabelTuple], float]) -> Iterable[Tuple[str, LabelTuple, float]]:
for (name, labels), value in sorted(entries.items()):
yield name, labels, value
def _prom_line(name: str, value: float, labels: LabelTuple) -> str:
if labels:
def _escape(label_value: str) -> str:
return label_value.replace("\\", "\\\\").replace('"', '\\"')
label_str = ",".join(
f'{key}="{_escape(str(val))}"'
for key, val in labels
)
return f"{name}{{{label_str}}} {value}"
return f"{name} {value}"
METRICS = _MetricsRegistry()
def counter_inc(name: str, amount: float = 1.0, **labels: object) -> None:
METRICS.inc_counter(name, amount=amount, **labels)
def gauge_set(name: str, value: float, **labels: object) -> None:
METRICS.set_gauge(name, value=value, **labels)
def histogram_observe(name: str, value: float, **labels: object) -> None:
METRICS.observe(name, value=value, **labels)
def record_source_call(source: str, operation: str, outcome: str, duration_ms: Optional[float] = None) -> None:
counter_inc(
"polyweather_source_requests_total",
source=source,
operation=operation,
outcome=outcome,
)
if duration_ms is not None:
histogram_observe(
"polyweather_source_request_duration_ms",
duration_ms,
source=source,
operation=operation,
outcome=outcome,
)
def build_metrics_summary() -> Dict[str, object]:
snapshot = METRICS.snapshot()
request_total = 0.0
source_total = 0.0
source_errors = 0.0
for (name, labels), value in snapshot["counters"].items():
if name == "polyweather_http_requests_total":
request_total += value
if name == "polyweather_source_requests_total":
source_total += value
label_map = dict(labels)
if label_map.get("outcome") not in {"success", "cache_hit"}:
source_errors += value
return {
"http_requests_total": int(request_total),
"source_requests_total": int(source_total),
"source_error_total": int(source_errors),
}
def export_prometheus_metrics() -> str:
return METRICS.export_prometheus()