Files
seanime-docker/seanime-2.9.10/internal/troubleshooter/logs.go
2025-09-20 14:08:38 +01:00

382 lines
9.4 KiB
Go

package troubleshooter
import (
"errors"
"os"
"path/filepath"
"runtime"
"seanime/internal/util"
"sort"
"strings"
"time"
)
type AnalysisResult struct {
Items []AnalysisResultItem `json:"items"`
}
type AnalysisResultItem struct {
Observation string `json:"observation"`
Recommendation string `json:"recommendation"`
Severity string `json:"severity"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Logs []string `json:"logs"`
}
// RuleBuilder provides a fluent interface for building rules
type RuleBuilder struct {
name string
description string
conditions []condition
platforms []string
branches []branch
defaultBranch *branch
state *AppState
}
type condition struct {
check func(LogLine) bool
message string // For debugging/logging
}
type branch struct {
conditions []condition
observation string
recommendation string
severity string
}
// NewRule starts building a new rule
func NewRule(name string) *RuleBuilder {
return &RuleBuilder{
name: name,
conditions: []condition{},
branches: []branch{},
defaultBranch: &branch{
severity: "info",
},
}
}
// Desc adds a description to the rule
func (r *RuleBuilder) Desc(desc string) *RuleBuilder {
r.description = desc
return r
}
// When adds a base condition that must be met
func (r *RuleBuilder) When(check func(LogLine) bool, message string) *RuleBuilder {
r.conditions = append(r.conditions, condition{check: check, message: message})
return r
}
// ModuleIs adds a module condition
func (r *RuleBuilder) ModuleIs(module Module) *RuleBuilder {
return r.When(func(l LogLine) bool {
return l.Module == string(module)
}, "module is "+string(module))
}
// LevelIs adds a level condition
func (r *RuleBuilder) LevelIs(level Level) *RuleBuilder {
return r.When(func(l LogLine) bool {
return l.Level == string(level)
}, "level is "+string(level))
}
// MessageContains adds a message contains condition
func (r *RuleBuilder) MessageContains(substr string) *RuleBuilder {
return r.When(func(l LogLine) bool {
return strings.Contains(l.Message, substr)
}, "message contains "+substr)
}
// MessageMatches adds a message regex condition
func (r *RuleBuilder) MessageMatches(pattern string) *RuleBuilder {
return r.When(func(l LogLine) bool {
matched, err := util.MatchesRegex(l.Message, pattern)
return err == nil && matched
}, "message matches "+pattern)
}
// OnPlatforms restricts the rule to specific platforms
func (r *RuleBuilder) OnPlatforms(platforms ...string) *RuleBuilder {
r.platforms = platforms
return r
}
// Branch adds a new branch with additional conditions
func (r *RuleBuilder) Branch() *BranchBuilder {
return &BranchBuilder{
rule: r,
branch: branch{
conditions: []condition{},
},
}
}
// Then sets the default observation and recommendation
func (r *RuleBuilder) Then(observation, recommendation string) *RuleBuilder {
r.defaultBranch.observation = observation
r.defaultBranch.recommendation = recommendation
return r
}
// WithSeverity sets the default severity
func (r *RuleBuilder) WithSeverity(severity string) *RuleBuilder {
r.defaultBranch.severity = severity
return r
}
// BranchBuilder helps build conditional branches
type BranchBuilder struct {
rule *RuleBuilder
branch branch
}
// When adds a condition to the branch
func (b *BranchBuilder) When(check func(LogLine) bool, message string) *BranchBuilder {
b.branch.conditions = append(b.branch.conditions, condition{check: check, message: message})
return b
}
// Then sets the branch observation and recommendation
func (b *BranchBuilder) Then(observation, recommendation string) *RuleBuilder {
b.branch.observation = observation
b.branch.recommendation = recommendation
b.rule.branches = append(b.rule.branches, b.branch)
return b.rule
}
// WithSeverity sets the branch severity
func (b *BranchBuilder) WithSeverity(severity string) *BranchBuilder {
b.branch.severity = severity
return b
}
// matches checks if a log line matches the rule and returns the matching branch
func (r *RuleBuilder) matches(line LogLine, platform string) (bool, *branch) {
// Check platform restrictions
if len(r.platforms) > 0 && !util.Contains(r.platforms, platform) {
return false, nil
}
// Check base conditions
for _, cond := range r.conditions {
if !cond.check(line) {
return false, nil
}
}
// Check branches in order
for _, branch := range r.branches {
matches := true
for _, cond := range branch.conditions {
if !cond.check(line) {
matches = false
break
}
}
if matches {
return true, &branch
}
}
// If no branches match but base conditions do, use default branch
return true, r.defaultBranch
}
// NewAnalyzer creates a new analyzer with the default rule groups
func NewAnalyzer(opts NewTroubleshooterOptions) *Troubleshooter {
a := &Troubleshooter{
logsDir: opts.LogsDir,
logger: opts.Logger,
state: opts.State,
rules: defaultRules(),
}
return a
}
// defaultRules returns the default set of rules
func defaultRules() []RuleBuilder {
return []RuleBuilder{
*mpvRules(),
}
}
// Analyze analyzes the logs in the logs directory and returns an AnalysisResult
// App.OnFlushLogs should be called before this function
func (t *Troubleshooter) Analyze() (AnalysisResult, error) {
files, err := os.ReadDir(t.logsDir)
if err != nil {
return AnalysisResult{}, err
}
if len(files) == 0 {
return AnalysisResult{}, errors.New("no logs found")
}
// Get the latest server log file
// name: seanime-<timestamp>.log
// e.g., seanime-2025-01-21-12-00-00.log
sort.Slice(files, func(i, j int) bool {
return files[i].Name() > files[j].Name()
})
latestFile := files[0]
return analyzeLogFile(filepath.Join(t.logsDir, latestFile.Name()))
}
// LogLine represents a parsed log line
type LogLine struct {
Timestamp time.Time
Line string
Module string
Level string
Message string
}
// analyzeLogFile analyzes a log file and returns an AnalysisResult
func analyzeLogFile(filepath string) (res AnalysisResult, err error) {
platform := runtime.GOOS
// Read the log file
content, err := os.ReadFile(filepath)
if err != nil {
return res, err
}
lines := strings.Split(string(content), "\n")
// Get lines no older than 1 hour
_lines := []string{}
for _, line := range lines {
timestamp, ok := util.SliceStrTo(line, len(time.DateTime))
if !ok {
continue
}
timestampTime, err := time.Parse(time.DateTime, timestamp)
if err != nil {
continue
}
if time.Since(timestampTime) < 1*time.Hour {
_lines = append(_lines, line)
}
}
lines = _lines
// Parse lines into LogLine
logLines := []LogLine{}
for _, line := range lines {
logLine, err := parseLogLine(line)
if err != nil {
continue
}
logLines = append(logLines, logLine)
}
// Group log lines by rule group
type matchGroup struct {
ruleGroup *RuleBuilder
branch *branch
logLines []LogLine
}
matches := make(map[string]*matchGroup) // key is rule group name
// For each log line, check against all rules
for _, logLine := range logLines {
for _, ruleGroup := range defaultRules() {
if matched, branch := ruleGroup.matches(logLine, platform); matched {
if _, ok := matches[ruleGroup.name]; !ok {
matches[ruleGroup.name] = &matchGroup{
ruleGroup: &ruleGroup,
branch: branch,
logLines: []LogLine{},
}
}
matches[ruleGroup.name].logLines = append(matches[ruleGroup.name].logLines, logLine)
break // Stop checking other rules in this group once we find a match
}
}
}
// Convert matches to analysis result items
for _, group := range matches {
item := AnalysisResultItem{
Observation: group.branch.observation,
Recommendation: group.branch.recommendation,
Severity: group.branch.severity,
}
// Add log lines based on their level
for _, logLine := range group.logLines {
switch logLine.Level {
case "error":
item.Errors = append(item.Errors, logLine.Line)
case "warning":
item.Warnings = append(item.Warnings, logLine.Line)
default:
item.Logs = append(item.Logs, logLine.Line)
}
}
res.Items = append(res.Items, item)
}
return res, nil
}
func parseLogLine(line string) (ret LogLine, err error) {
ret.Line = line
timestamp, ok := util.SliceStrTo(line, len(time.DateTime))
if !ok {
return LogLine{}, errors.New("failed to parse timestamp")
}
timestampTime, err := time.Parse(time.DateTime, timestamp)
if err != nil {
return LogLine{}, errors.New("failed to parse timestamp")
}
ret.Timestamp = timestampTime
rest, ok := util.SliceStrFrom(line, len(timestamp))
if !ok {
return LogLine{}, errors.New("failed to parse rest")
}
rest = strings.TrimSpace(rest)
if strings.HasPrefix(rest, "|ERR|") {
ret.Level = "error"
} else if strings.HasPrefix(rest, "|WRN|") {
ret.Level = "warning"
} else if strings.HasPrefix(rest, "|INF|") {
ret.Level = "info"
} else if strings.HasPrefix(rest, "|DBG|") {
ret.Level = "debug"
} else if strings.HasPrefix(rest, "|TRC|") {
ret.Level = "trace"
} else if strings.HasPrefix(rest, "|PNC|") {
ret.Level = "panic"
}
// Remove the level prefix
rest, ok = util.SliceStrFrom(rest, 6)
if !ok {
return LogLine{}, errors.New("failed to parse rest")
}
// Get the module (string before `>`)
moduleCaretIndex := strings.Index(rest, ">")
if moduleCaretIndex != -1 {
ret.Module = strings.TrimSpace(rest[:moduleCaretIndex])
rest = strings.TrimSpace(rest[moduleCaretIndex+1:])
}
ret.Message = rest
return
}