Files
logstream/filesource.go
cedricandClaude Opus 5.5 1a3b3ee23a Log files: find the history by date, count from the first poll
A file turned on was read from its start to find its last hour: with a
Traefik access log that holds months of lines (never rotated by
Traefik), LogStream spent minutes on old lines that were all left out,
and new lines waited behind them. Entries sent during the first poll
were also left out of the count shown in Settings.

- start of the FILES_BACKFILL history found by bisection on the dates
  of the lines; files without dates: only new lines
- up to 16 reads of 4 MB per file and per poll
- status "behind" (bytes left to read), shown as "Reading the history:
  N MB left"
- the status of a file exists before its first entries are counted

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:42:09 +02:00

712 lines
18 KiB
Go

package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// fileSourceDef is one log file declared in Settings > Sources.
type fileSourceDef struct {
ID string `json:"id"`
Name string `json:"name"` // app of the entries
Path string `json:"path"` // the file, as mounted in the container
Format string `json:"format"` // auto (detected), or text, json, logfmt, regex, clf, traefik
Regex string `json:"regex,omitempty"` // format regex: named groups time, level, msg, host, app…
Host string `json:"host,omitempty"` // host of the entries (empty: the host name of the machine)
Enabled bool `json:"enabled"`
}
// filesConfig is saved in /data/files.json.
type filesConfig struct {
Sources []fileSourceDef `json:"sources"`
}
// filePos is where reading resumes in a file: its inode and an offset before
// which every entry was sent. Until: end of the history found when the
// source was turned on; entries before it are kept only when dated within
// FILES_BACKFILL.
type filePos struct {
Ino uint64 `json:"ino"`
Off int64 `json:"off"`
Until int64 `json:"until,omitempty"`
}
type filesState struct {
Pos map[string]filePos `json:"pos"` // by source id
Started map[string]time.Time `json:"started"` // when each source was turned on
}
type fileSourceStatus struct {
Open bool
Read uint64
Detected string // format found when the source is in auto
Behind int64 // bytes left to read (history being read)
Code string
Error string
}
// fileSource is a definition with its parser (nil in auto: detected from
// the file).
type fileSource struct {
fileSourceDef
parser *fileParser
}
// pendingEntry is an entry waiting for its continuation lines.
type pendingEntry struct {
rec *fileRec
off int64 // offset of its first line
seen time.Time // when its last line was read
history bool // read from the history found when the source was turned on
lines int
}
// fileTail is the reading state of one file, owned by the Run goroutine.
type fileTail struct {
readOff int64 // where the next read starts (after the lines of the pending entry)
known bool // readOff is set
pending *pendingEntry
auto *fileParser // format detected from the file, in auto
behind int64 // bytes left to read after the last poll
}
// Files follows log files of other programs (Traefik, nginx, applications…)
// mounted in the container, one entry per line in the format detected or
// chosen for each file. Source type "file".
type Files struct {
sink func(*Entry)
hostName string
backfill time.Duration
cfgPath string
statePath string
wake chan struct{}
// Owned by the Run goroutine.
st filesState
tails map[string]*fileTail
dirty bool
mu sync.Mutex
cfg []*fileSource
resets map[string]bool // sources to read again from FILES_BACKFILL ago
redetect map[string]bool // sources whose format changed
status map[string]*fileSourceStatus
}
func NewFiles(hostRoot, dataDir string, backfill time.Duration, sink func(*Entry)) *Files {
f := &Files{
sink: sink,
hostName: readTrim(filepath.Join(hostRoot, "etc/hostname")),
backfill: backfill,
cfgPath: filepath.Join(dataDir, "files.json"),
statePath: filepath.Join(dataDir, "files-state.json"),
wake: make(chan struct{}, 1),
tails: map[string]*fileTail{},
resets: map[string]bool{},
redetect: map[string]bool{},
status: map[string]*fileSourceStatus{},
}
if f.hostName == "" {
f.hostName = "localhost"
}
var cfg filesConfig
if b, err := os.ReadFile(f.cfgPath); err == nil {
_ = json.Unmarshal(b, &cfg)
}
for _, d := range cfg.Sources {
src, err := compileFileSource(d)
if err != nil {
logEvent("files", 4, "file %q ignored: %v", d.Name, err)
continue
}
f.cfg = append(f.cfg, src)
}
if b, err := os.ReadFile(f.statePath); err == nil {
_ = json.Unmarshal(b, &f.st)
}
if f.st.Pos == nil {
f.st.Pos = map[string]filePos{}
}
if f.st.Started == nil {
f.st.Started = map[string]time.Time{}
}
return f
}
const maxFileSources = 50
var fileIDRe = regexp.MustCompile(`^[0-9a-f]{1,32}$`)
// checkFilePath: one file, absolute, without wildcards.
func checkFilePath(p string) (string, error) {
p = strings.TrimSpace(p)
switch {
case !filepath.IsAbs(p):
return "", errors.New("the path must be absolute (as mounted in the container)")
case strings.ContainsAny(p, "*?["):
return "", errors.New("give the path of one file (no wildcards)")
}
return filepath.Clean(p), nil
}
// compileFileSource checks a definition and builds its parser.
func compileFileSource(d fileSourceDef) (*fileSource, error) {
d.Name = strings.TrimSpace(d.Name)
d.Host = strings.TrimSpace(d.Host)
if d.Format == "" {
d.Format = fmtAuto
}
if d.Format != fmtRegex {
d.Regex = ""
}
switch {
case !fileIDRe.MatchString(d.ID):
return nil, errors.New("invalid id")
case d.Name == "" || len(d.Name) > 64:
return nil, errors.New("the name is required (64 characters at most)")
case len(d.Host) > 253:
return nil, fmt.Errorf("%s: host name too long", d.Name)
}
p, err := checkFilePath(d.Path)
if err != nil {
return nil, fmt.Errorf("%s: %v", d.Name, err)
}
d.Path = p
src := &fileSource{fileSourceDef: d}
if d.Format != fmtAuto {
if src.parser, err = newFileParser(d.Format, d.Regex); err != nil {
return nil, fmt.Errorf("%s: %v", d.Name, err)
}
}
return src, nil
}
func newFileID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Configure checks, saves and applies the list of files. Files turned off,
// removed or given another path are read again from FILES_BACKFILL ago the
// next time they are on.
func (f *Files) Configure(cfg filesConfig) error {
if len(cfg.Sources) > maxFileSources {
return fmt.Errorf("%d files at most", maxFileSources)
}
var srcs []*fileSource
ids := map[string]bool{}
for _, d := range cfg.Sources {
if d.ID == "" {
d.ID = newFileID()
}
if ids[d.ID] {
return fmt.Errorf("duplicate id %s", d.ID)
}
ids[d.ID] = true
src, err := compileFileSource(d)
if err != nil {
return err
}
srcs = append(srcs, src)
}
saved := filesConfig{Sources: []fileSourceDef{}}
for _, s := range srcs {
saved.Sources = append(saved.Sources, s.fileSourceDef)
}
if err := writeJSONFile(f.cfgPath, saved); err != nil {
return err
}
f.mu.Lock()
old := map[string]*fileSource{}
for _, s := range f.cfg {
old[s.ID] = s
}
for _, s := range srcs {
o := old[s.ID]
if o != nil && ((o.Enabled && !s.Enabled) || o.Path != s.Path) {
f.resets[s.ID] = true
delete(f.status, s.ID)
}
if o != nil && (o.Format != s.Format || o.Regex != s.Regex) {
f.redetect[s.ID] = true
}
delete(old, s.ID)
}
for id := range old { // removed
f.resets[id] = true
delete(f.status, id)
}
f.cfg = srcs
f.mu.Unlock()
select {
case f.wake <- struct{}{}:
default:
}
return nil
}
// Status is the state shown in Settings > Sources.
func (f *Files) Status() map[string]any {
f.mu.Lock()
defer f.mu.Unlock()
list := make([]map[string]any, 0, len(f.cfg))
for _, s := range f.cfg {
m := map[string]any{
"id": s.ID, "name": s.Name, "path": s.Path, "format": s.Format, "regex": s.Regex,
"host": s.Host, "enabled": s.Enabled, "open": false, "read": 0, "detected": "", "behind": 0, "code": "", "error": "",
}
if st := f.status[s.ID]; st != nil && s.Enabled {
m["open"], m["read"], m["detected"], m["code"], m["error"] = st.Open, st.Read, st.Detected, st.Code, st.Error
m["behind"] = st.Behind
}
list = append(list, m)
}
return map[string]any{"sources": list, "defaultHost": f.hostName}
}
// Run polls the files every second.
func (f *Files) Run(ctx context.Context) {
tick := time.NewTicker(time.Second)
defer tick.Stop()
n := 0
for {
select {
case <-ctx.Done():
now := time.Now()
for id := range f.tails {
f.flushPending(id, now)
}
f.checkpointAll()
f.saveState()
return
case <-tick.C:
case <-f.wake:
}
f.poll(time.Now())
if n++; n%5 == 0 {
f.saveState()
}
}
}
func (f *Files) saveState() {
if !f.dirty {
return
}
f.dirty = false
if err := writeJSONFile(f.statePath, f.st); err != nil {
logEvent("files", 4, "saving positions: %v", err)
}
}
// poll reads what was added to every file that is on.
func (f *Files) poll(now time.Time) {
f.mu.Lock()
srcs := f.cfg
resets, redetect := f.resets, f.redetect
f.resets, f.redetect = map[string]bool{}, map[string]bool{}
f.mu.Unlock()
for id := range resets {
delete(f.st.Pos, id)
delete(f.st.Started, id)
delete(f.tails, id)
f.dirty = true
}
for id := range redetect {
if tl := f.tails[id]; tl != nil {
tl.auto = nil
}
}
active := map[string]bool{}
for _, s := range srcs {
if !s.Enabled {
continue
}
active[s.ID] = true
f.setStatus(s.ID, f.pollFile(s, now))
}
// Files turned off: their position is kept until the next poll forgets it.
for id := range f.tails {
if !active[id] {
delete(f.tails, id)
}
}
f.checkpointAll()
}
const (
maxFileRead = 4 << 20 // per read
maxFileReads = 16 // reads per file and per poll (64 MB)
fileContinueDelay = 2 * time.Second // an entry waits this long for continuation lines
)
func (f *Files) setStatus(id string, err error) {
code, detail := "", ""
if err != nil {
code, detail = errCode(err), err.Error()
if errors.Is(err, fs.ErrNotExist) {
code = "not_found"
}
}
detected := ""
if tl := f.tails[id]; tl != nil && tl.auto != nil {
detected = tl.auto.format
}
f.mu.Lock()
st := f.status[id]
if st == nil {
st = &fileSourceStatus{}
f.status[id] = st
}
changed := code != st.Code
st.Open, st.Detected, st.Code, st.Error = err == nil, detected, code, detail
st.Behind = 0
if tl := f.tails[id]; tl != nil && err == nil {
st.Behind = tl.behind
}
name := id
for _, s := range f.cfg {
if s.ID == id {
name = s.Name
}
}
f.mu.Unlock()
if changed && code != "" && code != "not_found" {
logEvent("files", 4, "cannot read the file %s: %v", name, err)
}
}
func (f *Files) count(id string) {
f.mu.Lock()
st := f.status[id]
if st == nil { // first poll of the file: its status comes after
st = &fileSourceStatus{}
f.status[id] = st
}
st.Read++
f.mu.Unlock()
}
// parserOf is the parser of a file: the chosen format, or the one detected
// from its last lines (nil while the file has none).
func (f *Files) parserOf(s *fileSource, tl *fileTail, path string) *fileParser {
if s.parser != nil {
return s.parser
}
if tl.auto == nil {
lines, err := sampleLines(path)
if err != nil || len(lines) == 0 {
return nil
}
tl.auto, _ = newFileParser(detectFormat(lines), "")
}
return tl.auto
}
func (f *Files) pollFile(s *fileSource, now time.Time) error {
path := s.Path
first := false
if _, ok := f.st.Started[s.ID]; !ok {
f.st.Started[s.ID], f.dirty, first = now, true, true
}
fi, err := os.Stat(path)
if err != nil {
return err
}
if !fi.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", path)
}
tl := f.tails[s.ID]
if tl == nil {
tl = &fileTail{}
f.tails[s.ID] = tl
}
ino := inodeOf(fi)
pos, known := f.st.Pos[s.ID]
if !known {
pos = filePos{Ino: ino}
if first {
// Turned on: the last FILES_BACKFILL of what is already there
// (0: only new lines), found by date rather than read from the
// start of a file that can hold months of logs.
pos.Off = fi.Size()
if p := f.parserOf(s, tl, path); f.backfill > 0 && p != nil {
pos.Off = backfillStart(path, p, fi.Size(), f.st.Started[s.ID].Add(-f.backfill), now)
pos.Until = fi.Size()
}
}
f.st.Pos[s.ID], f.dirty = pos, true
}
if !tl.known {
tl.readOff, tl.known = pos.Off, true
}
if pos.Ino != ino {
// Rotated (access.log -> access.log.1): finish the old file, then
// read the new one from its start.
if old, err := os.Stat(path + ".1"); err == nil && inodeOf(old) == pos.Ino {
if p := f.parserOf(s, tl, path+".1"); p != nil {
for {
next, more, err := f.readFrom(s, tl, p, path+".1", tl.readOff, pos.Until, now)
if err != nil || !more {
break
}
tl.readOff = next
}
}
}
f.flushPending(s.ID, now)
pos, tl.readOff = filePos{Ino: ino}, 0
f.st.Pos[s.ID], f.dirty = pos, true
} else if fi.Size() < tl.readOff {
f.flushPending(s.ID, now) // truncated (copytruncate)
pos.Until, tl.readOff = 0, 0
f.st.Pos[s.ID], f.dirty = pos, true
}
if fi.Size() > tl.readOff {
p := f.parserOf(s, tl, path)
if p == nil {
return nil // nothing complete to detect the format from yet
}
for i := 0; i < maxFileReads; i++ {
next, more, err := f.readFrom(s, tl, p, path, tl.readOff, pos.Until, now)
if err != nil {
return err
}
tl.readOff = next
if !more {
break
}
}
}
tl.behind = max(fi.Size()-tl.readOff, 0)
if pe := tl.pending; pe != nil && now.Sub(pe.seen) >= fileContinueDelay {
f.flushPending(s.ID, now)
}
return nil
}
// backfillStart finds where the lines dated notBefore or later start, by
// bisection on the dates of the lines (logs are written in order). Undated
// lines are left out of the history anyway: then only new lines are read.
func backfillStart(path string, p *fileParser, size int64, notBefore, now time.Time) int64 {
fh, err := os.Open(path)
if err != nil {
return size
}
defer fh.Close()
buf := make([]byte, 64<<10)
// lineAt reads the first line starting at or after off (off itself when
// it is 0): its start and its date.
lineAt := func(off int64) (int64, time.Time, bool) {
n, _ := fh.ReadAt(buf, off)
b := buf[:n]
start := off
if off > 0 {
nl := bytes.IndexByte(b, '\n')
if nl < 0 {
return size, time.Time{}, false
}
b, start = b[nl+1:], off+int64(nl)+1
}
end := bytes.IndexByte(b, '\n')
if end < 0 {
return size, time.Time{}, false
}
r := p.parse(string(b[:end]), now)
return start, r.t, r.dated
}
if _, t, ok := lineAt(0); !ok || !t.Before(notBefore) {
if !ok {
// No date on the first line: try the last ones.
if _, _, ok := lineAt(max(size-int64(len(buf)), 0)); !ok {
return size
}
} else {
return 0 // all of it is recent
}
}
lo, hi := int64(0), size // line at lo: before notBefore; at hi: not
for hi-lo > int64(len(buf)) {
mid := lo + (hi-lo)/2
_, t, ok := lineAt(mid)
if ok && t.Before(notBefore) {
lo = mid
} else {
hi = mid
}
}
start, _, _ := lineAt(lo)
return min(start, size)
}
// readFrom parses the complete lines written after off and returns the new
// offset; more tells whether the read window was full.
func (f *Files) readFrom(s *fileSource, tl *fileTail, p *fileParser, path string, off, until int64, now time.Time) (int64, bool, error) {
fh, err := os.Open(path)
if err != nil {
return off, false, err
}
defer fh.Close()
buf, err := io.ReadAll(io.NewSectionReader(fh, off, maxFileRead))
if err != nil {
return off, false, err
}
full := len(buf) == maxFileRead
end := bytes.LastIndexByte(buf, '\n')
if end < 0 {
if !full {
return off, false, nil // partial line: wait for its end
}
end = len(buf) - 1 // line longer than the read window: cut it
}
start := off
for _, b := range bytes.Split(buf[:end], []byte{'\n'}) {
f.line(s, tl, p, string(b), start, start < until, now)
start += int64(len(b)) + 1
}
return off + int64(end) + 1, full, nil
}
// line handles one line: a continuation of the pending entry, or a new one.
func (f *Files) line(s *fileSource, tl *fileTail, p *fileParser, line string, off int64, history bool, now time.Time) {
if strings.TrimSpace(line) == "" {
return
}
if pe := tl.pending; p.multiline() && pe != nil && p.continuation(line) {
if pe.lines < maxContinuation && len(pe.rec.msg) < maxFileMsg {
pe.rec.msg += "\n" + strings.TrimRight(cleanLine(line), " \t")
pe.lines++
}
pe.seen = now
return
}
f.flushPending(s.ID, now)
pe := &pendingEntry{rec: p.parse(line, now), off: off, seen: now, history: history}
if p.multiline() {
tl.pending = pe
return
}
f.emit(s, pe, now)
}
func (f *Files) flushPending(id string, now time.Time) {
tl := f.tails[id]
if tl == nil || tl.pending == nil {
return
}
pe := tl.pending
tl.pending = nil
f.mu.Lock()
var src *fileSource
for _, s := range f.cfg {
if s.ID == id {
src = s
}
}
f.mu.Unlock()
if src != nil {
f.emit(src, pe, now)
}
}
// emit sends an entry; history entries are kept only when dated within
// FILES_BACKFILL of when the file was turned on.
func (f *Files) emit(s *fileSource, pe *pendingEntry, now time.Time) {
r := pe.rec
if pe.history {
notBefore := f.st.Started[s.ID].Add(-f.backfill)
if !r.dated || r.t.Before(notBefore) {
return
}
}
if len(r.msg) > maxFileMsg {
r.msg = strings.ToValidUTF8(r.msg[:maxFileMsg], "") + "…"
}
extra := map[string]string{"log_file": s.Path}
for k, v := range r.fields {
extra[k] = v
}
f.sink(&Entry{
Time: r.t,
Received: now,
Host: firstNonEmpty(r.host, s.Host, f.hostName),
App: firstNonEmpty(r.app, s.Name),
SevNum: r.sev,
Severity: severityNames[r.sev],
Message: r.msg,
Proto: "file",
SourceType: "file",
Extra: extra,
Wait: true, // the file keeps the lines: wait for room in the queue
})
f.count(s.ID)
}
// checkpointAll saves, for each file, an offset before which every entry is
// sent: the start of its pending entry, or the read offset.
func (f *Files) checkpointAll() {
for id, tl := range f.tails {
pos, ok := f.st.Pos[id]
if !ok || !tl.known {
continue
}
off := tl.readOff
if pe := tl.pending; pe != nil && pe.off < off {
off = pe.off
}
if off != pos.Off {
pos.Off, f.dirty = off, true
f.st.Pos[id] = pos
}
}
}
// DetectFile tells, for the settings, whether a file can be read and its
// format, with a line as example.
func DetectFile(path string) map[string]any {
res := map[string]any{"format": "", "sample": "", "code": "", "error": ""}
p, err := checkFilePath(path)
if err != nil {
res["code"], res["error"] = "invalid", err.Error()
return res
}
var lines []string
if fi, err2 := os.Stat(p); err2 != nil {
err = err2
} else if !fi.Mode().IsRegular() {
err = fmt.Errorf("%s is not a regular file", p)
} else {
lines, err = sampleLines(p)
}
switch {
case errors.Is(err, fs.ErrNotExist):
res["code"], res["error"] = "not_found", err.Error()
case err != nil:
res["code"], res["error"] = errCode(err), err.Error()
case len(lines) == 0:
res["code"] = "empty"
default:
res["format"] = detectFormat(lines)
sample := lines[len(lines)-1]
if len(sample) > 300 {
sample = strings.ToValidUTF8(sample[:300], "") + "…"
}
res["sample"] = cleanLine(sample)
}
return res
}