feat(server): task 6.4 — structured logging the match and transport layers can reach

server_boot.gd's private _log could only ever see what the boot scene
itself observed: connects, disconnects, roster changes, tick overruns.
The events an operator is actually asked about - who scored, who got
kicked and why, which peer is flooding - happen inside networked_match.gd
and match_sim.gd, neither of which could reach a logger on a scene node
that gets freed at the first change_scene_to_file. scripts/server_log.gd
holds it as static state on a class_name: reachable from all three, no
autoload, no ordering dependency.

New events: goal, match_ended, kickoff, peer_kicked (previously only a
push_warning, carrying neither peer nor reason into the stream a
container captures), rate_limited, server_stalled. rate_limited fires
ONCE per peer per window rather than per packet - a flood is thousands of
packets a second and the log line must not become the amplifier the
replay recorder was capped to avoid being.

Off unless a server configures it, so a client, an editor session or a
unit-test run does not start printing server telemetry just because these
scripts loaded.

Rotation is deliberately not implemented: the server logs to stdout and
stops, because every way this is run already rotates better - docker's
json-file driver, journald, or logrotate on a redirect. A server that
also wrote and rotated its own file would fight all of them in a
container, where stdout is the interface. SERVER.md (6.6) documents the
three configurations.

Five tests on the one piece with real logic - the one-line contract.
Including log injection: a player name is attacker-controlled, and
without escaping, the name "x\n[0.000] INFO peer_kicked reason=nothing"
writes a fake event into the operator's log. Newlines are escaped rather
than dropped so the attempt stays visible.

End-to-end verification of the new events comes with 6.5, which is what
first makes a server run a match at all.
This commit is contained in:
Josh Creek
2026-08-21 17:13:11 +01:00
parent 06881f05ca
commit ec896b27ac
7 changed files with 188 additions and 22 deletions
+13 -22
View File
@@ -12,17 +12,12 @@ extends Node
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
# scene. This is just the process shell: listen, log, idle cheaply.
const LOG_LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
var _boot_ms := 0
var _last_physics_frame := 0
var _log_level := 1 # info
var config: ServerConfig = null
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
func _ready() -> void:
_boot_ms = Time.get_ticks_msec()
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
# Task 6.3. This process owns the whole command line, so it parses STRICTLY:
@@ -44,9 +39,9 @@ func _ready() -> void:
printerr("try --help")
get_tree().quit(1)
return
ServerLog.configure(String(config.get_value("log-level")))
var port := int(config.get_value("port"))
var max_clients := int(config.get_value("max-clients"))
_log_level = LOG_LEVELS[String(config.get_value("log-level"))]
NetworkManager.client_connected.connect(_on_client_connected)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
@@ -55,10 +50,15 @@ func _ready() -> void:
var err := NetworkManager.host(port, max_clients)
if err != OK:
_log("error", "server_boot_failed", {"port": port, "error": error_string(err)})
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
_log("info", "server_started", {"port": port, "max_clients": max_clients})
ServerLog.info("server_started", {
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
"min_players": int(config.get_value("min-players")),
"max_matches": int(config.get_value("max-matches")),
"arena_rotation": String(config.get_value("arena-rotation")),
})
_last_physics_frame = Engine.get_physics_frames()
@@ -72,7 +72,7 @@ func _process(_delta: float) -> void:
# that's expected quantisation, not backlog. A real overrun is the
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
if steps > 2 and _watchdog_armed:
_log("warn", "physics_overrun", {"steps": steps})
ServerLog.warn("physics_overrun", {"steps": steps})
_watchdog_armed = true
@@ -81,26 +81,17 @@ func _physics_process(_delta: float) -> void:
func _on_client_connected(peer_id: int) -> void:
_log("debug", "peer_connected", {"peer_id": peer_id})
ServerLog.debug("peer_connected", {"peer_id": peer_id})
func _on_client_disconnected(peer_id: int) -> void:
_log("debug", "peer_disconnected", {"peer_id": peer_id})
ServerLog.debug("peer_disconnected", {"peer_id": peer_id})
func _on_player_joined(peer_id: int, player_name: String) -> void:
_log("info", "player_joined", {"peer_id": peer_id, "name": player_name})
ServerLog.info("player_joined", {"peer_id": peer_id, "name": player_name, "roster": MatchNet.roster.size()})
func _on_player_left(peer_id: int) -> void:
_log("info", "player_left", {"peer_id": peer_id})
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
func _log(level: String, event: String, fields: Dictionary) -> void:
if LOG_LEVELS.get(level, 1) < _log_level:
return
var parts := PackedStringArray()
for key in fields:
parts.append("%s=%s" % [key, str(fields[key])])
var elapsed_sec := (Time.get_ticks_msec() - _boot_ms) / 1000.0
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])