Files
Xonotic-Exporter/xonotic_exporter/prometheus.py
T
DerGrumpf 76adff71d7 Init
2026-04-26 16:56:13 +00:00

288 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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=<name> 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 (32126)."""
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)