from __future__ import annotations
import atexit
import errno
import logging
import os
import threading
from . import envs
logger = logging.getLogger("modelexpress.metrics")
ENV_ENABLED = "MX_METRICS_ENABLED"
_CANDIDATE_BUCKETS = (0, 1, 2, 4, 8, 16, 32, 64, 128)
LIST_SOURCES_RESULTS = ("ok", "empty", "error")
NIXL_ERROR_KINDS = ("timeout", "status_error")
NIXL_RECEIVE_RESULTS = ("complete", "partial", "empty", "rejected")
def _enabled() -> bool:
return envs.MX_METRICS_ENABLED
def _multiproc_dir() -> str | None:
value = envs.PROMETHEUS_MULTIPROC_DIR
return value.strip() or None if value else None
def _package_version() -> str:
try:
from importlib.metadata import version
return version("modelexpress")
except Exception:
return "unknown"
def reset_multiproc_dir(path: str | None = None) -> None:
directory = path or _multiproc_dir()
if not directory:
return
try:
os.makedirs(directory, exist_ok=True)
removed = 0
for name in os.listdir(directory):
if not name.endswith(".db"):
continue
try:
os.unlink(os.path.join(directory, name))
removed += 1
except OSError as e:
logger.warning("Failed to remove stale metrics file %s: %s", name, e)
logger.info("Reset metrics directory %s (%d stale file(s))", directory, removed)
except OSError as e:
logger.warning("Failed to reset metrics directory %s: %s", directory, e)
class MetricsCollector:
def __init__(self, registry=None) -> None:
self._registry = registry
self._ready = False
self._init_attempted = False
self._server_started = False
self._push_registered = False
self._bind_owner = False
self._source_id_label = False
self._retry_thread: threading.Thread | None = None
self._lock = threading.Lock()
self.scheme = envs.MX_METRICS_SCHEME
def _ensure(self) -> bool:
with self._lock:
if self._ready:
return True
if self._init_attempted:
return False
self._init_attempted = True
if not _enabled():
return False
try:
self._build_families()
multiproc_dir = _multiproc_dir()
if multiproc_dir:
_warn_if_multiprocess_inactive(multiproc_dir)
self._ready = True
logger.info(
"ModelExpress metrics enabled (scheme=%r, multiprocess=%s)",
self.scheme,
bool(_multiproc_dir()),
)
except Exception as e:
logger.warning("Failed to initialize metrics, disabling: %s", e)
self._ready = False
return False
try:
self._start_exposition()
except Exception as e:
logger.warning("Failed to start metrics exposition: %s", e)
return True
def _build_families(self) -> None:
from prometheus_client import REGISTRY, Counter, Histogram
registry = REGISTRY if self._registry is None else self._registry
self.build_info = _make_gauge(
registry,
"mx_build_info",
"Build and deployment constants for this process; always 1.",
["component", "version", "scheme"],
)
self.build_info.labels("client", _package_version(), self.scheme).set(1)
self._source_id_label = envs.MX_METRICS_SOURCE_ID_LABEL
selection_labels = ["policy", "scheme"]
if self._source_id_label:
selection_labels.append("source_worker_id")
logger.warning(
"MX_METRICS_SOURCE_ID_LABEL=1: mx_p2p_source_selections_total "
"carries source_worker_id, whose domain grows with process "
"count over time rather than with cluster size. Benchmark runs "
"only -- never a long-lived production Prometheus."
)
self.selections = Counter(
"mx_p2p_source_selections_total",
"Source workers chosen, by selection policy.",
selection_labels,
registry=registry,
)
self.attempts = Counter(
"mx_p2p_source_attempts_total",
"Source attempts by result.",
["policy", "scheme", "result"],
registry=registry,
)
self.metadata_failures = Counter(
"mx_p2p_metadata_lookup_failures_total",
"Metadata lookup failures during source selection.",
["policy", "scheme"],
registry=registry,
)
self.list_sources = Counter(
"mx_p2p_list_sources_total",
"ListSources outcomes: ok|empty|error. Separates a backend outage "
"from a healthy cluster that has published no peers yet.",
["policy", "scheme", "result"],
registry=registry,
)
self.nixl_errors = Counter(
"mx_nixl_data_plane_errors_total",
"NIXL data-plane failures by classified kind: timeout|status_error. "
"A non-zero rate is what demotes an agent from READY.",
["scheme", "kind"],
registry=registry,
)
self.nixl_receives = Counter(
"mx_nixl_receive_total",
"Receive outcomes: complete|partial|empty|rejected. `partial` and "
"`empty` both return success today, so they are otherwise "
"indistinguishable from a healthy transfer; `rejected` is the "
"strict-mode refusal, which raises instead of returning.",
["scheme", "result"],
registry=registry,
)
self.candidates = Histogram(
"mx_p2p_candidates",
"Candidate count at a selection stage.",
["policy", "scheme", "stage"], buckets=_CANDIDATE_BUCKETS,
registry=registry,
)
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),
registry=registry,
)
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),
registry=registry,
)
def _exposition_registry(self):
multiproc_dir = _multiproc_dir()
if multiproc_dir:
from prometheus_client import CollectorRegistry, multiprocess
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry, path=multiproc_dir)
return registry
from prometheus_client import REGISTRY
return REGISTRY if self._registry is None else self._registry
def _start_exposition(self) -> None:
with self._lock:
if self._server_started:
return
self._server_started = True
port = _configured_port()
gateway = envs.MX_METRICS_PUSHGATEWAY
if port and gateway:
logger.error(
"MX_METRICS_PORT and MX_METRICS_PUSHGATEWAY are both set; "
"running both double-counts every series. Serving the scrape "
"endpoint and disabling the push."
)
gateway = None
if port:
self._try_bind(port)
if gateway and not self._push_registered:
self._push_registered = True
atexit.register(push_metrics_if_enabled)
def _try_bind(self, port: int | None) -> None:
if port is None:
return
try:
from prometheus_client import start_http_server
start_http_server(port, registry=self._exposition_registry())
except OSError as e:
if e.errno == errno.EADDRINUSE and _multiproc_dir():
logger.info(
"Metrics port %s is held by another rank in this pod (%s); "
"its endpoint serves this rank's metrics too.",
port,
e,
)
self._start_bind_retry(port)
elif e.errno == errno.EADDRINUSE:
logger.warning(
"Failed to bind metrics port %s (%s). Without "
"PROMETHEUS_MULTIPROC_DIR this process's metrics are not "
"exposed anywhere.",
port,
e,
)
else:
logger.error(
"Metrics port %s cannot be bound (%s). This is not a lost "
"race with another rank -- it will fail identically on every "
"rank and no retry can fix it, so this pod will never be "
"scrapeable. Check the port number and the container's "
"capabilities.",
port,
e,
)
return
except Exception as e:
logger.warning("Failed to start metrics server on port %s: %s", port, e)
return
self._bind_owner = True
logger.info("Metrics /metrics endpoint listening on :%s", port)
def _start_bind_retry(self, port: int) -> None:
if self._retry_thread is not None:
return
interval = max(1.0, float(envs.MX_METRICS_BIND_RETRY_SECS))
def _retry() -> None:
while not self._bind_owner:
try:
threading.Event().wait(interval)
from prometheus_client import start_http_server
start_http_server(port, registry=self._exposition_registry())
except OSError:
continue except Exception as e:
logger.debug("Metrics bind retry gave up: %s", e)
return
else:
self._bind_owner = True
logger.info(
"Took ownership of the metrics endpoint on :%s "
"(previous owner exited)",
port,
)
return
thread = threading.Thread(
target=_retry, name="mx-metrics-bind-retry", daemon=True
)
self._retry_thread = thread
thread.start()
def record_selection(self, policy: str, source_worker_id: str | None = None) -> None:
if self._ensure():
try:
if self._source_id_label:
self.selections.labels(
policy, self.scheme, source_worker_id or "unknown"
).inc()
else:
self.selections.labels(policy, self.scheme).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 record_list_sources(self, policy: str, result: str) -> None:
if self._ensure():
try:
if result not in LIST_SOURCES_RESULTS:
result = "error"
self.list_sources.labels(policy, self.scheme, result).inc()
except Exception:
pass
def record_nixl_error(self, kind: str) -> None:
if self._ensure():
try:
if kind not in NIXL_ERROR_KINDS:
kind = "status_error"
self.nixl_errors.labels(self.scheme, kind).inc()
except Exception:
pass
def record_nixl_receive(self, result: str) -> None:
if self._ensure():
try:
if result not in NIXL_RECEIVE_RESULTS:
result = "empty"
self.nixl_receives.labels(self.scheme, result).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 _configured_port() -> int | None:
value = envs.MX_METRICS_PORT
if value is None:
return None
try:
port = int(value)
except (TypeError, ValueError):
logger.warning("Invalid MX_METRICS_PORT=%r; not serving /metrics", value)
return None
if port <= 0:
logger.info("MX_METRICS_PORT=%s disables the /metrics endpoint", port)
return None
return port
def _make_gauge(registry, name: str, documentation: str, labels: list[str]):
from prometheus_client import Gauge
for mode in ("mostrecent", "max"):
try:
return Gauge(
name, documentation, labels, registry=registry, multiprocess_mode=mode
)
except (ValueError, TypeError):
continue
return Gauge(name, documentation, labels, registry=registry)
def enable_metrics() -> bool:
if not _enabled():
return False
try:
multiproc_dir = _multiproc_dir()
if multiproc_dir:
_check_multiproc_dir(multiproc_dir)
return metrics._ensure()
except Exception as e: logger.warning("Failed to enable metrics: %s", e)
return False
def _check_multiproc_dir(multiproc_dir: str) -> None:
try:
os.makedirs(multiproc_dir, exist_ok=True)
except OSError as e:
logger.error(
"PROMETHEUS_MULTIPROC_DIR=%s is not usable (%s); metrics will be "
"empty. Mount it as an emptyDir in the pod manifest.",
multiproc_dir,
e,
)
def _warn_if_multiprocess_inactive(multiproc_dir: str) -> None:
try:
if any(os.scandir(multiproc_dir)):
return
except OSError:
return
logger.error(
"PROMETHEUS_MULTIPROC_DIR=%s is set but no .db files were written, so "
"prometheus_client is not in multiprocess mode and the merged endpoint "
"will be empty. get_value_class() latches at import, so the variable "
"must be set in the pod manifest before the process starts -- assigning "
"it from Python lands after the engine has already imported "
"prometheus_client.",
multiproc_dir,
)
def push_metrics_if_enabled(job: str = "modelexpress") -> None:
if not _enabled():
return
gateway = envs.MX_METRICS_PUSHGATEWAY
if not gateway:
return
if _configured_port():
logger.debug("Scrape endpoint is configured; skipping the push.")
return
try:
import socket
from prometheus_client import push_to_gateway
pod = envs.POD_UID or envs.POD_NAME or socket.gethostname()
grouping_key = {"pod": pod}
push_to_gateway(
gateway,
job=job,
grouping_key=grouping_key,
registry=metrics._exposition_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)
def _main(argv: list[str] | None = None) -> int:
import argparse
parser = argparse.ArgumentParser(prog="modelexpress.metrics")
parser.add_argument(
"--reset",
action="store_true",
help=(
"Delete stale .db files from PROMETHEUS_MULTIPROC_DIR. Run this from "
"the container entrypoint, never from worker code: an emptyDir "
"survives container restarts within a pod, but wiping it after "
"ranks have started unlinks their mmapped files."
),
)
parser.add_argument("--dir", default=None, help="Override PROMETHEUS_MULTIPROC_DIR.")
args = parser.parse_args(argv)
if args.reset:
logging.basicConfig(level=logging.INFO)
reset_multiproc_dir(args.dir)
return 0
if __name__ == "__main__":
raise SystemExit(_main())