""" Prometheus metric definitions and exposition helpers. We use the low-level prometheus_client primitives so we can build a *per-scrape* registry (no global state) — this is the correct approach for a multi-target exporter where each /metrics?target= request returns metrics for one server. """ from __future__ import annotations from prometheus_client import ( CollectorRegistry, Gauge, generate_latest, CONTENT_TYPE_LATEST, ) from .metrics_parser import XonoticMetrics # ── Metric definitions (names, help strings, labels) ───────────────────────── _METRIC_DEFS: list[tuple[str, str]] = [ ("xonotic_up", "1 if the server was reachable, 0 otherwise"), ("xonotic_sv_public", "Value of sv_public cvar (1 = listed on master server)"), ("xonotic_ping_seconds", "Round-trip time to the server in seconds"), ("xonotic_timing_cpu_percent", "Server CPU usage percentage reported by status"), ("xonotic_timing_lost_percent", "Percentage of packets lost reported by status"), ("xonotic_timing_offset_avg_ms", "Average timing offset in milliseconds"), ("xonotic_timing_offset_max_ms", "Maximum timing offset in milliseconds"), ("xonotic_timing_offset_sdev_ms", "Standard deviation of timing offset in milliseconds"), ("xonotic_players_active", "Number of active (scoring) players"), ("xonotic_players_spectators", "Number of spectators"), ("xonotic_players_bots", "Number of bots"), ("xonotic_players_total", "Total players connected (active + spectators + bots)"), ("xonotic_players_max", "Maximum player slots on the server"), ] def build_registry( server_name: str, metrics: XonoticMetrics | None, *, up: bool, extra_labels: dict[str, str] | None = None, ) -> tuple[CollectorRegistry, bytes]: """ Build a fresh :class:`CollectorRegistry` populated with *metrics* and return ``(registry, exposition_bytes)``. Parameters ---------- server_name: Value of the ``instance`` label attached to every metric. metrics: Scraped metrics object. Pass ``None`` when the server is unreachable (only ``xonotic_up`` will be emitted, set to 0). up: Whether the scrape succeeded. extra_labels: Additional constant labels to attach to every metric (optional). """ registry = CollectorRegistry(auto_describe=False) labels = {"instance": server_name, **(extra_labels or {})} def _gauge(name: str, helpstr: str, value: float) -> None: g = Gauge(name, helpstr, labelnames=list(labels), registry=registry) g.labels(**labels).set(value) # always emit "up" _gauge("xonotic_up", "1 if the server was reachable, 0 otherwise", 1.0 if up else 0.0) if metrics is not None and up: _gauge("xonotic_sv_public", "Value of sv_public cvar", metrics.sv_public) _gauge("xonotic_ping_seconds", "Round-trip time in seconds", metrics.ping) _gauge("xonotic_timing_cpu_percent", "Server CPU usage %", metrics.timing_cpu) _gauge("xonotic_timing_lost_percent", "Packet loss %", metrics.timing_lost) _gauge("xonotic_timing_offset_avg_ms", "Avg timing offset ms", metrics.timing_offset_avg) _gauge("xonotic_timing_offset_max_ms", "Max timing offset ms", metrics.timing_offset_max) _gauge("xonotic_timing_offset_sdev_ms","Timing offset sdev ms", metrics.timing_offset_sdev) _gauge("xonotic_players_active", "Active (scoring) players", metrics.players_active) _gauge("xonotic_players_spectators", "Spectators", metrics.players_spectators) _gauge("xonotic_players_bots", "Bots", metrics.players_bots) _gauge( "xonotic_players_total", "Total connected (active + spectators + bots)", metrics.players_active + metrics.players_spectators + metrics.players_bots, ) _gauge("xonotic_players_max", "Max player slots", metrics.players_max) raw = generate_latest(registry) return registry, raw CONTENT_TYPE = CONTENT_TYPE_LATEST def _safe_label(value: str) -> str: """Keep only printable ASCII characters (32–126).""" r = "".join(c for c in value if 32 <= ord(c) <= 126).strip() if r: return r return "Anonymous" def build_player_geo_registry( server_name: str, players: list, # list[PlayerRow] geo_results: dict, # dict[ip, GeoResult] ) -> bytes: """ Emits xonotic_player_info and xonotic_player_geo metrics. One time series per connected public-IP player. """ registry = CollectorRegistry(auto_describe=False) info_gauge = Gauge( "xonotic_player_info", "Connected player metadata", labelnames=["instance", "ip", "port", "slot", "name", "country", "city"], registry=registry, ) geo_gauge = Gauge( "xonotic_player_geo", "Connected player geolocation (lat/lon as label, value always 1)", labelnames=["instance", "ip", "name", "country", "city", "lat", "lon", "ping"], registry=registry, ) ping_gauge = Gauge( "xonotic_player_ping", "Per-player ping to the server in ms", labelnames=["instance", "ip", "name"], registry=registry, ) pl_gauge = Gauge( "xonotic_player_packetloss_percent", "Per-player packet loss percentage", labelnames=["instance", "ip", "name"], registry=registry, ) for player in players: if player.is_bot or player.ip == "botclient": continue geo = geo_results.get(player.ip) if geo is None: continue # private IP — excluded country = geo.country city = geo.city lat = str(round(geo.lat, 4)) lon = str(round(geo.lon, 4)) info_gauge.labels( instance=server_name, ip=player.ip, port=player.port, slot=player.slot, name=_safe_label(player.name), country=country, city=city ).set(1) geo_gauge.labels( instance=server_name, ip=player.ip, name=_safe_label(player.name), country=country, city=city, lat=lat, lon=lon, ping=str(player.ping) ).set(1) ping_gauge.labels( instance=server_name, ip=player.ip, name=_safe_label(player.name), ).set(player.ping) pl_gauge.labels( instance=server_name, ip=player.ip, name=_safe_label(player.name), ).set(player.packetloss) return generate_latest(registry) def build_match_registry( server_name: str, metrics: XonoticMetrics, match_meta: dict, ) -> bytes: """ Emits match-level and per-player metrics for the match dashboard. """ registry = CollectorRegistry(auto_describe=False) labels = {"instance": server_name} def _gauge(name, helpstr, value): g = Gauge(name, helpstr, labelnames=list(labels), registry=registry) g.labels(**labels).set(value) # match metadata _gauge("xonotic_match_timelimit_seconds", "Match time limit in seconds", match_meta.get("timelimit", 0) * 60) _gauge("xonotic_match_fraglimit", "Frag limit for current match", match_meta.get("fraglimit", 0)) _gauge("xonotic_match_teamplay", "Teamplay mode (0=FFA)", match_meta.get("teamplay", 0)) # map name as a label on a info metric map_gauge = Gauge( "xonotic_match_info", "Match info — map name as label", labelnames=["instance", "map"], registry=registry, ) map_gauge.labels(instance=server_name, map=metrics.map_name).set(1) # per-player metrics frag_gauge = Gauge( "xonotic_match_player_frags", "Current frag count per player", labelnames=["instance", "name", "slot"], registry=registry, ) spec_gauge = Gauge( "xonotic_match_spectator", "1 if player is spectating", labelnames=["instance", "name", "slot"], registry=registry, ) conn_gauge = Gauge( "xonotic_match_player_connection", "Player connection stats", labelnames=["instance", "name", "slot", "ping", "packetloss", "time_seconds"], registry=registry, ) for player in metrics.players: if player.is_bot: continue safe_name = _safe_label(player.name) plabels = dict(instance=server_name, name=safe_name, slot=player.slot) if player.is_spectator: spec_gauge.labels(**plabels).set(1) else: frag_gauge.labels(**plabels).set(player.frags) conn_gauge.labels( instance=server_name, name=safe_name, slot=player.slot, ping=str(player.ping), packetloss=str(player.packetloss), time_seconds=str(player.time_seconds), ).set(1) return generate_latest(registry)