Files
logwisp/src/internal/source/directory.go

287 lines
6.1 KiB
Go

// FILE: logwisp/src/internal/source/directory.go
package source
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lixenwraith/log"
)
// DirectorySource monitors a directory for log files
type DirectorySource struct {
path string
pattern string
checkInterval time.Duration
subscribers []chan LogEntry
watchers map[string]*fileWatcher
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
totalEntries atomic.Uint64
droppedEntries atomic.Uint64
startTime time.Time
lastEntryTime atomic.Value // time.Time
logger *log.Logger
}
// NewDirectorySource creates a new directory monitoring source
func NewDirectorySource(options map[string]any, logger *log.Logger) (*DirectorySource, error) {
path, ok := options["path"].(string)
if !ok {
return nil, fmt.Errorf("directory source requires 'path' option")
}
pattern, _ := options["pattern"].(string)
if pattern == "" {
pattern = "*"
}
checkInterval := 100 * time.Millisecond
if ms, ok := options["check_interval_ms"].(int64); ok && ms > 0 {
checkInterval = time.Duration(ms) * time.Millisecond
}
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("invalid path %s: %w", path, err)
}
ds := &DirectorySource{
path: absPath,
pattern: pattern,
checkInterval: checkInterval,
watchers: make(map[string]*fileWatcher),
startTime: time.Now(),
logger: logger,
}
ds.lastEntryTime.Store(time.Time{})
return ds, nil
}
func (ds *DirectorySource) Subscribe() <-chan LogEntry {
ds.mu.Lock()
defer ds.mu.Unlock()
ch := make(chan LogEntry, 1000)
ds.subscribers = append(ds.subscribers, ch)
return ch
}
func (ds *DirectorySource) Start() error {
ds.ctx, ds.cancel = context.WithCancel(context.Background())
ds.wg.Add(1)
go ds.monitorLoop()
ds.logger.Info("msg", "Directory source started",
"component", "directory_source",
"path", ds.path,
"pattern", ds.pattern,
"check_interval_ms", ds.checkInterval.Milliseconds())
return nil
}
func (ds *DirectorySource) Stop() {
if ds.cancel != nil {
ds.cancel()
}
ds.wg.Wait()
ds.mu.Lock()
for _, w := range ds.watchers {
w.close()
}
for _, ch := range ds.subscribers {
close(ch)
}
ds.mu.Unlock()
ds.logger.Info("msg", "Directory source stopped",
"component", "directory_source",
"path", ds.path)
}
func (ds *DirectorySource) GetStats() SourceStats {
lastEntry, _ := ds.lastEntryTime.Load().(time.Time)
ds.mu.RLock()
watcherCount := int64(len(ds.watchers))
details := make(map[string]any)
// Add watcher details
watchers := make([]map[string]any, 0, watcherCount)
for _, w := range ds.watchers {
info := w.getInfo()
watchers = append(watchers, map[string]any{
"path": info.Path,
"size": info.Size,
"position": info.Position,
"entries_read": info.EntriesRead,
"rotations": info.Rotations,
"last_read": info.LastReadTime,
})
}
details["watchers"] = watchers
details["active_watchers"] = watcherCount
ds.mu.RUnlock()
return SourceStats{
Type: "directory",
TotalEntries: ds.totalEntries.Load(),
DroppedEntries: ds.droppedEntries.Load(),
StartTime: ds.startTime,
LastEntryTime: lastEntry,
Details: details,
}
}
func (ds *DirectorySource) publish(entry LogEntry) {
ds.mu.RLock()
defer ds.mu.RUnlock()
ds.totalEntries.Add(1)
ds.lastEntryTime.Store(entry.Time)
for _, ch := range ds.subscribers {
select {
case ch <- entry:
default:
ds.droppedEntries.Add(1)
ds.logger.Debug("msg", "Dropped log entry - subscriber buffer full",
"component", "directory_source")
}
}
}
func (ds *DirectorySource) monitorLoop() {
defer ds.wg.Done()
ds.checkTargets()
ticker := time.NewTicker(ds.checkInterval)
defer ticker.Stop()
for {
select {
case <-ds.ctx.Done():
return
case <-ticker.C:
ds.checkTargets()
}
}
}
func (ds *DirectorySource) checkTargets() {
files, err := ds.scanDirectory()
if err != nil {
ds.logger.Warn("msg", "Failed to scan directory",
"component", "directory_source",
"path", ds.path,
"pattern", ds.pattern,
"error", err)
return
}
for _, file := range files {
ds.ensureWatcher(file)
}
ds.cleanupWatchers()
}
func (ds *DirectorySource) scanDirectory() ([]string, error) {
entries, err := os.ReadDir(ds.path)
if err != nil {
return nil, err
}
// Convert glob pattern to regex
regexPattern := globToRegex(ds.pattern)
re, err := regexp.Compile(regexPattern)
if err != nil {
return nil, fmt.Errorf("invalid pattern regex: %w", err)
}
var files []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if re.MatchString(name) {
files = append(files, filepath.Join(ds.path, name))
}
}
return files, nil
}
func (ds *DirectorySource) ensureWatcher(path string) {
ds.mu.Lock()
defer ds.mu.Unlock()
if _, exists := ds.watchers[path]; exists {
return
}
w := newFileWatcher(path, ds.publish, ds.logger)
ds.watchers[path] = w
ds.logger.Debug("msg", "Created file watcher",
"component", "directory_source",
"path", path)
ds.wg.Add(1)
go func() {
defer ds.wg.Done()
if err := w.watch(ds.ctx); err != nil {
if errors.Is(err, context.Canceled) {
ds.logger.Debug("msg", "Watcher cancelled",
"component", "directory_source",
"path", path)
} else {
ds.logger.Error("msg", "Watcher failed",
"component", "directory_source",
"path", path,
"error", err)
}
}
ds.mu.Lock()
delete(ds.watchers, path)
ds.mu.Unlock()
}()
}
func (ds *DirectorySource) cleanupWatchers() {
ds.mu.Lock()
defer ds.mu.Unlock()
for path, w := range ds.watchers {
if _, err := os.Stat(path); os.IsNotExist(err) {
w.stop()
delete(ds.watchers, path)
ds.logger.Debug("msg", "Cleaned up watcher for non-existent file",
"component", "directory_source",
"path", path)
}
}
}
func globToRegex(glob string) string {
regex := regexp.QuoteMeta(glob)
regex = strings.ReplaceAll(regex, `\*`, `.*`)
regex = strings.ReplaceAll(regex, `\?`, `.`)
return "^" + regex + "$"
}