157 lines
4.4 KiB
Go
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
|
|
}
|