package web import ( "strings" "sync" "time" ) // LogEntry represents a single parsed log line. type LogEntry struct { Timestamp time.Time `json:"timestamp"` Level string `json:"level"` // "DEBUG", "INFO", "WARN", "ERROR" Message string `json:"message"` Source string `json:"source"` // "file.go:123" if Lshortfile enabled } // LogBuffer is a thread-safe ring buffer that captures log output. // It implements io.Writer so it can be used with log.New(io.MultiWriter(...)). type LogBuffer struct { mu sync.RWMutex entries []LogEntry size int pos int full bool } // NewLogBuffer creates a ring buffer that keeps the last `size` log entries. func NewLogBuffer(size int) *LogBuffer { return &LogBuffer{ entries: make([]LogEntry, size), size: size, } } // Write implements io.Writer. It parses Go's standard log output format. // Handles two formats: // - With Lshortfile: "2026/02/21 18:33:35 file.go:123: [LEVEL] message" // - Without: "2026/02/21 18:33:35 [LEVEL] message" func (lb *LogBuffer) Write(p []byte) (n int, err error) { line := strings.TrimRight(string(p), "\n\r") if line == "" { return len(p), nil } entry := parseLine(line) lb.mu.Lock() lb.entries[lb.pos] = entry lb.pos = (lb.pos + 1) % lb.size if lb.pos == 0 && !lb.full { lb.full = true } lb.mu.Unlock() return len(p), nil } // Entries returns log entries filtered by minimum level, limited by count, // and optionally filtered to entries after a given timestamp. // Returns the matching entries and the total count in the buffer. func (lb *LogBuffer) Entries(minLevel string, limit int, after time.Time) ([]LogEntry, int) { lb.mu.RLock() defer lb.mu.RUnlock() // Collect all entries in chronological order total := lb.size if !lb.full { total = lb.pos } if limit <= 0 || limit > 1000 { limit = 200 } levelOrder := levelPriority(minLevel) var result []LogEntry start := 0 if lb.full { start = lb.pos } for i := 0; i < total; i++ { idx := (start + i) % lb.size e := lb.entries[idx] // Filter by level if levelPriority(e.Level) < levelOrder { continue } // Filter by timestamp if !after.IsZero() && !e.Timestamp.After(after) { continue } result = append(result, e) } // Apply limit (keep the most recent entries) if len(result) > limit { result = result[len(result)-limit:] } return result, total } // parseLine parses a single log line into a LogEntry. func parseLine(line string) LogEntry { entry := LogEntry{ Level: "INFO", Message: line, } // Try to parse timestamp: "2006/01/02 15:04:05" // Use Local timezone because Go's log.LstdFlags outputs in local time. if len(line) >= 19 { if t, err := time.ParseInLocation("2006/01/02 15:04:05", line[:19], time.Local); err == nil { entry.Timestamp = t rest := line[19:] if len(rest) > 0 && rest[0] == ' ' { rest = rest[1:] } // Check for source file (Lshortfile): "file.go:123: [LEVEL] ..." if colonIdx := strings.Index(rest, ": "); colonIdx > 0 && colonIdx < 40 { candidate := rest[:colonIdx] // Source file pattern: contains ".go:" or ".go" before the colon if strings.Contains(candidate, ".go:") || strings.HasSuffix(candidate, ".go") { entry.Source = candidate rest = rest[colonIdx+2:] } } // Extract level tag: [DEBUG], [INFO], [WARN], [ERROR], [SYNC], [SCHED], etc. entry.Level, entry.Message = extractLevel(rest) } } if entry.Timestamp.IsZero() { entry.Timestamp = time.Now() } return entry } // extractLevel finds and removes a [LEVEL] tag from the beginning of a string. func extractLevel(s string) (string, string) { s = strings.TrimSpace(s) if len(s) < 3 || s[0] != '[' { return "INFO", s } end := strings.Index(s, "]") if end < 0 || end > 20 { return "INFO", s } tag := s[1:end] msg := strings.TrimSpace(s[end+1:]) switch tag { case "DEBUG": return "DEBUG", msg case "INFO": return "INFO", msg case "WARN": return "WARN", msg case "ERROR": return "ERROR", msg case "FATAL": return "ERROR", msg default: // Tags like [SYNC], [SCHED], [STORAGE] etc. — treat as INFO, keep tag in message return "INFO", s } } // levelPriority returns numeric priority for log levels. func levelPriority(level string) int { switch strings.ToUpper(level) { case "DEBUG": return 0 case "INFO": return 1 case "WARN": return 2 case "ERROR": return 3 default: return 0 } }