Smail/backend/services/runtimelog/writer.go
smail dd6258c98b
Some checks are pending
Security Checks / security (push) Waiting to run
补齐三类日志的触发操作和结果并完善运行事件展示
2026-09-09 02:48:07 +08:00

202 lines
5.0 KiB
Go

// Package runtimelog 管理按天写入、七天压缩和三十天滚动清理的运行日志。
package runtimelog
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
log "github.com/sirupsen/logrus"
)
const prefix = "runtime-"
const maxLineBytes = 256 * 1024
var ErrInvalid = errors.New("运行日志文件或查询参数无效")
var active atomic.Pointer[Writer]
type Writer struct {
archiveMu sync.Mutex
mu sync.Mutex
root *os.Root
file *os.File
day string
now func() time.Time
cancel context.CancelFunc
done chan struct{}
closed bool
}
func Current() *Writer { return active.Load() }
func New(root string) (*Writer, error) {
directory, err := runtimepath.SecureDirectory(root, "log")
if err != nil {
return nil, err
}
handle, err := os.OpenRoot(directory)
if err != nil {
return nil, err
}
if err := migrateLegacyLogs(handle); err != nil {
return nil, errors.Join(err, handle.Close())
}
return &Writer{root: handle, now: time.Now}, nil
}
// Start 使用日志钩子,配置重载重设 stdout 时不会丢失文件输出。
func Start(root string) (*Writer, error) {
w, err := New(root)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
w.cancel, w.done = cancel, make(chan struct{})
active.Store(w)
log.AddHook(w)
go func() {
defer close(w.done)
ticker := time.NewTicker(logpolicy.MaintenanceInterval)
defer ticker.Stop()
for {
bounded, stop := context.WithTimeout(ctx, 2*time.Minute)
err := w.Maintain(bounded, w.now())
stop()
if err != nil && ctx.Err() == nil {
log.WithError(err).WithFields(log.Fields{"action": "压缩与清理运行日志", "result": "failure"}).Error("运行日志维护失败,源文件保留并在下一轮重试")
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return w, nil
}
func (w *Writer) Levels() []log.Level { return log.AllLevels }
func (w *Writer) Fire(entry *log.Entry) error {
// 结构化字段和警告以上级别本身具有排错价值,不能只看消息是否为空。
if entry.Level >= log.InfoLevel && len(entry.Data) == 0 {
if logpolicy.EmptyMessage(entry.Message) || (entry.Level == log.InfoLevel && entry.Caller != nil && legacyStartupNoise(entry.Caller.File, entry.Message)) {
return nil
}
}
// 复用已有格式器保留请求标识、错误字段和源位置;转义换行保证一条记录一行。
data, err := entry.Logger.Formatter.Format(entry)
if err != nil {
return err
}
return w.writeAt(entry.Time, strings.TrimRight(string(data), "\r\n"))
}
func (w *Writer) WriteAt(at time.Time, message string) error {
if at.IsZero() {
return ErrInvalid
}
if logpolicy.EmptyMessage(message) {
return nil
}
if _, keep := presentLine("[" + at.UTC().Format(time.RFC3339Nano) + "] " + message); !keep {
return nil
}
return w.writeAt(at, message)
}
func (w *Writer) writeAt(at time.Time, message string) error {
if at.IsZero() {
return ErrInvalid
}
if at.Before(logpolicy.Cutoff(w.now())) {
return nil
}
message = strings.NewReplacer("\r", "\\r", "\n", "\\n", "\x00", "").Replace(message)
if len(message) > maxLineBytes-128 {
message = string([]rune(message)[:min(len([]rune(message)), (maxLineBytes-128)/4)]) + "…"
}
day := at.UTC().Format("2006-01-02")
w.mu.Lock()
// 当天句柄已打开时直接追加,不等待历史归档的读取或压缩。
if !w.closed && (w.file == nil || w.day != day) {
w.mu.Unlock()
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
w.mu.Lock()
}
defer w.mu.Unlock()
if w.closed {
return nil
}
if w.file == nil || w.day != day {
if w.file != nil {
if err := errors.Join(w.file.Sync(), w.file.Close()); err != nil {
w.file = nil
return err
}
w.file = nil
}
name := prefix + day + ".log"
if err := w.regular(name, true); err != nil {
return err
}
file, err := w.root.OpenFile(name, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return err
}
w.file, w.day = file, day
}
_, err := fmt.Fprintf(w.file, "[%s] %s\n", at.UTC().Format(time.RFC3339Nano), message)
return err
}
func (w *Writer) regular(name string, allowMissing bool) error {
if !filepath.IsLocal(name) || filepath.Base(name) != name {
return ErrInvalid
}
info, err := w.root.Lstat(name)
if allowMissing && os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return ErrInvalid
}
return nil
}
func (w *Writer) Close() error {
if w == nil {
return nil
}
if w.cancel != nil {
w.cancel()
<-w.done
}
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
active.CompareAndSwap(w, nil)
var err error
if w.file != nil {
err = errors.Join(w.file.Sync(), w.file.Close())
}
return errors.Join(err, w.root.Close())
}