Files

157 lines
4.4 KiB
Go

package observability
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
// RotatingFile is an append-only daemon log with bounded numbered archives.
// It is deliberately a single-process writer: deployment supplies one daemon
// process per configured path, and a second writer must use a distinct file.
// Archive .1 is newest; .maxFiles is oldest.
type RotatingFile struct {
mu sync.Mutex
path string
maxBytes uint64
maxFiles uint32
file *os.File
size uint64
}
// OpenRotatingFile opens path for append, creating its parent directories with
// conservative permissions. A blank path disables file logging and returns a
// no-op closer. Both rotation controls must be zero (unbounded) or positive.
func OpenRotatingFile(path string, maxBytes uint64, maxFiles uint32) (io.WriteCloser, error) {
if path == "" {
return nopWriteCloser{Writer: io.Discard}, nil
}
if (maxBytes == 0) != (maxFiles == 0) {
return nil, errors.New("log rotation byte/file limits must both be zero or positive")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
result := &RotatingFile{path: path, maxBytes: maxBytes, maxFiles: maxFiles}
if err := result.open(); err != nil {
return nil, err
}
return result, nil
}
func (file *RotatingFile) open() error {
opened, err := os.OpenFile(file.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open log file: %w", err)
}
info, err := opened.Stat()
if err != nil {
_ = opened.Close()
return fmt.Errorf("stat log file: %w", err)
}
file.file = opened
file.size = uint64(info.Size())
return nil
}
func (file *RotatingFile) Write(data []byte) (int, error) {
file.mu.Lock()
defer file.mu.Unlock()
if file.file == nil {
return 0, os.ErrClosed
}
if file.maxBytes != 0 && file.size != 0 && (file.size >= file.maxBytes || uint64(len(data)) > file.maxBytes-file.size) {
if err := file.rotate(); err != nil {
return 0, err
}
}
written, err := file.file.Write(data)
file.size += uint64(written)
return written, err
}
func (file *RotatingFile) Close() error {
file.mu.Lock()
defer file.mu.Unlock()
if file.file == nil {
return nil
}
err := file.file.Close()
file.file = nil
return err
}
func (file *RotatingFile) rotate() error {
if err := file.file.Close(); err != nil {
return fmt.Errorf("close log before rotation: %w", err)
}
file.file = nil
oldest := file.archivePath(file.maxFiles)
if err := os.Remove(oldest); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove oldest log archive: %w", err)
}
for index := file.maxFiles; index > 1; index-- {
from := file.archivePath(index - 1)
to := file.archivePath(index)
if err := os.Rename(from, to); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("rotate log archive: %w", err)
}
}
if err := os.Rename(file.path, file.archivePath(1)); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("seal current log: %w", err)
}
return file.open()
}
func (file *RotatingFile) archivePath(index uint32) string {
return file.path + "." + strconv.FormatUint(uint64(index), 10)
}
type nopWriteCloser struct{ io.Writer }
func (nopWriteCloser) Close() error { return nil }
// FormatLog makes file records either newline-delimited JSON or plain text.
// It is intentionally applied only to daemon diagnostics, never command
// output, stdin, environment values, or other payload-bearing data.
func FormatLog(destination io.Writer, format string) io.Writer {
if destination == nil || format != "json" {
return destination
}
return &jsonLogWriter{destination: destination}
}
type jsonLogWriter struct {
mu sync.Mutex
destination io.Writer
}
func (writer *jsonLogWriter) Write(data []byte) (int, error) {
writer.mu.Lock()
defer writer.mu.Unlock()
for _, line := range bytes.Split(data, []byte{'\n'}) {
if len(line) == 0 {
continue
}
record, err := json.Marshal(struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
}{Time: time.Now().UTC().Format(time.RFC3339Nano), Level: "info", Message: string(line)})
if err != nil {
return 0, fmt.Errorf("encode structured log: %w", err)
}
if _, err := writer.destination.Write(append(record, '\n')); err != nil {
return 0, err
}
}
return len(data), nil
}