class_name ServerLog extends RefCounted # Structured server logging (multiplayer-next.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: `[] 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