Files
felhom-controller/controller/internal/web/logbuffer.go
T
admin 26a43708b7 v0.116.0: observability pass — always-on debug ring + leveled sweep + agent tab + self-log pull — MinAgent: 0.81.0
Capture layer: LogBuffer always exists; logger = MultiWriter(LevelFilterWriter
(stdout, logging.level), ring) so DEBUG detail exists remotely without a config
flip while docker logs keep respecting the level. New internal/logx leveled
helpers. Report ACK gains controller_log_requested (additive); next report
ships controller_log_tail (128KB, consume-once, app-tail wire byte-compatible).
Debug page: Vezérlő|Ügynök tabs; agent tab proxies agent /debug/logs with the
pre-0.83 notice on typed 404. Sweep: netstorage_job phases, netprobe, handler
validation refusals + orphan WARN, SupportsWithSource gate line, agentapi
per-call DEBUG, migrate phase lines, tier2/offbox unswallowed persists.
Red-proofs: filter-disabled, drain-removed, dropped-phase-line all FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 16:45:57 +02:00

229 lines
5.4 KiB
Go

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
}
// Lines renders the held entries as plain text lines (chronological), dropping
// from the HEAD (oldest) to honor maxBytes so the newest lines survive — the
// report-channel controller_log_tail budget (maxBytes ≤ 0 → no byte cap).
// Format mirrors the agent ring's: "<RFC3339> [<LEVEL>] <message>".
func (lb *LogBuffer) Lines(maxBytes int) []string {
lb.mu.RLock()
total := lb.size
start := 0
if !lb.full {
total = lb.pos
} else {
start = lb.pos
}
entries := make([]LogEntry, 0, total)
for i := 0; i < total; i++ {
entries = append(entries, lb.entries[(start+i)%lb.size])
}
lb.mu.RUnlock()
lines := make([]string, len(entries))
for i, e := range entries {
src := ""
if e.Source != "" {
src = e.Source + ": "
}
lines[i] = e.Timestamp.Format(time.RFC3339) + " [" + e.Level + "] " + src + e.Message
}
if maxBytes <= 0 {
return lines
}
budget := 0
keepFrom := len(lines)
for i := len(lines) - 1; i >= 0; i-- {
budget += len(lines[i]) + 1 // +1 for the newline it represents
if budget > maxBytes {
break
}
keepFrom = i
}
return lines[keepFrom:]
}
// 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
}
}