package main import ( "context" "fmt" "log/slog" "regexp" "strconv" "strings" "github.com/hpcloud/tail" ) type FileMonitor struct { config ToolConfig parser LogParser } type LogParser interface { Parse(line string, toolName string) LogEntry } func NewFileMonitor(config ToolConfig) *FileMonitor { var parser LogParser if config.Format.Pattern != "" { pattern, err := regexp.Compile(config.Format.Pattern) if err != nil { slog.Error("invalid regex pattern", "tool", config.Name, "error", err) parser = &DefaultLogParser{} } else { parser = &RegexLogParser{ pattern: pattern, fields: config.Format.Fields, } } } else { switch config.Name { case "nginx-tjm": parser = &NginxTJMLogParser{} default: parser = &DefaultLogParser{} } } return &FileMonitor{ config: config, parser: parser, } } func (fm *FileMonitor) Start(ctx context.Context, out chan<- LogEntry) error { t, err := tail.TailFile(fm.config.LogFile, tail.Config{ Follow: true, ReOpen: true, MustExist: false, Poll: true, Location: &tail.SeekInfo{Offset: 0, Whence: 2}, }) if err != nil { return fmt.Errorf("tail.TailFile: %w", err) } defer t.Stop() slog.Debug("Started tailing file", "file", fm.config.LogFile, "tool", fm.config.Name) for { select { case <-ctx.Done(): slog.Debug("File monitor stopped", "tool", fm.config.Name) return nil case line, ok := <-t.Lines: if !ok { return nil } if line.Err != nil { slog.Error("error reading log file", "tool", fm.config.Name, "error", line.Err) continue } if strings.TrimSpace(line.Text) == "" { continue } entry := fm.parser.Parse(line.Text, fm.config.Name) select { case out <- entry: case <-ctx.Done(): return nil default: slog.Warn("Log-Channel is full, entry dropped", "tool", fm.config.Name) } } } } type DefaultLogParser struct{} func (p *DefaultLogParser) Parse(line string, toolName string) LogEntry { entry := NewLogEntry("log_entry") entry.Tool = toolName entry.LogMessage = strings.TrimSpace(line) entry.Raw = line return entry } type RegexLogParser struct { pattern *regexp.Regexp fields map[string]string } func (p *RegexLogParser) Parse(line string, toolName string) LogEntry { entry := NewLogEntry("log_entry") entry.Tool = toolName entry.Raw = line fields := p.parseWithPattern(line) if fields != nil { entry.Fields = fields } else { entry.LogMessage = strings.TrimSpace(line) } return entry } func (p *RegexLogParser) parseWithPattern(text string) map[string]any { matches := p.pattern.FindStringSubmatch(text) if matches == nil { return nil } fields := make(map[string]any) subexpNames := p.pattern.SubexpNames() for i, match := range matches { if i == 0 { continue } if i < len(subexpNames) && subexpNames[i] != "" { fieldName := subexpNames[i] if mappedName, exists := p.fields[fieldName]; exists { fieldName = mappedName } fields[fieldName] = match } } return fields } type NginxTJMLogParser struct{} func (p *NginxTJMLogParser) Parse(line string, toolName string) LogEntry { entry := NewLogEntry("log_entry") entry.Tool = toolName entry.Raw = line entry = p.parseNginxTJM(entry) return entry } func (p *NginxTJMLogParser) parseNginxTJM(entry LogEntry) LogEntry { newEntry := entry var nginxBase NGinXBaseInfo parts := strings.Fields(entry.Raw) if len(parts) < 10 { return newEntry } if len(parts) > 0 { timestampStr := strings.Trim(parts[0], "[]") timestamp, err := parseRFC3339WithOptionalZ(timestampStr) if err != nil { slog.Error("unable to parse time", "error", err) } newEntry.Timestamp = timestamp } if len(parts) > 2 { nginxBase.ClientIP = parts[2] } for i, part := range parts { if strings.HasPrefix(part, "\"") { if i+1 < len(parts) { nginxBase.HTTPMethod = strings.Trim(part, "\"") nginxBase.Route = parts[i+1] } break } } for _, part := range parts { if after, ok := strings.CutPrefix(part, "status="); ok { statusCode, err := strconv.ParseInt(after, 10, 64) if err != nil { slog.Error("cant convert statuscode", "error", err) } nginxBase.StatusCode = int(statusCode) break } } newEntry.BaseInformation = nginxBase return newEntry }