Files
Erupe/tools/loganalyzer/connections.go
2026-02-06 13:02:38 +01:00

461 lines
14 KiB
Go

package main
import (
"flag"
"fmt"
"os"
"sort"
"strings"
"time"
)
// PlayerSession represents a single player's connection session to the server.
//
// A session is identified by the combination of channel and IP:port, tracking
// all activities from when a player connects until they disconnect.
type PlayerSession struct {
Name string // Player name
IPPort string // Client IP address and port (e.g., "192.168.1.1:12345")
Channel string // Server channel (e.g., "channel-4")
FirstSeen time.Time // Timestamp of first activity
LastSeen time.Time // Timestamp of last activity
Activities []string // List of player activities
Stages []string // List of stage changes
Objects []string // List of objects broadcast by this player
Errors int // Number of errors encountered during session
SaveCount int // Number of save operations performed
}
// ConnectionStats aggregates statistics about player connections across all sessions.
//
// This structure tracks high-level metrics useful for understanding server usage
// patterns, peak times, and common connection issues.
type ConnectionStats struct {
TotalConnections int // Total number of player sessions
UniqueIPs map[string]int // IP addresses to connection count
UniquePlayers map[string]bool // Set of unique player names
ConnectionsPerDay map[string]int // Date to connection count
ChannelDistribution map[string]int // Channel to connection count
DisconnectReasons map[string]int // Disconnect reason to count
}
// runConnections implements the connections command for analyzing player connection patterns.
//
// The connections command tracks player sessions from connection to disconnection, providing
// both aggregate statistics and individual session details. It can identify patterns in
// player activity, track connection issues, and analyze channel usage.
//
// Features:
// - Tracks individual player sessions with timestamps and activities
// - Aggregates connection statistics (total, unique players, IPs)
// - Shows channel distribution and peak connection times
// - Analyzes disconnect reasons
// - Supports filtering by player name
// - Provides verbose session details including objects and stage changes
//
// Options:
// - f: Path to log file (default: "logs/erupe.log")
// - player: Filter sessions by player name (case-insensitive substring match)
// - sessions: Show individual player sessions
// - stats: Show connection statistics (default: true)
// - v: Verbose output including objects and stage changes
//
// Examples:
//
// runConnections([]string{"-stats"})
// runConnections([]string{"-sessions", "-v"})
// runConnections([]string{"-player", "Sarah", "-sessions"})
func runConnections(args []string) {
fs := flag.NewFlagSet("connections", flag.ExitOnError)
logFile := fs.String("f", "logs/erupe.log", "Path to log file")
player := fs.String("player", "", "Filter by player name")
showSessions := fs.Bool("sessions", false, "Show individual player sessions")
showStats := fs.Bool("stats", true, "Show connection statistics")
verbose := fs.Bool("v", false, "Verbose output")
fs.Parse(args)
stats := &ConnectionStats{
UniqueIPs: make(map[string]int),
UniquePlayers: make(map[string]bool),
ConnectionsPerDay: make(map[string]int),
ChannelDistribution: make(map[string]int),
DisconnectReasons: make(map[string]int),
}
sessions := make(map[string]*PlayerSession) // key: channel-IP:port
err := StreamLogFile(*logFile, func(entry *LogEntry) error {
// Track player activities
if strings.Contains(entry.Message, "Sending existing stage objects to") {
// Extract player name
parts := strings.Split(entry.Message, " to ")
if len(parts) == 2 {
playerName := strings.TrimSpace(parts[1])
// Extract IP:port and channel from logger
sessionKey := extractSessionKey(entry.Logger)
if sessionKey != "" {
session, exists := sessions[sessionKey]
if !exists {
session = &PlayerSession{
Name: playerName,
IPPort: extractIPPort(entry.Logger),
Channel: extractChannel(entry.Logger),
FirstSeen: entry.Timestamp,
Activities: make([]string, 0),
Stages: make([]string, 0),
Objects: make([]string, 0),
}
sessions[sessionKey] = session
stats.TotalConnections++
stats.UniquePlayers[playerName] = true
if session.IPPort != "" {
ip := strings.Split(session.IPPort, ":")[0]
stats.UniqueIPs[ip]++
}
if session.Channel != "" {
stats.ChannelDistribution[session.Channel]++
}
day := entry.Timestamp.Format("2006-01-02")
stats.ConnectionsPerDay[day]++
}
session.LastSeen = entry.Timestamp
session.Activities = append(session.Activities, entry.Message)
}
}
}
// Track broadcasts
if strings.Contains(entry.Message, "Broadcasting new object:") {
sessionKey := extractSessionKey(entry.Logger)
if session, exists := sessions[sessionKey]; exists {
parts := strings.Split(entry.Message, "Broadcasting new object: ")
if len(parts) == 2 {
session.Objects = append(session.Objects, parts[1])
}
}
}
// Track stage changes
if strings.Contains(entry.Message, "Sending notification to old stage clients") {
sessionKey := extractSessionKey(entry.Logger)
if session, exists := sessions[sessionKey]; exists {
session.Stages = append(session.Stages, "Stage changed")
}
}
// Track save operations
if strings.Contains(entry.Message, "Wrote recompressed savedata back to DB") {
sessionKey := extractSessionKey(entry.Logger)
if session, exists := sessions[sessionKey]; exists {
session.SaveCount++
}
}
// Track disconnections
if strings.Contains(entry.Message, "Error on ReadPacket, exiting recv loop") ||
strings.Contains(entry.Message, "Error reading packet") {
sessionKey := extractSessionKey(entry.Logger)
if session, exists := sessions[sessionKey]; exists {
session.Errors++
}
// Extract disconnect reason
if entry.Error != "" {
reason := entry.Error
if strings.Contains(reason, "connection reset by peer") {
reason = "connection reset by peer"
} else if strings.Contains(reason, "timeout") {
reason = "timeout"
}
stats.DisconnectReasons[reason]++
}
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error processing log file: %v\n", err)
os.Exit(1)
}
// Filter by player if specified
if *player != "" {
filteredSessions := make(map[string]*PlayerSession)
for key, session := range sessions {
if strings.Contains(strings.ToLower(session.Name), strings.ToLower(*player)) {
filteredSessions[key] = session
}
}
sessions = filteredSessions
}
// Display results
if *showStats {
printConnectionStats(stats)
}
if *showSessions {
printPlayerSessions(sessions, *verbose)
}
}
// printConnectionStats displays aggregate connection statistics in a formatted report.
//
// The report includes:
// - Total connections and unique player/IP counts
// - Channel distribution showing which channels are most popular
// - Connections per day to identify peak usage days
// - Disconnect reasons to identify common connection issues
// - Top IP addresses by connection count
//
// All sections are sorted for easy analysis (channels alphabetically,
// days chronologically, others by frequency).
//
// Parameters:
// - stats: ConnectionStats structure containing aggregated data
func printConnectionStats(stats *ConnectionStats) {
fmt.Printf("=== Connection Statistics ===\n\n")
fmt.Printf("Total Connections: %d\n", stats.TotalConnections)
fmt.Printf("Unique Players: %d\n", len(stats.UniquePlayers))
fmt.Printf("Unique IP Addresses: %d\n", len(stats.UniqueIPs))
if len(stats.ChannelDistribution) > 0 {
fmt.Printf("\n--- Channel Distribution ---\n")
// Sort channels
type channelPair struct {
name string
count int
}
var channels []channelPair
for name, count := range stats.ChannelDistribution {
channels = append(channels, channelPair{name, count})
}
sort.Slice(channels, func(i, j int) bool {
return channels[i].name < channels[j].name
})
for _, ch := range channels {
fmt.Printf(" %s: %d connections\n", ch.name, ch.count)
}
}
if len(stats.ConnectionsPerDay) > 0 {
fmt.Printf("\n--- Connections Per Day ---\n")
// Sort by date
type dayPair struct {
date string
count int
}
var days []dayPair
for date, count := range stats.ConnectionsPerDay {
days = append(days, dayPair{date, count})
}
sort.Slice(days, func(i, j int) bool {
return days[i].date < days[j].date
})
for _, day := range days {
fmt.Printf(" %s: %d connections\n", day.date, day.count)
}
}
if len(stats.DisconnectReasons) > 0 {
fmt.Printf("\n--- Disconnect Reasons ---\n")
// Sort by count
type reasonPair struct {
reason string
count int
}
var reasons []reasonPair
for reason, count := range stats.DisconnectReasons {
reasons = append(reasons, reasonPair{reason, count})
}
sort.Slice(reasons, func(i, j int) bool {
return reasons[i].count > reasons[j].count
})
for _, r := range reasons {
fmt.Printf(" %s: %d times\n", r.reason, r.count)
}
}
if len(stats.UniqueIPs) > 0 {
fmt.Printf("\n--- Top IP Addresses ---\n")
type ipPair struct {
ip string
count int
}
var ips []ipPair
for ip, count := range stats.UniqueIPs {
ips = append(ips, ipPair{ip, count})
}
sort.Slice(ips, func(i, j int) bool {
return ips[i].count > ips[j].count
})
// Show top 10
limit := 10
if len(ips) < limit {
limit = len(ips)
}
for i := 0; i < limit; i++ {
fmt.Printf(" %s: %d connections\n", ips[i].ip, ips[i].count)
}
}
}
// printPlayerSessions displays detailed information about individual player sessions.
//
// For each session, displays:
// - Player name, channel, and IP:port
// - Connection duration (first seen to last seen)
// - Number of save operations and errors
// - Objects and stage changes (if verbose=true)
//
// Sessions are sorted chronologically by first seen time.
//
// Parameters:
// - sessions: Map of session keys to PlayerSession data
// - verbose: Whether to show detailed activity information
func printPlayerSessions(sessions map[string]*PlayerSession, verbose bool) {
// Sort sessions by first seen
type sessionPair struct {
key string
session *PlayerSession
}
var pairs []sessionPair
for key, session := range sessions {
pairs = append(pairs, sessionPair{key, session})
}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].session.FirstSeen.Before(pairs[j].session.FirstSeen)
})
fmt.Printf("\n\n=== Player Sessions ===\n")
fmt.Printf("Total Sessions: %d\n\n", len(sessions))
for idx, pair := range pairs {
session := pair.session
duration := session.LastSeen.Sub(session.FirstSeen)
fmt.Printf("%s\n", strings.Repeat("-", 80))
fmt.Printf("Session #%d: %s\n", idx+1, session.Name)
fmt.Printf("%s\n", strings.Repeat("-", 80))
fmt.Printf("Channel: %s\n", session.Channel)
fmt.Printf("IP:Port: %s\n", session.IPPort)
fmt.Printf("First Seen: %s\n", session.FirstSeen.Format("2006-01-02 15:04:05"))
fmt.Printf("Last Seen: %s\n", session.LastSeen.Format("2006-01-02 15:04:05"))
fmt.Printf("Duration: %s\n", formatDuration(duration))
fmt.Printf("Save Operations: %d\n", session.SaveCount)
fmt.Printf("Errors: %d\n", session.Errors)
if verbose {
if len(session.Objects) > 0 {
fmt.Printf("\nObjects: %s\n", strings.Join(session.Objects, ", "))
}
if len(session.Stages) > 0 {
fmt.Printf("Stage Changes: %d\n", len(session.Stages))
}
}
fmt.Println()
}
}
// extractSessionKey extracts a unique session identifier from a logger string.
//
// Logger format: "main.channel-X.IP:port"
// Returns: "channel-X.IP:port"
//
// This key uniquely identifies a player session by combining the channel
// and the client's IP:port combination.
//
// Parameters:
// - logger: The logger field from a log entry
//
// Returns:
// - A session key string, or empty string if the format is invalid
func extractSessionKey(logger string) string {
// Logger format: "main.channel-X.IP:port"
parts := strings.Split(logger, ".")
if len(parts) >= 3 {
return strings.Join(parts[1:], ".")
}
return ""
}
// extractIPPort extracts the client IP address and port from a logger string.
//
// Logger format: "main.channel-X.A.B.C.D:port" where A.B.C.D is the IPv4 address
// Returns: "A.B.C.D:port"
//
// Parameters:
// - logger: The logger field from a log entry
//
// Returns:
// - The IP:port string, or empty string if extraction fails
func extractIPPort(logger string) string {
parts := strings.Split(logger, ".")
if len(parts) >= 4 {
// Last part might be IP:port
lastPart := parts[len(parts)-1]
if strings.Contains(lastPart, ":") {
// Reconstruct IP:port (handle IPv4)
if len(parts) >= 4 {
ip := strings.Join(parts[len(parts)-4:len(parts)-1], ".")
port := lastPart
return ip + ":" + port
}
}
}
return ""
}
// extractChannel extracts the channel name from a logger string.
//
// Logger format: "main.channel-X.IP:port"
// Returns: "channel-X"
//
// Parameters:
// - logger: The logger field from a log entry
//
// Returns:
// - The channel name (e.g., "channel-4"), or empty string if not found
func extractChannel(logger string) string {
if strings.Contains(logger, "channel-") {
parts := strings.Split(logger, "channel-")
if len(parts) >= 2 {
channelPart := strings.Split(parts[1], ".")[0]
return "channel-" + channelPart
}
}
return ""
}
// formatDuration formats a time duration into a human-readable string.
//
// The format varies based on duration:
// - Less than 1 minute: "N seconds"
// - Less than 1 hour: "N.N minutes"
// - 1 hour or more: "N.N hours"
//
// Parameters:
// - d: The duration to format
//
// Returns:
// - A human-readable string representation of the duration
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%.0f seconds", d.Seconds())
} else if d < time.Hour {
return fmt.Sprintf("%.1f minutes", d.Minutes())
} else {
return fmt.Sprintf("%.1f hours", d.Hours())
}
}