Files
CosmicClash/Game/scripts/server_log.gd
T
Josh Creek ec896b27ac 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.
2026-08-21 17:13:11 +01:00

93 lines
3.5 KiB
GDScript

class_name ServerLog
extends RefCounted
# Structured server logging (multiplayer-todo.md task 6.4).
#
# Extracted from server_boot.gd's private `_log`, which could only ever see
# what the boot scene itself observed: connects, disconnects, roster changes
# and tick overruns. The events an operator actually asks 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
# living on a scene node that gets freed at the first change_scene_to_file.
# Static state on a class_name is reachable from all three with no autoload
# and no ordering dependency.
#
# Format: `[<seconds since boot>] LEVEL event key=value key=value`. One line
# per event, no wrapping, no multi-line payloads, keys before values — so
# `grep 'player_joined'` and `awk` both work on it without a parser.
#
# ROTATION IS DELIBERATELY NOT IMPLEMENTED HERE. The server logs to stdout and
# stops there, because every way this is actually run already owns log
# rotation and does it better: `docker logs` with its json-file driver's
# max-size/max-file, journald under the systemd unit, or a redirect into
# logrotate for a bare process. A server that also writes and rotates its own
# file would duplicate all of that and fight it in a container, where stdout is
# the interface. SERVER.md documents the three configurations; task 6.6 ships
# them. Godot's own `debug/file_logging` remains available for anyone who wants
# a file as well, and it rotates via `max_log_files`.
const LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
static var _level := 1 # info
static var _boot_ms := -1
static var _enabled := false # servers only; a client process logs nothing
# Called once by the process that owns the command line. Until then nothing is
# emitted at all — a client, an editor session or a unit-test run must not
# start printing server telemetry just because it loaded these scripts.
static func configure(level_name: String) -> void:
_level = int(LEVELS.get(level_name, 1))
_boot_ms = Time.get_ticks_msec()
_enabled = true
static func is_enabled() -> bool:
return _enabled
static func level_name() -> String:
for key in LEVELS:
if int(LEVELS[key]) == _level:
return key
return "info"
static func debug(event: String, fields: Dictionary = {}) -> void:
_write("debug", event, fields)
static func info(event: String, fields: Dictionary = {}) -> void:
_write("info", event, fields)
static func warn(event: String, fields: Dictionary = {}) -> void:
_write("warn", event, fields)
static func error(event: String, fields: Dictionary = {}) -> void:
_write("error", event, fields)
static func _write(level: String, event: String, fields: Dictionary) -> void:
if not _enabled:
return
if int(LEVELS.get(level, 1)) < _level:
return
var parts := PackedStringArray()
for key in fields:
parts.append("%s=%s" % [key, _flatten(fields[key])])
var elapsed_sec := float(Time.get_ticks_msec() - _boot_ms) / 1000.0
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])
# One line per event is the whole contract, so a value containing a space or a
# newline would break every downstream `awk '{print $4}'`. Quote rather than
# silently mangle: a player name is operator-supplied and can contain anything.
static func _flatten(value: Variant) -> String:
var text := str(value)
text = text.replace("\n", "\\n").replace("\r", "\\r")
if " " in text or text.is_empty():
return "\"%s\"" % text.replace("\"", "'")
return text