mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
feat(loganalyzer): complete tool suite for analysing erupe logs.
This commit is contained in:
6
tools/loganalyzer/.gitignore
vendored
Normal file
6
tools/loganalyzer/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Binary
|
||||||
|
loganalyzer
|
||||||
|
loganalyzer.exe
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*.test.log
|
||||||
60
tools/loganalyzer/Makefile
Normal file
60
tools/loganalyzer/Makefile
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
.PHONY: build clean install test help
|
||||||
|
|
||||||
|
# Default log file path
|
||||||
|
LOGFILE ?= ../../erupe.log
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
build:
|
||||||
|
@echo "Building loganalyzer..."
|
||||||
|
@go build -o loganalyzer
|
||||||
|
|
||||||
|
# Install the binary to GOPATH/bin
|
||||||
|
install:
|
||||||
|
@echo "Installing loganalyzer..."
|
||||||
|
@go install
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning..."
|
||||||
|
@rm -f loganalyzer
|
||||||
|
|
||||||
|
# Run quick tests
|
||||||
|
test: build
|
||||||
|
@echo "Running basic tests..."
|
||||||
|
@./loganalyzer stats -f $(LOGFILE) > /dev/null && echo "✓ stats command works"
|
||||||
|
@./loganalyzer errors -f $(LOGFILE) -summary > /dev/null && echo "✓ errors command works"
|
||||||
|
@./loganalyzer connections -f $(LOGFILE) > /dev/null && echo "✓ connections command works"
|
||||||
|
@./loganalyzer filter -f $(LOGFILE) -count > /dev/null && echo "✓ filter command works"
|
||||||
|
@echo "All tests passed!"
|
||||||
|
|
||||||
|
# Quick stats
|
||||||
|
stats: build
|
||||||
|
@./loganalyzer stats -f $(LOGFILE)
|
||||||
|
|
||||||
|
# Quick error summary
|
||||||
|
errors: build
|
||||||
|
@./loganalyzer errors -f $(LOGFILE) -summary
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
help:
|
||||||
|
@echo "Erupe Log Analyzer - Makefile"
|
||||||
|
@echo ""
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " build - Build the binary (default)"
|
||||||
|
@echo " install - Install to GOPATH/bin"
|
||||||
|
@echo " clean - Remove build artifacts"
|
||||||
|
@echo " test - Run basic functionality tests"
|
||||||
|
@echo " stats - Quick stats of log file"
|
||||||
|
@echo " errors - Quick error summary"
|
||||||
|
@echo " help - Show this help"
|
||||||
|
@echo ""
|
||||||
|
@echo "Variables:"
|
||||||
|
@echo " LOGFILE - Path to log file (default: ../../erupe.log)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Examples:"
|
||||||
|
@echo " make build"
|
||||||
|
@echo " make stats"
|
||||||
|
@echo " make test LOGFILE=/path/to/custom.log"
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
.DEFAULT_GOAL := build
|
||||||
119
tools/loganalyzer/QUICK_REFERENCE.md
Normal file
119
tools/loganalyzer/QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Quick Reference Guide
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/loganalyzer
|
||||||
|
go build -o loganalyzer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
### View Statistics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./loganalyzer stats -f ../../erupe.log
|
||||||
|
./loganalyzer stats -f ../../erupe.log -detailed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Errors only
|
||||||
|
./loganalyzer filter -f ../../erupe.log -level error
|
||||||
|
|
||||||
|
# Last hour
|
||||||
|
./loganalyzer filter -f ../../erupe.log -since 1h
|
||||||
|
|
||||||
|
# Last 50 entries
|
||||||
|
./loganalyzer filter -f ../../erupe.log -tail 50
|
||||||
|
|
||||||
|
# Search message
|
||||||
|
./loganalyzer filter -f ../../erupe.log -msg "connection reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyze Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Error summary
|
||||||
|
./loganalyzer errors -f ../../erupe.log -summary
|
||||||
|
|
||||||
|
# Detailed with stack traces
|
||||||
|
./loganalyzer errors -f ../../erupe.log -detailed -stack
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track Connections
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connection stats
|
||||||
|
./loganalyzer connections -f ../../erupe.log
|
||||||
|
|
||||||
|
# Player sessions
|
||||||
|
./loganalyzer connections -f ../../erupe.log -sessions
|
||||||
|
|
||||||
|
# Specific player
|
||||||
|
./loganalyzer connections -f ../../erupe.log -player "PlayerName" -sessions -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Follow Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Like tail -f
|
||||||
|
./loganalyzer tail -f ../../erupe.log
|
||||||
|
|
||||||
|
# Only errors
|
||||||
|
./loganalyzer tail -f ../../erupe.log -level error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Troubleshooting a crash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Check recent errors
|
||||||
|
./loganalyzer filter -f erupe.log -level error -tail 20
|
||||||
|
|
||||||
|
# 2. Analyze error patterns
|
||||||
|
./loganalyzer errors -f erupe.log -detailed -stack
|
||||||
|
|
||||||
|
# 3. Check what was happening before crash
|
||||||
|
./loganalyzer filter -f erupe.log -since "2025-11-12T23:00:00Z" -tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Player investigation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Find player sessions
|
||||||
|
./loganalyzer connections -f erupe.log -player "PlayerName" -sessions -v
|
||||||
|
|
||||||
|
# 2. Check errors for that player
|
||||||
|
./loganalyzer filter -f erupe.log -logger "*PlayerName*"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Real-time error monitoring
|
||||||
|
./loganalyzer tail -f erupe.log -level error
|
||||||
|
|
||||||
|
# Daily statistics
|
||||||
|
./loganalyzer stats -f erupe.log -detailed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. **Pipe to less for long output**: `./loganalyzer filter -f erupe.log | less -R`
|
||||||
|
2. **Save to file**: `./loganalyzer stats -f erupe.log > stats.txt`
|
||||||
|
3. **Combine with grep**: `./loganalyzer filter -f erupe.log -level error | grep "mail"`
|
||||||
|
4. **Use -count for quick checks**: `./loganalyzer filter -f erupe.log -level error -count`
|
||||||
|
5. **Time ranges**: `-since` accepts both absolute (RFC3339) and relative (1h, 30m) times
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Default output is colorized:
|
||||||
|
|
||||||
|
- Errors: Red
|
||||||
|
- Warnings: Yellow
|
||||||
|
- Info: Green
|
||||||
|
|
||||||
|
Disable colors with `-color=false` for piping to files.
|
||||||
319
tools/loganalyzer/README.md
Normal file
319
tools/loganalyzer/README.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# Erupe Log Analyzer
|
||||||
|
|
||||||
|
A comprehensive suite of Go tools to analyze Erupe server logs (`erupe.log`).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Filter logs** by level, logger, message content, or time range
|
||||||
|
- **Analyze errors** with grouping, statistics, and stack trace display
|
||||||
|
- **Track connections** and player sessions with detailed statistics
|
||||||
|
- **Generate statistics** about log activity, operations, and patterns
|
||||||
|
- **Tail logs** in real-time like `tail -f`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/loganalyzer
|
||||||
|
go build -o loganalyzer
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a `loganalyzer` binary in the current directory.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The tool provides multiple commands, each with its own options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./loganalyzer <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
#### 1. `filter` - Filter logs by various criteria
|
||||||
|
|
||||||
|
Filter logs by level, logger, message content, or time range.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show only errors
|
||||||
|
./loganalyzer filter -f ../../erupe.log -level error
|
||||||
|
|
||||||
|
# Show warnings from the last hour
|
||||||
|
./loganalyzer filter -f ../../erupe.log -level warn -since 1h
|
||||||
|
|
||||||
|
# Filter by logger (supports wildcards)
|
||||||
|
./loganalyzer filter -f ../../erupe.log -logger "channel-4*"
|
||||||
|
|
||||||
|
# Search for specific message content
|
||||||
|
./loganalyzer filter -f ../../erupe.log -msg "connection reset"
|
||||||
|
|
||||||
|
# Show only last 50 entries
|
||||||
|
./loganalyzer filter -f ../../erupe.log -tail 50
|
||||||
|
|
||||||
|
# Count matching entries without displaying them
|
||||||
|
./loganalyzer filter -f ../../erupe.log -level error -count
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `-f` - Path to log file (default: `erupe.log`)
|
||||||
|
- `-level` - Filter by log level (info, warn, error, fatal)
|
||||||
|
- `-logger` - Filter by logger name (supports wildcards with *)
|
||||||
|
- `-msg` - Filter by message content (case-insensitive)
|
||||||
|
- `-since` - Show logs since this time (RFC3339 or duration like '1h', '30m')
|
||||||
|
- `-until` - Show logs until this time (RFC3339)
|
||||||
|
- `-color` - Colorize output (default: true)
|
||||||
|
- `-count` - Only show count of matching entries
|
||||||
|
- `-tail` - Show last N entries
|
||||||
|
|
||||||
|
#### 2. `errors` - Analyze errors and warnings
|
||||||
|
|
||||||
|
Extract and analyze errors with grouping by message, caller, or logger.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show error summary grouped by message
|
||||||
|
./loganalyzer errors -f ../../erupe.log -summary
|
||||||
|
|
||||||
|
# Show detailed error information with examples
|
||||||
|
./loganalyzer errors -f ../../erupe.log -detailed
|
||||||
|
|
||||||
|
# Show errors with stack traces
|
||||||
|
./loganalyzer errors -f ../../erupe.log -stack -detailed
|
||||||
|
|
||||||
|
# Group errors by caller instead of message
|
||||||
|
./loganalyzer errors -f ../../erupe.log -group caller -summary
|
||||||
|
|
||||||
|
# Show more examples per error group
|
||||||
|
./loganalyzer errors -f ../../erupe.log -detailed -limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `-f` - Path to log file (default: `erupe.log`)
|
||||||
|
- `-group` - Group errors by: message, caller, or logger (default: message)
|
||||||
|
- `-stack` - Show stack traces
|
||||||
|
- `-limit` - Limit number of examples per error group (default: 10)
|
||||||
|
- `-summary` - Show summary only (grouped by error type)
|
||||||
|
- `-detailed` - Show detailed error information
|
||||||
|
|
||||||
|
#### 3. `connections` - Analyze player connections and sessions
|
||||||
|
|
||||||
|
Track connection events, player sessions, and connection statistics.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show connection statistics
|
||||||
|
./loganalyzer connections -f ../../erupe.log
|
||||||
|
|
||||||
|
# Show individual player sessions
|
||||||
|
./loganalyzer connections -f ../../erupe.log -sessions
|
||||||
|
|
||||||
|
# Show detailed session information
|
||||||
|
./loganalyzer connections -f ../../erupe.log -sessions -v
|
||||||
|
|
||||||
|
# Filter by player name
|
||||||
|
./loganalyzer connections -f ../../erupe.log -player "Sarah" -sessions
|
||||||
|
|
||||||
|
# Show only statistics without sessions
|
||||||
|
./loganalyzer connections -f ../../erupe.log -stats -sessions=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `-f` - Path to log file (default: `erupe.log`)
|
||||||
|
- `-player` - Filter by player name
|
||||||
|
- `-sessions` - Show individual player sessions
|
||||||
|
- `-stats` - Show connection statistics (default: true)
|
||||||
|
- `-v` - Verbose output
|
||||||
|
|
||||||
|
**Statistics provided:**
|
||||||
|
|
||||||
|
- Total connections
|
||||||
|
- Unique players and IP addresses
|
||||||
|
- Channel distribution
|
||||||
|
- Connections per day
|
||||||
|
- Top IP addresses
|
||||||
|
- Disconnect reasons
|
||||||
|
|
||||||
|
#### 4. `stats` - Generate comprehensive statistics
|
||||||
|
|
||||||
|
Analyze overall log statistics, activity patterns, and operation counts.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show basic statistics
|
||||||
|
./loganalyzer stats -f ../../erupe.log
|
||||||
|
|
||||||
|
# Show detailed statistics including top loggers and messages
|
||||||
|
./loganalyzer stats -f ../../erupe.log -detailed
|
||||||
|
|
||||||
|
# Show top 20 instead of default 10
|
||||||
|
./loganalyzer stats -f ../../erupe.log -detailed -top 20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `-f` - Path to log file (default: `erupe.log`)
|
||||||
|
- `-top` - Show top N messages/loggers (default: 10)
|
||||||
|
- `-detailed` - Show detailed statistics
|
||||||
|
|
||||||
|
**Statistics provided:**
|
||||||
|
|
||||||
|
- Total log entries and time range
|
||||||
|
- Entries by log level
|
||||||
|
- Operation counts (saves, broadcasts, stage changes)
|
||||||
|
- Top loggers and messages
|
||||||
|
- Activity by day and hour
|
||||||
|
- Unique callers
|
||||||
|
|
||||||
|
#### 5. `tail` - Follow logs in real-time
|
||||||
|
|
||||||
|
Watch log file for new entries, similar to `tail -f`.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow log file showing last 10 lines first
|
||||||
|
./loganalyzer tail -f ../../erupe.log
|
||||||
|
|
||||||
|
# Show last 50 lines and follow
|
||||||
|
./loganalyzer tail -f ../../erupe.log -n 50
|
||||||
|
|
||||||
|
# Follow only errors
|
||||||
|
./loganalyzer tail -f ../../erupe.log -level error
|
||||||
|
|
||||||
|
# Don't follow, just show last 20 lines
|
||||||
|
./loganalyzer tail -f ../../erupe.log -n 20 -follow=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `-f` - Path to log file (default: `erupe.log`)
|
||||||
|
- `-n` - Number of initial lines to show (default: 10)
|
||||||
|
- `-follow` - Follow the log file (default: true)
|
||||||
|
- `-level` - Filter by log level
|
||||||
|
- `-color` - Colorize output (default: true)
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
### Finding the cause of a server crash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Look for errors around a specific time
|
||||||
|
./loganalyzer filter -f erupe.log -level error -since "2025-11-12T23:00:00Z"
|
||||||
|
|
||||||
|
# Analyze all errors with stack traces
|
||||||
|
./loganalyzer errors -f erupe.log -stack -detailed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyzing player activity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# See which players connected today
|
||||||
|
./loganalyzer connections -f erupe.log -sessions -v
|
||||||
|
|
||||||
|
# Find all activity for a specific player
|
||||||
|
./loganalyzer connections -f erupe.log -player "Sarah" -sessions -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring server health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Real-time monitoring of errors
|
||||||
|
./loganalyzer tail -f erupe.log -level error
|
||||||
|
|
||||||
|
# Check overall statistics
|
||||||
|
./loganalyzer stats -f erupe.log -detailed
|
||||||
|
|
||||||
|
# Analyze connection patterns
|
||||||
|
./loganalyzer connections -f erupe.log -stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Investigating specific issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find all connection reset errors
|
||||||
|
./loganalyzer filter -f erupe.log -msg "connection reset"
|
||||||
|
|
||||||
|
# Analyze database errors
|
||||||
|
./loganalyzer errors -f erupe.log -group caller | grep -i database
|
||||||
|
|
||||||
|
# Check activity during peak hours
|
||||||
|
./loganalyzer stats -f erupe.log -detailed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Log Format Support
|
||||||
|
|
||||||
|
The tool supports both log formats found in Erupe logs:
|
||||||
|
|
||||||
|
1. **JSON format** (structured logs):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"level":"info","ts":1762989571.547817,"logger":"main","caller":"Erupe/main.go:57","msg":"Starting Erupe"}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Timestamp format** (simple logs):
|
||||||
|
|
||||||
|
```text
|
||||||
|
2025-11-12T23:19:31.546Z INFO commands Command Help: Enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
The tool uses streaming parsing to handle large log files efficiently:
|
||||||
|
|
||||||
|
- Memory-efficient streaming for filter and stats commands
|
||||||
|
- Fast pattern matching for message filtering
|
||||||
|
- Handles log files with millions of entries
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
By default, output is colorized for better readability:
|
||||||
|
|
||||||
|
- **Errors** are displayed in red
|
||||||
|
- **Warnings** are displayed in yellow
|
||||||
|
- **Info** messages are displayed in green
|
||||||
|
|
||||||
|
Colorization can be disabled with `-color=false` for piping to files or other tools.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. Use `-count` with filter to quickly see how many entries match without displaying them all
|
||||||
|
2. Combine `filter` with `grep` for more complex searches: `./loganalyzer filter -f erupe.log | grep pattern`
|
||||||
|
3. Use `-tail` to limit output when exploring logs interactively
|
||||||
|
4. The `-since` option accepts both absolute timestamps and relative durations (1h, 30m, 24h)
|
||||||
|
5. Use `-summary` with errors command for a quick overview before diving into details
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/loganalyzer
|
||||||
|
go build -o loganalyzer
|
||||||
|
```
|
||||||
|
|
||||||
|
Or to install it system-wide:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Feel free to add new commands or improve existing ones. The codebase is modular:
|
||||||
|
|
||||||
|
- `parser.go` - Log parsing logic
|
||||||
|
- `filter.go` - Filter command
|
||||||
|
- `errors.go` - Error analysis command
|
||||||
|
- `connections.go` - Connection tracking command
|
||||||
|
- `stats.go` - Statistics generation
|
||||||
|
- `tail.go` - Real-time log following
|
||||||
|
- `main.go` - Command routing
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Part of the Erupe project.
|
||||||
459
tools/loganalyzer/connections.go
Normal file
459
tools/loganalyzer/connections.go
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
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: "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", "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())
|
||||||
|
}
|
||||||
|
}
|
||||||
269
tools/loganalyzer/errors.go
Normal file
269
tools/loganalyzer/errors.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorGroup represents a collection of similar errors grouped together.
|
||||||
|
//
|
||||||
|
// Errors can be grouped by message, caller, or logger to identify patterns
|
||||||
|
// and recurring issues in the logs.
|
||||||
|
type ErrorGroup struct {
|
||||||
|
Message string // Primary message for this error group
|
||||||
|
Count int // Total number of occurrences
|
||||||
|
FirstSeen string // Timestamp of first occurrence
|
||||||
|
LastSeen string // Timestamp of last occurrence
|
||||||
|
Examples []*LogEntry // Sample log entries (limited by the limit flag)
|
||||||
|
Callers map[string]int // Map of caller locations to occurrence counts
|
||||||
|
}
|
||||||
|
|
||||||
|
// runErrors implements the errors command for extracting and analyzing errors.
|
||||||
|
//
|
||||||
|
// The errors command processes log files to find all errors and warnings, groups them
|
||||||
|
// by a specified criterion (message, caller, or logger), and presents statistics and
|
||||||
|
// examples for each group.
|
||||||
|
//
|
||||||
|
// Features:
|
||||||
|
// - Groups errors by message (default), caller, or logger
|
||||||
|
// - Shows total error and warning counts
|
||||||
|
// - Displays first and last occurrence timestamps
|
||||||
|
// - Optionally shows stack traces for detailed debugging
|
||||||
|
// - Provides summary or detailed views
|
||||||
|
// - Tracks which callers produced each error
|
||||||
|
//
|
||||||
|
// Options:
|
||||||
|
// - f: Path to log file (default: "erupe.log")
|
||||||
|
// - group: Group errors by "message", "caller", or "logger" (default: "message")
|
||||||
|
// - stack: Show stack traces in detailed view
|
||||||
|
// - limit: Maximum number of example entries per group (default: 10)
|
||||||
|
// - summary: Show summary table only
|
||||||
|
// - detailed: Show detailed information including examples and extra data
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// runErrors([]string{"-summary"})
|
||||||
|
// runErrors([]string{"-detailed", "-stack"})
|
||||||
|
// runErrors([]string{"-group", "caller", "-limit", "20"})
|
||||||
|
func runErrors(args []string) {
|
||||||
|
fs := flag.NewFlagSet("errors", flag.ExitOnError)
|
||||||
|
|
||||||
|
logFile := fs.String("f", "erupe.log", "Path to log file")
|
||||||
|
groupBy := fs.String("group", "message", "Group errors by: message, caller, or logger")
|
||||||
|
showStack := fs.Bool("stack", false, "Show stack traces")
|
||||||
|
limit := fs.Int("limit", 10, "Limit number of examples per error group")
|
||||||
|
summary := fs.Bool("summary", false, "Show summary only (grouped by error type)")
|
||||||
|
detailed := fs.Bool("detailed", false, "Show detailed error information")
|
||||||
|
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
errorGroups := make(map[string]*ErrorGroup)
|
||||||
|
var totalErrors int
|
||||||
|
var totalWarnings int
|
||||||
|
|
||||||
|
err := StreamLogFile(*logFile, func(entry *LogEntry) error {
|
||||||
|
// Only process errors and warnings
|
||||||
|
if entry.Level != "error" && entry.Level != "warn" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Level == "error" {
|
||||||
|
totalErrors++
|
||||||
|
} else {
|
||||||
|
totalWarnings++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine grouping key
|
||||||
|
var key string
|
||||||
|
switch *groupBy {
|
||||||
|
case "message":
|
||||||
|
key = entry.Message
|
||||||
|
case "caller":
|
||||||
|
key = entry.Caller
|
||||||
|
case "logger":
|
||||||
|
key = entry.Logger
|
||||||
|
default:
|
||||||
|
key = entry.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update error group
|
||||||
|
group, exists := errorGroups[key]
|
||||||
|
if !exists {
|
||||||
|
group = &ErrorGroup{
|
||||||
|
Message: entry.Message,
|
||||||
|
Callers: make(map[string]int),
|
||||||
|
Examples: make([]*LogEntry, 0),
|
||||||
|
FirstSeen: entry.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
errorGroups[key] = group
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Count++
|
||||||
|
group.LastSeen = entry.Timestamp.Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
if entry.Caller != "" {
|
||||||
|
group.Callers[entry.Caller]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store example (limit to avoid memory issues)
|
||||||
|
if len(group.Examples) < *limit {
|
||||||
|
group.Examples = append(group.Examples, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error processing log file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print results
|
||||||
|
fmt.Printf("=== Error Analysis ===\n")
|
||||||
|
fmt.Printf("Total Errors: %d\n", totalErrors)
|
||||||
|
fmt.Printf("Total Warnings: %d\n", totalWarnings)
|
||||||
|
fmt.Printf("Unique Error Groups: %d\n\n", len(errorGroups))
|
||||||
|
|
||||||
|
if *summary {
|
||||||
|
printErrorSummary(errorGroups)
|
||||||
|
} else {
|
||||||
|
printDetailedErrors(errorGroups, *showStack, *detailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printErrorSummary displays a tabular summary of error groups sorted by occurrence count.
|
||||||
|
//
|
||||||
|
// The summary table includes:
|
||||||
|
// - Error message (truncated to 60 characters if longer)
|
||||||
|
// - Total count of occurrences
|
||||||
|
// - First seen timestamp
|
||||||
|
// - Last seen timestamp
|
||||||
|
//
|
||||||
|
// Groups are sorted by count in descending order (most frequent first).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - groups: Map of error groups to summarize
|
||||||
|
func printErrorSummary(groups map[string]*ErrorGroup) {
|
||||||
|
// Sort by count
|
||||||
|
type groupPair struct {
|
||||||
|
key string
|
||||||
|
group *ErrorGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairs []groupPair
|
||||||
|
for key, group := range groups {
|
||||||
|
pairs = append(pairs, groupPair{key, group})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pairs, func(i, j int) bool {
|
||||||
|
return pairs[i].group.Count > pairs[j].group.Count
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("%-60s | %-8s | %-19s | %-19s\n", "Error Message", "Count", "First Seen", "Last Seen")
|
||||||
|
fmt.Println(strings.Repeat("-", 120))
|
||||||
|
|
||||||
|
for _, pair := range pairs {
|
||||||
|
msg := pair.group.Message
|
||||||
|
if len(msg) > 60 {
|
||||||
|
msg = msg[:57] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf("%-60s | %-8d | %-19s | %-19s\n",
|
||||||
|
msg,
|
||||||
|
pair.group.Count,
|
||||||
|
pair.group.FirstSeen,
|
||||||
|
pair.group.LastSeen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printDetailedErrors displays comprehensive information about each error group.
|
||||||
|
//
|
||||||
|
// For each error group, displays:
|
||||||
|
// - Group number and occurrence count
|
||||||
|
// - Error message
|
||||||
|
// - First and last seen timestamps
|
||||||
|
// - Caller locations with counts
|
||||||
|
// - Example occurrences with full details (if detailed=true)
|
||||||
|
// - Stack traces (if showStack=true and available)
|
||||||
|
//
|
||||||
|
// Groups are sorted by occurrence count in descending order.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - groups: Map of error groups to display
|
||||||
|
// - showStack: Whether to include stack traces in the output
|
||||||
|
// - detailed: Whether to show example occurrences and extra data
|
||||||
|
func printDetailedErrors(groups map[string]*ErrorGroup, showStack, detailed bool) {
|
||||||
|
// Sort by count
|
||||||
|
type groupPair struct {
|
||||||
|
key string
|
||||||
|
group *ErrorGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairs []groupPair
|
||||||
|
for key, group := range groups {
|
||||||
|
pairs = append(pairs, groupPair{key, group})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pairs, func(i, j int) bool {
|
||||||
|
return pairs[i].group.Count > pairs[j].group.Count
|
||||||
|
})
|
||||||
|
|
||||||
|
for idx, pair := range pairs {
|
||||||
|
fmt.Printf("\n%s\n", strings.Repeat("=", 80))
|
||||||
|
fmt.Printf("Error Group #%d (Count: %d)\n", idx+1, pair.group.Count)
|
||||||
|
fmt.Printf("%s\n", strings.Repeat("=", 80))
|
||||||
|
fmt.Printf("Message: %s\n", pair.group.Message)
|
||||||
|
fmt.Printf("First Seen: %s\n", pair.group.FirstSeen)
|
||||||
|
fmt.Printf("Last Seen: %s\n", pair.group.LastSeen)
|
||||||
|
|
||||||
|
if len(pair.group.Callers) > 0 {
|
||||||
|
fmt.Printf("\nCallers:\n")
|
||||||
|
// Sort callers by count
|
||||||
|
type callerPair struct {
|
||||||
|
name string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
var callers []callerPair
|
||||||
|
for name, count := range pair.group.Callers {
|
||||||
|
callers = append(callers, callerPair{name, count})
|
||||||
|
}
|
||||||
|
sort.Slice(callers, func(i, j int) bool {
|
||||||
|
return callers[i].count > callers[j].count
|
||||||
|
})
|
||||||
|
for _, c := range callers {
|
||||||
|
fmt.Printf(" %s: %d times\n", c.name, c.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if detailed && len(pair.group.Examples) > 0 {
|
||||||
|
fmt.Printf("\nExample occurrences:\n")
|
||||||
|
for i, example := range pair.group.Examples {
|
||||||
|
fmt.Printf("\n [Example %d] %s\n", i+1, example.Timestamp.Format("2006-01-02 15:04:05.000"))
|
||||||
|
fmt.Printf(" Logger: %s\n", example.Logger)
|
||||||
|
if example.Caller != "" {
|
||||||
|
fmt.Printf(" Caller: %s\n", example.Caller)
|
||||||
|
}
|
||||||
|
if example.Error != "" {
|
||||||
|
fmt.Printf(" Error: %s\n", example.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print extra data
|
||||||
|
if len(example.ExtraData) > 0 {
|
||||||
|
fmt.Printf(" Extra Data:\n")
|
||||||
|
for k, v := range example.ExtraData {
|
||||||
|
fmt.Printf(" %s: %v\n", k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showStack && example.StackTrace != "" {
|
||||||
|
fmt.Printf(" Stack Trace:\n")
|
||||||
|
lines := strings.Split(example.StackTrace, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
fmt.Printf(" %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
tools/loganalyzer/filter.go
Normal file
182
tools/loganalyzer/filter.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runFilter implements the filter command for filtering log entries by various criteria.
|
||||||
|
//
|
||||||
|
// The filter command supports the following filters:
|
||||||
|
// - level: Filter by log level (info, warn, error, fatal)
|
||||||
|
// - logger: Filter by logger name (supports wildcards with *)
|
||||||
|
// - msg: Filter by message content (case-insensitive substring match)
|
||||||
|
// - since: Show logs since this time (RFC3339 format or duration like "1h", "30m")
|
||||||
|
// - until: Show logs until this time (RFC3339 format)
|
||||||
|
// - tail: Show only the last N matching entries
|
||||||
|
// - count: Show only the count of matching entries instead of the entries themselves
|
||||||
|
// - color: Enable/disable colorized output (default: true)
|
||||||
|
//
|
||||||
|
// All filters are combined with AND logic.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// runFilter([]string{"-level", "error"})
|
||||||
|
// runFilter([]string{"-since", "1h", "-logger", "channel-4*"})
|
||||||
|
// runFilter([]string{"-msg", "connection reset", "-count"})
|
||||||
|
func runFilter(args []string) {
|
||||||
|
fs := flag.NewFlagSet("filter", flag.ExitOnError)
|
||||||
|
|
||||||
|
logFile := fs.String("f", "erupe.log", "Path to log file")
|
||||||
|
level := fs.String("level", "", "Filter by log level (info, warn, error, fatal)")
|
||||||
|
logger := fs.String("logger", "", "Filter by logger name (supports wildcards)")
|
||||||
|
message := fs.String("msg", "", "Filter by message content (case-insensitive)")
|
||||||
|
sinceStr := fs.String("since", "", "Show logs since this time (RFC3339 or duration like '1h')")
|
||||||
|
untilStr := fs.String("until", "", "Show logs until this time (RFC3339)")
|
||||||
|
colorize := fs.Bool("color", true, "Colorize output")
|
||||||
|
count := fs.Bool("count", false, "Only show count of matching entries")
|
||||||
|
tail := fs.Int("tail", 0, "Show last N entries")
|
||||||
|
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
// Parse time filters
|
||||||
|
var since, until time.Time
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if *sinceStr != "" {
|
||||||
|
// Try parsing as duration first
|
||||||
|
if duration, err := time.ParseDuration(*sinceStr); err == nil {
|
||||||
|
since = time.Now().Add(-duration)
|
||||||
|
} else if since, err = time.Parse(time.RFC3339, *sinceStr); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Invalid since time format: %s\n", *sinceStr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *untilStr != "" {
|
||||||
|
if until, err = time.Parse(time.RFC3339, *untilStr); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Invalid until time format: %s\n", *untilStr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matching entries
|
||||||
|
var matches []*LogEntry
|
||||||
|
var totalCount int
|
||||||
|
|
||||||
|
err = StreamLogFile(*logFile, func(entry *LogEntry) error {
|
||||||
|
totalCount++
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if *level != "" && !strings.EqualFold(entry.Level, *level) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if *logger != "" && !matchWildcard(entry.Logger, *logger) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if *message != "" && !strings.Contains(strings.ToLower(entry.Message), strings.ToLower(*message)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !since.IsZero() && entry.Timestamp.Before(since) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !until.IsZero() && entry.Timestamp.After(until) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches = append(matches, entry)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error processing log file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tail option
|
||||||
|
if *tail > 0 && len(matches) > *tail {
|
||||||
|
matches = matches[len(matches)-*tail:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if *count {
|
||||||
|
fmt.Printf("Total entries: %d\n", totalCount)
|
||||||
|
fmt.Printf("Matching entries: %d\n", len(matches))
|
||||||
|
} else {
|
||||||
|
for _, entry := range matches {
|
||||||
|
fmt.Println(FormatLogEntry(entry, *colorize))
|
||||||
|
}
|
||||||
|
if len(matches) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%d of %d entries matched\n", len(matches), totalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchWildcard performs simple wildcard matching where * matches any sequence of characters.
|
||||||
|
//
|
||||||
|
// The function supports the following patterns:
|
||||||
|
// - "*" matches everything
|
||||||
|
// - "foo*" matches strings starting with "foo"
|
||||||
|
// - "*foo" matches strings ending with "foo"
|
||||||
|
// - "*foo*" matches strings containing "foo"
|
||||||
|
// - "foo*bar" matches strings starting with "foo" and ending with "bar"
|
||||||
|
//
|
||||||
|
// Matching is case-insensitive. If the pattern contains no wildcards, it performs
|
||||||
|
// a simple case-insensitive substring match.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - s: The string to match against
|
||||||
|
// - pattern: The pattern with optional wildcards
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - true if the string matches the pattern, false otherwise
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// matchWildcard("channel-4", "channel-*") // returns true
|
||||||
|
// matchWildcard("main.channel-4.error", "*channel-4*") // returns true
|
||||||
|
// matchWildcard("test", "foo*") // returns false
|
||||||
|
func matchWildcard(s, pattern string) bool {
|
||||||
|
if pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
return strings.Contains(strings.ToLower(s), strings.ToLower(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
|
||||||
|
pos := 0
|
||||||
|
for i, part := range parts {
|
||||||
|
part = strings.ToLower(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := strings.Index(s[pos:], part)
|
||||||
|
if idx == -1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// First part must match from beginning
|
||||||
|
if i == 0 && idx != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += idx + len(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last part must match to end
|
||||||
|
if !strings.HasSuffix(pattern, "*") {
|
||||||
|
lastPart := strings.ToLower(parts[len(parts)-1])
|
||||||
|
return strings.HasSuffix(s, lastPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
5
tools/loganalyzer/go.mod
Normal file
5
tools/loganalyzer/go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module erupe-loganalyzer
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require ()
|
||||||
61
tools/loganalyzer/main.go
Normal file
61
tools/loganalyzer/main.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// main is the entry point for the log analyzer CLI tool.
|
||||||
|
//
|
||||||
|
// The tool provides five main commands:
|
||||||
|
// - filter: Filter logs by level, logger, message content, or time range
|
||||||
|
// - errors: Extract and analyze errors with grouping and stack traces
|
||||||
|
// - connections: Track player connections and sessions with statistics
|
||||||
|
// - stats: Generate comprehensive statistics about log activity
|
||||||
|
// - tail: Follow logs in real-time (like tail -f)
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// loganalyzer <command> [options]
|
||||||
|
// loganalyzer filter -level error -since 1h
|
||||||
|
// loganalyzer errors -summary
|
||||||
|
// loganalyzer stats -detailed
|
||||||
|
func main() {
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Erupe Log Analyzer - Suite of tools to analyze erupe.log files\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s <command> [options]\n\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, "Available commands:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " filter Filter logs by level, logger, or time range\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " errors Extract and analyze errors with stack traces\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " connections Analyze connection events and player sessions\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " stats Generate statistics summary\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " tail Follow log file in real-time (like tail -f)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "\nUse '%s <command> -h' for more information about a command.\n", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
command := os.Args[1]
|
||||||
|
args := os.Args[2:]
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "filter":
|
||||||
|
runFilter(args)
|
||||||
|
case "errors":
|
||||||
|
runErrors(args)
|
||||||
|
case "connections":
|
||||||
|
runConnections(args)
|
||||||
|
case "stats":
|
||||||
|
runStats(args)
|
||||||
|
case "tail":
|
||||||
|
runTail(args)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", command)
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
315
tools/loganalyzer/parser.go
Normal file
315
tools/loganalyzer/parser.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
// Package main provides a comprehensive suite of tools for analyzing Erupe server logs.
|
||||||
|
//
|
||||||
|
// The log analyzer supports both JSON-formatted logs and tab-delimited timestamp logs,
|
||||||
|
// providing commands for filtering, error analysis, connection tracking, statistics
|
||||||
|
// generation, and real-time log following.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry represents a parsed log entry from either JSON or timestamp-based format.
|
||||||
|
//
|
||||||
|
// The parser supports two log formats:
|
||||||
|
// 1. JSON format: {"level":"info","ts":1762989571.547817,"logger":"main","msg":"Starting"}
|
||||||
|
// 2. Timestamp format: 2025-11-12T23:19:31.546Z INFO commands Command Help: Enabled
|
||||||
|
type LogEntry struct {
|
||||||
|
Raw string // Original log line
|
||||||
|
Level string // Log level: info, warn, error, fatal
|
||||||
|
Timestamp time.Time // Parsed timestamp
|
||||||
|
Logger string // Logger name
|
||||||
|
Caller string // Caller file:line
|
||||||
|
Message string // Log message
|
||||||
|
Error string // Error message (if present)
|
||||||
|
StackTrace string // Stack trace (if present)
|
||||||
|
ExtraData map[string]interface{} // Additional fields
|
||||||
|
IsJSON bool // True if parsed from JSON format
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLogFile reads and parses an entire log file into memory.
|
||||||
|
//
|
||||||
|
// This function loads all log entries into memory and is suitable for smaller log files
|
||||||
|
// or when random access to entries is needed. For large files or streaming operations,
|
||||||
|
// use StreamLogFile instead.
|
||||||
|
//
|
||||||
|
// The function automatically handles both JSON and timestamp-based log formats,
|
||||||
|
// skips empty lines and "nohup: ignoring input" messages, and uses a large buffer
|
||||||
|
// (1MB) to handle long lines like stack traces.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - filename: Path to the log file to parse
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A slice of LogEntry pointers containing all parsed entries
|
||||||
|
// - An error if the file cannot be opened or read
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// entries, err := ParseLogFile("erupe.log")
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
// fmt.Printf("Parsed %d entries\n", len(entries))
|
||||||
|
func ParseLogFile(filename string) ([]*LogEntry, error) {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open log file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var entries []*LogEntry
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
|
||||||
|
// Increase buffer size for long lines (like stack traces)
|
||||||
|
const maxCapacity = 1024 * 1024 // 1MB
|
||||||
|
buf := make([]byte, maxCapacity)
|
||||||
|
scanner.Buffer(buf, maxCapacity)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" || line == "nohup: ignoring input" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := ParseLogLine(line)
|
||||||
|
if entry != nil {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLogLine parses a single log line into a LogEntry.
|
||||||
|
//
|
||||||
|
// This function attempts to parse the line in the following order:
|
||||||
|
// 1. JSON format: Lines starting with '{' are parsed as JSON objects
|
||||||
|
// 2. Timestamp format: Tab-delimited lines with RFC3339 timestamps
|
||||||
|
// 3. Unknown format: Lines that don't match either format are marked as "unknown" level
|
||||||
|
//
|
||||||
|
// For JSON logs, all standard fields (level, ts, logger, caller, msg, error, stacktrace)
|
||||||
|
// are extracted, and any additional fields are stored in ExtraData.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - line: A single line from the log file
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A LogEntry pointer containing the parsed data, or nil if the line is invalid
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// entry := ParseLogLine(`{"level":"info","ts":1762989571.547817,"msg":"Starting"}`)
|
||||||
|
// fmt.Println(entry.Level, entry.Message)
|
||||||
|
func ParseLogLine(line string) *LogEntry {
|
||||||
|
entry := &LogEntry{
|
||||||
|
Raw: line,
|
||||||
|
ExtraData: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as JSON first
|
||||||
|
if strings.HasPrefix(line, "{") {
|
||||||
|
var jsonData map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(line), &jsonData); err == nil {
|
||||||
|
entry.IsJSON = true
|
||||||
|
|
||||||
|
// Extract standard fields
|
||||||
|
if level, ok := jsonData["level"].(string); ok {
|
||||||
|
entry.Level = level
|
||||||
|
}
|
||||||
|
|
||||||
|
if ts, ok := jsonData["ts"].(float64); ok {
|
||||||
|
entry.Timestamp = time.Unix(int64(ts), int64((ts-float64(int64(ts)))*1e9))
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger, ok := jsonData["logger"].(string); ok {
|
||||||
|
entry.Logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
if caller, ok := jsonData["caller"].(string); ok {
|
||||||
|
entry.Caller = caller
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg, ok := jsonData["msg"].(string); ok {
|
||||||
|
entry.Message = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg, ok := jsonData["error"].(string); ok {
|
||||||
|
entry.Error = errMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
if stackTrace, ok := jsonData["stacktrace"].(string); ok {
|
||||||
|
entry.StackTrace = stackTrace
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store any extra fields
|
||||||
|
for k, v := range jsonData {
|
||||||
|
if k != "level" && k != "ts" && k != "logger" && k != "caller" &&
|
||||||
|
k != "msg" && k != "error" && k != "stacktrace" {
|
||||||
|
entry.ExtraData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as timestamp-based log (2025-11-12T23:19:31.546Z INFO commands ...)
|
||||||
|
parts := strings.SplitN(line, "\t", 4)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
// Parse timestamp
|
||||||
|
if ts, err := time.Parse(time.RFC3339Nano, parts[0]); err == nil {
|
||||||
|
entry.Timestamp = ts
|
||||||
|
entry.Level = strings.ToLower(parts[1])
|
||||||
|
entry.Logger = parts[2]
|
||||||
|
if len(parts) == 4 {
|
||||||
|
entry.Message = parts[3]
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't parse it, return a basic entry
|
||||||
|
entry.Level = "unknown"
|
||||||
|
entry.Message = line
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamLogFile reads a log file line by line and calls the callback for each entry.
|
||||||
|
//
|
||||||
|
// This function is memory-efficient and suitable for processing large log files as it
|
||||||
|
// processes entries one at a time without loading the entire file into memory. The
|
||||||
|
// callback function is called for each successfully parsed log entry.
|
||||||
|
//
|
||||||
|
// The function uses a 1MB buffer to handle long lines such as those containing stack traces.
|
||||||
|
// Empty lines and "nohup: ignoring input" messages are automatically skipped.
|
||||||
|
//
|
||||||
|
// If the callback returns an error, processing stops immediately and that error is returned.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - filename: Path to the log file to process
|
||||||
|
// - callback: Function to call for each parsed LogEntry
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - An error if the file cannot be opened, read, or if the callback returns an error
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// err := StreamLogFile("erupe.log", func(entry *LogEntry) error {
|
||||||
|
// if entry.Level == "error" {
|
||||||
|
// fmt.Println(entry.Message)
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
// })
|
||||||
|
func StreamLogFile(filename string, callback func(*LogEntry) error) error {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open log file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
|
||||||
|
// Increase buffer size for long lines
|
||||||
|
const maxCapacity = 1024 * 1024 // 1MB
|
||||||
|
buf := make([]byte, maxCapacity)
|
||||||
|
scanner.Buffer(buf, maxCapacity)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" || line == "nohup: ignoring input" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := ParseLogLine(line)
|
||||||
|
if entry != nil {
|
||||||
|
if err := callback(entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatLogEntry formats a log entry for human-readable display.
|
||||||
|
//
|
||||||
|
// The output format is: "TIMESTAMP LEVEL [LOGGER] MESSAGE key=value ..."
|
||||||
|
//
|
||||||
|
// Timestamps are formatted as "2006-01-02 15:04:05.000". Log levels can be colorized
|
||||||
|
// for terminal display:
|
||||||
|
// - Errors (error, fatal, panic): Red
|
||||||
|
// - Warnings: Yellow
|
||||||
|
// - Info: Green
|
||||||
|
//
|
||||||
|
// If the entry contains an error message, it's appended as error="message".
|
||||||
|
// Any extra fields in ExtraData are appended as key=value pairs.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - entry: The LogEntry to format
|
||||||
|
// - colorize: Whether to add ANSI color codes for terminal display
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A formatted string representation of the log entry
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// formatted := FormatLogEntry(entry, true)
|
||||||
|
// fmt.Println(formatted)
|
||||||
|
// // Output: 2025-11-12 23:19:31.546 INFO [main] Starting Erupe
|
||||||
|
func FormatLogEntry(entry *LogEntry, colorize bool) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
if !entry.Timestamp.IsZero() {
|
||||||
|
sb.WriteString(entry.Timestamp.Format("2006-01-02 15:04:05.000"))
|
||||||
|
sb.WriteString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format level with colors
|
||||||
|
levelStr := strings.ToUpper(entry.Level)
|
||||||
|
if colorize {
|
||||||
|
switch entry.Level {
|
||||||
|
case "error", "fatal", "panic":
|
||||||
|
levelStr = fmt.Sprintf("\033[31m%s\033[0m", levelStr) // Red
|
||||||
|
case "warn":
|
||||||
|
levelStr = fmt.Sprintf("\033[33m%s\033[0m", levelStr) // Yellow
|
||||||
|
case "info":
|
||||||
|
levelStr = fmt.Sprintf("\033[32m%s\033[0m", levelStr) // Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%-5s ", levelStr))
|
||||||
|
|
||||||
|
// Format logger
|
||||||
|
if entry.Logger != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("[%s] ", entry.Logger))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format message
|
||||||
|
sb.WriteString(entry.Message)
|
||||||
|
|
||||||
|
// Add error if present
|
||||||
|
if entry.Error != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" error=%q", entry.Error))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra data
|
||||||
|
if len(entry.ExtraData) > 0 {
|
||||||
|
sb.WriteString(" ")
|
||||||
|
first := true
|
||||||
|
for k, v := range entry.ExtraData {
|
||||||
|
if !first {
|
||||||
|
sb.WriteString(" ")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s=%v", k, v))
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
338
tools/loganalyzer/stats.go
Normal file
338
tools/loganalyzer/stats.go
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogStats aggregates comprehensive statistics about log file contents.
|
||||||
|
//
|
||||||
|
// This structure tracks various metrics including temporal patterns, log levels,
|
||||||
|
// message types, and server operations to provide insights into server behavior
|
||||||
|
// and activity patterns.
|
||||||
|
type LogStats struct {
|
||||||
|
TotalEntries int // Total number of log entries
|
||||||
|
EntriesByLevel map[string]int // Log level to count
|
||||||
|
EntriesByLogger map[string]int // Logger name to count
|
||||||
|
EntriesByDay map[string]int // Date string to count
|
||||||
|
EntriesByHour map[int]int // Hour (0-23) to count
|
||||||
|
TopMessages map[string]int // Message text to count
|
||||||
|
FirstEntry time.Time // Timestamp of first entry
|
||||||
|
LastEntry time.Time // Timestamp of last entry
|
||||||
|
SaveOperations int // Count of save operations
|
||||||
|
ObjectBroadcasts int // Count of object broadcasts
|
||||||
|
StageChanges int // Count of stage changes
|
||||||
|
TerminalLogs int // Count of terminal log entries
|
||||||
|
UniqueCallers map[string]bool // Set of unique caller locations
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStats implements the stats command for generating comprehensive log statistics.
|
||||||
|
//
|
||||||
|
// The stats command processes the entire log file to collect statistics about:
|
||||||
|
// - Overall log volume and time span
|
||||||
|
// - Distribution of log levels (info, warn, error, etc.)
|
||||||
|
// - Server operation counts (saves, broadcasts, stage changes)
|
||||||
|
// - Temporal patterns (activity by day and hour)
|
||||||
|
// - Top loggers and message types
|
||||||
|
// - Unique code locations generating logs
|
||||||
|
//
|
||||||
|
// This provides a high-level overview of server activity and can help identify
|
||||||
|
// patterns, peak usage times, and potential issues.
|
||||||
|
//
|
||||||
|
// Options:
|
||||||
|
// - f: Path to log file (default: "erupe.log")
|
||||||
|
// - top: Number of top items to show in detailed view (default: 10)
|
||||||
|
// - detailed: Show detailed statistics including temporal patterns and top messages
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// runStats([]string{}) // Basic statistics
|
||||||
|
// runStats([]string{"-detailed"}) // Full statistics with temporal analysis
|
||||||
|
// runStats([]string{"-detailed", "-top", "20"}) // Show top 20 items
|
||||||
|
func runStats(args []string) {
|
||||||
|
fs := flag.NewFlagSet("stats", flag.ExitOnError)
|
||||||
|
|
||||||
|
logFile := fs.String("f", "erupe.log", "Path to log file")
|
||||||
|
topN := fs.Int("top", 10, "Show top N messages/loggers")
|
||||||
|
detailed := fs.Bool("detailed", false, "Show detailed statistics")
|
||||||
|
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
stats := &LogStats{
|
||||||
|
EntriesByLevel: make(map[string]int),
|
||||||
|
EntriesByLogger: make(map[string]int),
|
||||||
|
EntriesByDay: make(map[string]int),
|
||||||
|
EntriesByHour: make(map[int]int),
|
||||||
|
TopMessages: make(map[string]int),
|
||||||
|
UniqueCallers: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := StreamLogFile(*logFile, func(entry *LogEntry) error {
|
||||||
|
stats.TotalEntries++
|
||||||
|
|
||||||
|
// Track first and last entry
|
||||||
|
if stats.FirstEntry.IsZero() || entry.Timestamp.Before(stats.FirstEntry) {
|
||||||
|
stats.FirstEntry = entry.Timestamp
|
||||||
|
}
|
||||||
|
if entry.Timestamp.After(stats.LastEntry) {
|
||||||
|
stats.LastEntry = entry.Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count by level
|
||||||
|
stats.EntriesByLevel[entry.Level]++
|
||||||
|
|
||||||
|
// Count by logger
|
||||||
|
stats.EntriesByLogger[entry.Logger]++
|
||||||
|
|
||||||
|
// Count by day
|
||||||
|
if !entry.Timestamp.IsZero() {
|
||||||
|
day := entry.Timestamp.Format("2006-01-02")
|
||||||
|
stats.EntriesByDay[day]++
|
||||||
|
|
||||||
|
// Count by hour of day
|
||||||
|
hour := entry.Timestamp.Hour()
|
||||||
|
stats.EntriesByHour[hour]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count message types
|
||||||
|
msg := entry.Message
|
||||||
|
if len(msg) > 80 {
|
||||||
|
msg = msg[:80] + "..."
|
||||||
|
}
|
||||||
|
stats.TopMessages[msg]++
|
||||||
|
|
||||||
|
// Track unique callers
|
||||||
|
if entry.Caller != "" {
|
||||||
|
stats.UniqueCallers[entry.Caller] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count specific operations
|
||||||
|
if strings.Contains(entry.Message, "Wrote recompressed savedata back to DB") {
|
||||||
|
stats.SaveOperations++
|
||||||
|
}
|
||||||
|
if strings.Contains(entry.Message, "Broadcasting new object") {
|
||||||
|
stats.ObjectBroadcasts++
|
||||||
|
}
|
||||||
|
if strings.Contains(entry.Message, "Sending notification to old stage clients") {
|
||||||
|
stats.StageChanges++
|
||||||
|
}
|
||||||
|
if entry.Message == "SysTerminalLog" {
|
||||||
|
stats.TerminalLogs++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error processing log file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
printStats(stats, *topN, *detailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printStats displays formatted statistics output.
|
||||||
|
//
|
||||||
|
// In basic mode, shows:
|
||||||
|
// - Total entries, time range, and average rate
|
||||||
|
// - Distribution by log level
|
||||||
|
// - Operation counts
|
||||||
|
//
|
||||||
|
// In detailed mode, additionally shows:
|
||||||
|
// - Top N loggers by volume
|
||||||
|
// - Entries by day
|
||||||
|
// - Activity distribution by hour of day (with bar chart)
|
||||||
|
// - Top N message types
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - stats: LogStats structure containing collected statistics
|
||||||
|
// - topN: Number of top items to display in detailed view
|
||||||
|
// - detailed: Whether to show detailed statistics
|
||||||
|
func printStats(stats *LogStats, topN int, detailed bool) {
|
||||||
|
fmt.Printf("=== Erupe Log Statistics ===\n\n")
|
||||||
|
|
||||||
|
// Basic stats
|
||||||
|
fmt.Printf("Total Log Entries: %s\n", formatNumber(stats.TotalEntries))
|
||||||
|
if !stats.FirstEntry.IsZero() && !stats.LastEntry.IsZero() {
|
||||||
|
duration := stats.LastEntry.Sub(stats.FirstEntry)
|
||||||
|
fmt.Printf("Time Range: %s to %s\n",
|
||||||
|
stats.FirstEntry.Format("2006-01-02 15:04:05"),
|
||||||
|
stats.LastEntry.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Printf("Total Duration: %s\n", formatDuration(duration))
|
||||||
|
|
||||||
|
if duration.Hours() > 0 {
|
||||||
|
entriesPerHour := float64(stats.TotalEntries) / duration.Hours()
|
||||||
|
fmt.Printf("Average Entries/Hour: %.1f\n", entriesPerHour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Log levels
|
||||||
|
fmt.Printf("--- Entries by Log Level ---\n")
|
||||||
|
levels := []string{"info", "warn", "error", "fatal", "panic", "unknown"}
|
||||||
|
for _, level := range levels {
|
||||||
|
if count, ok := stats.EntriesByLevel[level]; ok {
|
||||||
|
percentage := float64(count) / float64(stats.TotalEntries) * 100
|
||||||
|
fmt.Printf(" %-8s: %s (%.1f%%)\n", strings.ToUpper(level), formatNumber(count), percentage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Operation counts
|
||||||
|
fmt.Printf("--- Operation Counts ---\n")
|
||||||
|
fmt.Printf(" Save Operations: %s\n", formatNumber(stats.SaveOperations))
|
||||||
|
fmt.Printf(" Object Broadcasts: %s\n", formatNumber(stats.ObjectBroadcasts))
|
||||||
|
fmt.Printf(" Stage Changes: %s\n", formatNumber(stats.StageChanges))
|
||||||
|
fmt.Printf(" Terminal Logs: %s\n", formatNumber(stats.TerminalLogs))
|
||||||
|
fmt.Printf(" Unique Callers: %s\n", formatNumber(len(stats.UniqueCallers)))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if detailed {
|
||||||
|
// Top loggers
|
||||||
|
if len(stats.EntriesByLogger) > 0 {
|
||||||
|
fmt.Printf("--- Top %d Loggers ---\n", topN)
|
||||||
|
printTopMap(stats.EntriesByLogger, topN, stats.TotalEntries)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries by day
|
||||||
|
if len(stats.EntriesByDay) > 0 {
|
||||||
|
fmt.Printf("--- Entries by Day ---\n")
|
||||||
|
printDayMap(stats.EntriesByDay)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries by hour
|
||||||
|
if len(stats.EntriesByHour) > 0 {
|
||||||
|
fmt.Printf("--- Activity by Hour of Day ---\n")
|
||||||
|
printHourDistribution(stats.EntriesByHour, stats.TotalEntries)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top messages
|
||||||
|
if len(stats.TopMessages) > 0 {
|
||||||
|
fmt.Printf("--- Top %d Messages ---\n", topN)
|
||||||
|
printTopMap(stats.TopMessages, topN, stats.TotalEntries)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printTopMap displays the top N items from a map sorted by count.
|
||||||
|
//
|
||||||
|
// The output includes:
|
||||||
|
// - Rank number (1, 2, 3, ...)
|
||||||
|
// - Item key (truncated to 60 characters if longer)
|
||||||
|
// - Count with thousand separators
|
||||||
|
// - Percentage of total
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - m: Map of items to counts
|
||||||
|
// - topN: Maximum number of items to display
|
||||||
|
// - total: Total count for calculating percentages
|
||||||
|
func printTopMap(m map[string]int, topN, total int) {
|
||||||
|
type pair struct {
|
||||||
|
key string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairs []pair
|
||||||
|
for k, v := range m {
|
||||||
|
pairs = append(pairs, pair{k, v})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pairs, func(i, j int) bool {
|
||||||
|
return pairs[i].count > pairs[j].count
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(pairs) > topN {
|
||||||
|
pairs = pairs[:topN]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, p := range pairs {
|
||||||
|
percentage := float64(p.count) / float64(total) * 100
|
||||||
|
key := p.key
|
||||||
|
if len(key) > 60 {
|
||||||
|
key = key[:57] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf(" %2d. %-60s: %s (%.1f%%)\n", i+1, key, formatNumber(p.count), percentage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printDayMap displays entries grouped by day in chronological order.
|
||||||
|
//
|
||||||
|
// Output format: "YYYY-MM-DD: count"
|
||||||
|
// Days are sorted chronologically from earliest to latest.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - m: Map of date strings (YYYY-MM-DD format) to counts
|
||||||
|
func printDayMap(m map[string]int) {
|
||||||
|
type pair struct {
|
||||||
|
day string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairs []pair
|
||||||
|
for k, v := range m {
|
||||||
|
pairs = append(pairs, pair{k, v})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pairs, func(i, j int) bool {
|
||||||
|
return pairs[i].day < pairs[j].day
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, p := range pairs {
|
||||||
|
fmt.Printf(" %s: %s\n", p.day, formatNumber(p.count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printHourDistribution displays log activity by hour of day with a bar chart.
|
||||||
|
//
|
||||||
|
// For each hour (0-23), shows:
|
||||||
|
// - Hour range (e.g., "14:00 - 14:59")
|
||||||
|
// - ASCII bar chart visualization (█ characters proportional to percentage)
|
||||||
|
// - Count with thousand separators
|
||||||
|
// - Percentage of total
|
||||||
|
//
|
||||||
|
// Hours with no activity are skipped.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - m: Map of hours (0-23) to entry counts
|
||||||
|
// - total: Total number of entries for percentage calculation
|
||||||
|
func printHourDistribution(m map[int]int, total int) {
|
||||||
|
for hour := 0; hour < 24; hour++ {
|
||||||
|
count := m[hour]
|
||||||
|
if count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
percentage := float64(count) / float64(total) * 100
|
||||||
|
bar := strings.Repeat("█", int(percentage))
|
||||||
|
fmt.Printf(" %02d:00 - %02d:59: %-20s %s (%.1f%%)\n",
|
||||||
|
hour, hour, bar, formatNumber(count), percentage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatNumber formats an integer with thousand separators for readability.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - 123 -> "123"
|
||||||
|
// - 1234 -> "1,234"
|
||||||
|
// - 1234567 -> "1,234,567"
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - n: The integer to format
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A string with comma separators
|
||||||
|
func formatNumber(n int) string {
|
||||||
|
if n < 1000 {
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
|
if n < 1000000 {
|
||||||
|
return fmt.Sprintf("%d,%03d", n/1000, n%1000)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d,%03d,%03d", n/1000000, (n/1000)%1000, n%1000)
|
||||||
|
}
|
||||||
108
tools/loganalyzer/tail.go
Normal file
108
tools/loganalyzer/tail.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runTail implements the tail command for following log files in real-time.
|
||||||
|
//
|
||||||
|
// The tail command mimics the Unix `tail -f` command, displaying the last N lines
|
||||||
|
// of a log file and then continuously monitoring the file for new entries. This is
|
||||||
|
// useful for real-time monitoring of server activity.
|
||||||
|
//
|
||||||
|
// The command operates in two phases:
|
||||||
|
// 1. Initial display: Shows the last N matching entries from the file
|
||||||
|
// 2. Follow mode: Continuously monitors for new lines and displays them as they appear
|
||||||
|
//
|
||||||
|
// Both phases support filtering by log level and colorized output.
|
||||||
|
//
|
||||||
|
// Options:
|
||||||
|
// - f: Path to log file (default: "erupe.log")
|
||||||
|
// - n: Number of initial lines to show (default: 10)
|
||||||
|
// - follow: Whether to continue following the file (default: true)
|
||||||
|
// - level: Filter by log level (info, warn, error, fatal)
|
||||||
|
// - color: Colorize output (default: true)
|
||||||
|
//
|
||||||
|
// The follow mode polls the file every 100ms for new content. Use Ctrl+C to stop.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// runTail([]string{}) // Show last 10 lines and follow
|
||||||
|
// runTail([]string{"-n", "50"}) // Show last 50 lines and follow
|
||||||
|
// runTail([]string{"-level", "error"}) // Only show errors
|
||||||
|
// runTail([]string{"-follow=false", "-n", "20"}) // Just show last 20 lines, don't follow
|
||||||
|
func runTail(args []string) {
|
||||||
|
fs := flag.NewFlagSet("tail", flag.ExitOnError)
|
||||||
|
|
||||||
|
logFile := fs.String("f", "erupe.log", "Path to log file")
|
||||||
|
lines := fs.Int("n", 10, "Number of initial lines to show")
|
||||||
|
follow := fs.Bool("follow", true, "Follow the log file (like tail -f)")
|
||||||
|
level := fs.String("level", "", "Filter by log level")
|
||||||
|
colorize := fs.Bool("color", true, "Colorize output")
|
||||||
|
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
// First, show last N lines
|
||||||
|
if *lines > 0 {
|
||||||
|
entries, err := ParseLogFile(*logFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error reading log file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by level if specified
|
||||||
|
var filtered []*LogEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
if *level == "" || entry.Level == *level {
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show last N lines
|
||||||
|
start := len(filtered) - *lines
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := start; i < len(filtered); i++ {
|
||||||
|
fmt.Println(FormatLogEntry(filtered[i], *colorize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If follow is enabled, watch for new lines
|
||||||
|
if *follow {
|
||||||
|
file, err := os.Open(*logFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Seek to end of file
|
||||||
|
file.Seek(0, 2)
|
||||||
|
|
||||||
|
reader := bufio.NewReader(file)
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "Following log file... (Ctrl+C to stop)")
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
// No more data, wait a bit and try again
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := ParseLogLine(line)
|
||||||
|
if entry != nil {
|
||||||
|
// Filter by level if specified
|
||||||
|
if *level == "" || entry.Level == *level {
|
||||||
|
fmt.Println(FormatLogEntry(entry, *colorize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user