Init
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
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 (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)
|
||||
Reference in New Issue
Block a user