// Package storage implements persistent storage for telemetry data. package storage import ( "bufio" "context" "encoding/json" "os" "strings" "git.tornberg.me/go-otel/internal/websocket" "github.com/natefinch/lumberjack" ) // FileStore implements the Store interface using a file-based logger. type FileStore struct { logger *lumberjack.Logger } // NewFileStore creates a new FileStore instance that writes to filename. func NewFileStore(filename string) *FileStore { return &FileStore{ logger: &lumberjack.Logger{ Filename: filename, MaxSize: 100, // megabytes MaxBackups: 3, MaxAge: 28, // days Compress: true, // disabled by default }, } } // Append saves a new history entry to the file store. func (fs *FileStore) Append(ctx context.Context, entry websocket.HistoryEntry) error { data, err := json.Marshal(entry) if err != nil { return err } _, err = fs.logger.Write(append(data, '\n')) return err } // GetHistory retrieves historical entries from the file store. func (fs *FileStore) GetHistory(ctx context.Context, limit int) ([]websocket.HistoryEntry, error) { return fs.Search(ctx, SearchOptions{}, limit) } // entryHead is used to cheaply inspect an entry's type/timestamp without // unmarshaling the (potentially large) Data payload. type entryHead struct { Type string `json:"type"` Timestamp int64 `json:"timestamp"` } // matches reports whether the given line satisfies the type/time filters in opts. // The free-text Query filter is applied separately by the caller against the raw line. func (opts SearchOptions) matches(line string) bool { if opts.Type == "" && opts.Since == 0 && opts.Until == 0 { return true } var head entryHead if err := json.Unmarshal([]byte(line), &head); err != nil { return false } if opts.Type != "" && head.Type != opts.Type { return false } if opts.Since != 0 && head.Timestamp < opts.Since { return false } if opts.Until != 0 && head.Timestamp > opts.Until { return false } return true } // Search searches for historical entries in the file store matching opts. func (fs *FileStore) Search(ctx context.Context, opts SearchOptions, limit int) ([]websocket.HistoryEntry, error) { if limit <= 0 { limit = 50 // Default to a manageable number } file, err := os.Open(fs.logger.Filename) if err != nil { if os.IsNotExist(err) { return nil, nil // No logs yet } return nil, err } defer func() { _ = file.Close() }() var matchingLines []string scanner := bufio.NewScanner(file) // Use a 10MB buffer for the scanner to handle large trace/log entries const maxCapacity = 10 * 1024 * 1024 buf := make([]byte, 64*1024) scanner.Buffer(buf, maxCapacity) // Lowercase query for case-insensitive search lowerQuery := strings.ToLower(opts.Query) for scanner.Scan() { line := scanner.Text() if opts.Query != "" && !strings.Contains(strings.ToLower(line), lowerQuery) { continue } if !opts.matches(line) { continue } matchingLines = append(matchingLines, line) // Only keep the most recent 'limit' matches to save memory if len(matchingLines) > limit { matchingLines = matchingLines[1:] } } var results []websocket.HistoryEntry // Reverse to get latest first (since matchingLines contains them in chronological order) for i := len(matchingLines) - 1; i >= 0; i-- { var entry websocket.HistoryEntry if err := json.Unmarshal([]byte(matchingLines[i]), &entry); err == nil { results = append(results, entry) } } return results, scanner.Err() }