from __future__ import annotations
import atexit
import logging
from . import envs
logger = logging.getLogger("modelexpress.metrics")
ENV_ENABLED = "MX_METRICS_ENABLED"
def _enabled() -> bool:
return envs.MX_METRICS_ENABLED
class MetricsCollector:
def __init__(self) -> None:
self._ready = False
self._init_attempted = False
self._server_started = False
self._atexit_registered = False
self.scheme = envs.MX_METRICS_SCHEME
def _ensure(self) -> bool:
if self._ready:
return True
if self._init_attempted:
return False
self._init_attempted = True
if not _enabled():
return False
try:
from prometheus_client import Counter, Histogram
self.selections = Counter(
"mx_p2p_source_selections_total",
"How often each source worker is chosen (utilization balance).",
["policy", "scheme", "source_worker_id"],
)
self.attempts = Counter(
"mx_p2p_source_attempts_total",
"Source attempts by result.",
["policy", "scheme", "result"],
)
self.metadata_failures = Counter(
"mx_p2p_metadata_lookup_failures_total",
"Metadata lookup failures during source selection.",
["policy", "scheme"],
)
self.candidates = Histogram(
"mx_p2p_candidates",
"Candidate count at a selection stage.",
["policy", "scheme", "stage"], buckets=(0, 1, 2, 4, 8, 16, 32, 64, 128),
)
self.selection_seconds = Histogram(
"mx_p2p_source_selection_seconds",
"Selection (ordering) overhead in seconds.",
["policy", "scheme"],
buckets=(1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1.0),
)
self.transfer_seconds = Histogram(
"mx_p2p_transfer_seconds",
"End-to-end transfer time in seconds.",
["policy", "scheme", "outcome"], buckets=(0.5, 1, 2, 5, 10, 30, 60, 120, 300),
)
self._ready = True
logger.info("ModelExpress metrics enabled (scheme=%r)", self.scheme)
except Exception as e:
logger.warning("Failed to initialize metrics, disabling: %s", e)
self._ready = False
if self._ready and not self._atexit_registered:
self._atexit_registered = True
self._start_pull_server_once()
atexit.register(push_metrics_if_enabled)
return self._ready
def _start_pull_server_once(self) -> None:
if self._server_started:
return
self._server_started = True
port = envs.MX_METRICS_PORT
if not port:
return
try:
from prometheus_client import start_http_server
start_http_server(int(port))
logger.info("Metrics /metrics endpoint listening on :%s", port)
except Exception as e:
logger.warning("Failed to start metrics server on port %s: %s", port, e)
def record_selection(self, policy: str, source_worker_id: str) -> None:
if self._ensure():
try:
self.selections.labels(policy, self.scheme, source_worker_id).inc()
except Exception:
pass
def record_attempt(self, policy: str, result: str) -> None:
if self._ensure():
try:
self.attempts.labels(policy, self.scheme, result).inc()
except Exception:
pass
def record_metadata_failure(self, policy: str) -> None:
if self._ensure():
try:
self.metadata_failures.labels(policy, self.scheme).inc()
except Exception:
pass
def observe_candidates(self, policy: str, stage: str, count: int) -> None:
if self._ensure():
try:
self.candidates.labels(policy, self.scheme, stage).observe(count)
except Exception:
pass
def observe_selection_seconds(self, policy: str, seconds: float) -> None:
if self._ensure():
try:
self.selection_seconds.labels(policy, self.scheme).observe(seconds)
except Exception:
pass
def observe_transfer_seconds(self, policy: str, outcome: str, seconds: float) -> None:
if self._ensure():
try:
self.transfer_seconds.labels(policy, self.scheme, outcome).observe(seconds)
except Exception:
pass
metrics = MetricsCollector()
def push_metrics_if_enabled(job: str = "modelexpress") -> None:
if not _enabled():
return
gateway = envs.MX_METRICS_PUSHGATEWAY
if not gateway:
return
try:
import socket
from prometheus_client import REGISTRY, push_to_gateway
grouping_key = {"instance": socket.gethostname()}
push_to_gateway(gateway, job=job, grouping_key=grouping_key, registry=REGISTRY)
logger.info("Pushed metrics to %s (job=%s, %s)", gateway, job, grouping_key)
except Exception as e:
logger.warning("Failed to push metrics to %s: %s", gateway, e)