A multi-target Prometheus exporter for game servers. It follows the
blackbox_exporter model —
Prometheus scrapes /probe?target=host:port&module=MODULE, and the exporter
speaks the game's query protocol to report liveness, player counts and server
info.
| Module | Protocol | Metrics |
|---|---|---|
tcp |
plain TCP connect | gameserver_up |
steam-a2s |
Source-engine A2S (UDP query) | gameserver_up, gameserver_players, gameserver_max_players, gameserver_info{name,map,game,version} |
minecraft-slp |
Minecraft Server List Ping | gameserver_up, gameserver_players, gameserver_max_players, gameserver_info{version,protocol,motd} |
Every probe also exposes gameserver_probe_duration_seconds.
make run # listens on :9116 (probe) and :9117 (health)
curl 'localhost:9116/probe?target=mc.hypixel.net:25565&module=minecraft-slp'
# gameserver_up 1
# gameserver_players 78012
# gameserver_max_players 200000
# gameserver_info{version="Requires MC 1.8-1.21",protocol="47",motd="..."} 1
# gameserver_probe_duration_seconds 0.07- Multi-target pattern: one exporter instance probes many servers. Each
scrape gets a fresh
prometheus.Registry, so per-target metrics never leak into one another — the same isolation blackbox_exporter uses. minecraft-slpis a hand-rolled implementation of the SLP protocol (VarInt codec + length-prefixed handshake/status packets → JSON status).steam-a2susesgo-a2s; A2S is UDP with a challenge/response and packet fragmentation, so a maintained library is the sensible choice over a bespoke parser.- Respects the
X-Prometheus-Scrape-Timeout-Secondsheader, so probes stay inside the scrape budget. - The exporter's own health lives on a separate port (
:9117/-/healthy,/-/ready); operational counters (gameserver_exporter_probes_total,gameserver_exporter_probe_errors_total) are on/metrics.
Scrape the list of servers and rewrite the request onto the exporter — see
deploy/prometheus-scrape-example.yaml:
scrape_configs:
- job_name: gameservers
metrics_path: /probe
params: { module: [minecraft-slp] }
static_configs:
- targets: [mc-1.example.com:25565, mc-2.example.com:25565]
relabel_configs:
- { source_labels: [__address__], target_label: __param_target }
- { source_labels: [__param_target], target_label: instance }
- { target_label: __address__, replacement: gameserver-exporter.monitoring.svc:9116 }kubectl apply -f deploy/deployment.yaml # Deployment + Service (namespace: monitoring)Why not blackbox_exporter? It speaks HTTP/TCP/ICMP/DNS, not game protocols.
This reuses the same /probe + relabel pattern (so it drops into Prometheus
identically) while adding game semantics: online players, version, map, MOTD.
Why a fresh registry per request? A single instance probes many targets;
global gauges would race and blur "whose up is this". A per-scrape registry
keeps each target's series clean and cardinality bounded.
Why hand-roll SLP but use a library for A2S? SLP is simple enough (VarInt + handshake + JSON) that a small, tested codec is clearer than a dependency; A2S's UDP challenge/response and fragmentation are error-prone enough to be worth a maintained library.
go test -race -cover ./...85% statement coverage of internal/probe, no network access beyond loopback,
about a second to run.
The Minecraft module is the part that earns its tests. It speaks the Server
List Ping protocol directly, because the wire format is VarInt-length-prefixed
and there is no maintained Go library worth the dependency — which means the
codec is hand-written, and hand-written codecs are where boundary cases go
wrong. A wrong VarInt does not return an error; it frames the next packet at
the wrong offset and the failure surfaces as an unexplained read_error.
So the suite covers:
- VarInt boundaries — every width transition (127/128, 16383/16384, and so
on),
MaxInt32, and negatives, which always occupy the full five bytes. - Refusal to trust the peer. A stream of continuation bits must not loop forever, and every length read off the wire is range-checked before it sizes an allocation: a remote server should not be able to ask this exporter for a gigabyte.
- The handshake, byte by byte — frame length, packet id, protocol version of -1, the length-prefixed host, big-endian port, and next-state 1. It also checks the requested hostname is sent rather than the resolved address, since virtual-hosted servers route on it.
- The probe end to end, against a fake server on a real loopback socket
rather than an
io.Reader. Framing bugs only appear once the bytes actually cross a connection in two separate writes. - Every error label the probe can return:
bad_target, the dial-class errors,read_errorandparse_erroreach have a test that produces them. - Scrape-timeout resolution. Prometheus sends bare seconds in a header, not a Go duration; accepting only the Go form would silently ignore every real scrape, and a probe that outlives its scrape budget is worse than a failed one.
The MOTD becomes a metric label, so it is asserted to stay clamped regardless
of what the server sends, and gameserver_info is asserted to be exactly one
series — more than one means a label is varying per scrape.
gameserver_infocarriesmotd/namelabels — bounded (MOTD clamped to 96 chars), but watch cardinality if MOTDs are dynamic across many servers.- Probes are synchronous per request (standard for the blackbox pattern); mind
scrape_timeoutfor slow/unreachable targets.
MIT — see LICENSE.