Quick answer: Emit every log entry as a JSON object with mandatory fields: timestamp, level, message, session_id, player_id, match_id, and server_id. Generate a unique session ID when each player connects and thread it through every log call. Sample high-frequency events (position updates, input state) at a configurable rate to control volume. Ship logs to a centralized backend (Loki or Elasticsearch) via a log shipper and build dashboards for error rates, session timelines, and cross-player correlation.
Unstructured logs are useless at scale. When your multiplayer server handles 64 players producing hundreds of events per second, printf("player moved to %f %f") gives you a wall of text that nobody can search. Structured logging — JSON objects with consistent fields — turns that wall into a queryable database. You can filter by player, by match, by error level, by time window, and find the exact sequence of events that led to a desync, a crash, or a cheat.
Designing the Log Schema
Every log entry needs a set of standard fields. These fields are your query dimensions — the columns you filter on when investigating an issue. Start with this schema and extend it as needed.
// Structured log entry — Go example
type LogEntry struct {
Timestamp string `json:"ts"`
Level string `json:"level"`
Message string `json:"msg"`
SessionID string `json:"session_id"`
PlayerID string `json:"player_id"`
MatchID string `json:"match_id"`
ServerID string `json:"server_id"`
Component string `json:"component"`
Data any `json:"data,omitempty"`
}
// Example output:
// {"ts":"2026-04-10T14:32:01.003Z","level":"info",
// "msg":"player_joined","session_id":"a1b2c3d4",
// "player_id":"usr_9f8e7d","match_id":"match_xyz",
// "server_id":"us-east-1-04","component":"lobby",
// "data":{"team":"blue","latency_ms":42}}
The session_id is the correlation ID. Generate a UUID when the player connects and attach it to every log entry for that session. When a player reports a bug, their session ID is included in the report metadata (Bugnet captures this automatically). You search your log backend for that session ID and see every event the player experienced, in order.
The match_id lets you view all events across all players in a single match. This is critical for debugging desync: pull every log entry for a match, sort by timestamp, and look for the moment where two players’ states diverge.
Correlation IDs in Practice
Thread the session ID through your entire server codebase. Every function that writes a log entry should accept a context or logger that already has the session ID attached. In Go, use slog with a logger per connection. In C#, use a scoped logger. In C++, pass a session context struct.
// Go — per-connection logger with slog
func handleConnection(conn net.Conn) {
sessionID := uuid.New().String()
logger := slog.With(
"session_id", sessionID,
"server_id", serverID,
"remote_addr", conn.RemoteAddr().String(),
)
logger.Info("player_connected")
// Pass logger to all handlers
player := NewPlayer(conn, logger)
player.Run()
}
func (p *Player) handleMove(pos Vector3) {
p.logger.Info("player_moved",
"x", pos.X,
"y", pos.Y,
"z", pos.Z,
"match_id", p.matchID,
)
}
The pattern is simple: create a logger with baseline fields when the connection is established, and use that logger everywhere. No function needs to remember to include the session ID — it is already attached.
Log Levels for Game Servers
Use four levels: debug (verbose tick-by-tick data, disabled in production), info (significant events: connect, disconnect, match start, match end, achievement unlock), warn (recoverable problems: high latency, reconnection, rate limit hit), and error (unrecoverable problems: crash, desync detected, data corruption, failed database write).
In production, emit info and above. Enable debug-level logging on specific servers or for specific sessions when investigating a reported issue. Some log backends support dynamic level changes — Loki with Grafana Agent lets you set per-label log levels without restarting the server.
Never log at error level for expected conditions. A player disconnecting is not an error — it is a normal event. A matchmaking queue taking longer than expected is a warning, not an error. Reserve error for things that should never happen in correct code. This keeps your error dashboards clean and actionable.
Sampling High-Frequency Events
Some events fire every tick: position updates, input state, physics snapshots. Logging all of them at 60 ticks per second for 64 players produces ~3,840 log entries per second per match. At 100 bytes each, that is 380 KB/s per match, or over a gigabyte per hour. This will overwhelm your log backend and your budget.
Use sampling. Log every Nth occurrence of high-frequency events, where N is configurable per event type. A good default: log player position every 60 ticks (once per second), input state every 120 ticks (every two seconds), and physics snapshots every 300 ticks (every five seconds). Always log the full event — without sampling — when an error or anomaly is detected (e.g., a position that is outside the map bounds, or an input sequence that is physically impossible).
Store the sampling rate in the log entry itself so you can account for it during analysis. If you sampled at 1/60 and see 10 position events in a 10-second window, you know the player was actually producing 600 events in that window.
“Logging everything is not observability — it is data hoarding. Sample aggressively during normal operation, log everything during anomalies. Your log backend should contain signals, not noise.”
Shipping to a Centralized Backend
Your game servers should write structured JSON to stdout. A log shipper running as a sidecar or daemon picks up the output and forwards it to your log backend. This separation means your game server code never needs to know about the log backend — it just writes to stdout, and the infrastructure handles the rest.
For Loki: use Promtail or Grafana Agent as the shipper. Configure labels for server_id, region, and environment (production, staging). Query with LogQL in Grafana: {server_id="us-east-1-04"} |= "desync" | json | player_id = "usr_9f8e7d".
For Elasticsearch: use Filebeat or Fluent Bit. Index by date and server. Query with KQL in Kibana: session_id: "a1b2c3d4" AND level: "error". Elasticsearch is more expensive to operate but excels at full-text search and complex aggregations.
For indie studios on a budget, Loki with Grafana Cloud’s free tier (50 GB logs/month) is often enough for development and early access. Scale to a self-hosted Loki instance or Elasticsearch when your player base grows.
Querying Across Players
The real power of structured logging shows when you query across multiple players. Some example queries that are easy with structured logs and impossible with printf:
“Show me every error in the last hour, grouped by match.” This tells you whether errors are concentrated in specific matches (likely a map bug) or distributed evenly (likely a server bug).
“Show me the event timeline for player X and player Y in the same match.” Interleave two players’ logs by timestamp and you can see exactly when they desynchronized.
“Show me all sessions where latency exceeded 200ms for more than 10 seconds.” This identifies network quality issues before players report them.
Build these as saved queries or dashboard panels. The engineering team should check them daily during active development and weekly during stable periods. When a player files a bug report, the first thing your support engineer does is pull that player’s session log and scan for errors and warnings.
Retention and Cost
Log retention is a cost decision. Keep error-level logs for 90 days, warn for 30 days, info for 14 days, and debug for 3 days. Most investigations happen within the first week of an incident — if you haven’t looked at a log entry in 14 days, you probably never will. Configure your log backend’s retention policies accordingly and review storage costs monthly.
Related Issues
For hitbox debugging that benefits from structured collision logs, see How to Debug Hitbox/Hurtbox Mismatches in Action Games. For using log data to build a health scorecard, check How to Set Up a Game Health Scorecard.
A session ID in every log entry is the single best debugging investment you can make in a multiplayer game.