Files
RemLink/internal/logging/logger.go
T
qsc20001102 142e5dc7d6
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled
初版功能完成
2026-08-29 13:12:17 +08:00

229 lines
5.6 KiB
Go

package logging
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"gopkg.in/natefinch/lumberjack.v2"
)
const defaultPacketSampleInterval = 5 * time.Second
var ErrInvalidModule = errors.New("invalid logging module")
// RotationConfig controls rolling-file retention.
type RotationConfig struct {
MaxSizeMB int
MaxAgeDays int
MaxBackups int
LocalTime bool
Compress bool
}
// PacketConfig controls opt-in, rate-limited packet-event metadata logging.
// Raw packet payloads are deliberately not accepted by the sampling API.
type PacketConfig struct {
Enabled bool
SampleInterval time.Duration
}
// Config controls the slog handler and rolling file.
type Config struct {
File string
Level slog.Level
JSON bool
Console io.Writer
Rotation RotationConfig
Packets PacketConfig
}
// DefaultConfig returns production-safe defaults with packet events disabled.
func DefaultConfig(file string) Config {
return Config{
File: file,
Level: slog.LevelInfo,
JSON: true,
Console: os.Stderr,
Rotation: RotationConfig{
MaxSizeMB: 50,
MaxAgeDays: 14,
MaxBackups: 5,
LocalTime: true,
Compress: true,
},
Packets: PacketConfig{
Enabled: false,
SampleInterval: defaultPacketSampleInterval,
},
}
}
// Logger owns the structured logger and its rolling file writer.
type Logger struct {
base *slog.Logger
file *lumberjack.Logger
packets packetSampler
security packetSampler
}
// New constructs a structured logger writing to a rolling file and optionally a console.
func New(config Config) (*Logger, error) {
if config.File == "" {
return nil, fmt.Errorf("log file path must not be empty")
}
applyRotationDefaults(&config.Rotation)
if config.Packets.SampleInterval <= 0 {
config.Packets.SampleInterval = defaultPacketSampleInterval
}
if err := os.MkdirAll(filepath.Dir(config.File), 0o750); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
rollingFile := &lumberjack.Logger{
Filename: config.File,
MaxSize: config.Rotation.MaxSizeMB,
MaxAge: config.Rotation.MaxAgeDays,
MaxBackups: config.Rotation.MaxBackups,
LocalTime: config.Rotation.LocalTime,
Compress: config.Rotation.Compress,
}
var writer io.Writer = rollingFile
if config.Console != nil {
writer = io.MultiWriter(config.Console, rollingFile)
}
handlerOptions := &slog.HandlerOptions{Level: config.Level}
var handler slog.Handler
if config.JSON {
handler = slog.NewJSONHandler(writer, handlerOptions)
} else {
handler = slog.NewTextHandler(writer, handlerOptions)
}
logger := &Logger{
base: slog.New(handler),
file: rollingFile,
}
logger.packets = packetSampler{
enabled: config.Packets.Enabled,
interval: config.Packets.SampleInterval,
last: make(map[packetSampleKey]time.Time),
}
logger.security = packetSampler{
enabled: true, interval: defaultPacketSampleInterval,
last: make(map[packetSampleKey]time.Time),
}
return logger, nil
}
// For returns a logger permanently tagged with a validated module.
func (l *Logger) For(module Module) (*slog.Logger, error) {
if !module.Valid() {
return nil, fmt.Errorf("%w: %q", ErrInvalidModule, module)
}
return l.base.With(slog.String("module", string(module))), nil
}
// SamplePacketDebug emits at most one DEBUG metadata record per module/key interval.
// It returns true only when the sample passed the limiter. Callers must never add raw
// packet bytes to attrs.
func (l *Logger) SamplePacketDebug(
ctx context.Context,
module Module,
key string,
message string,
attrs ...slog.Attr,
) (bool, error) {
if !module.Valid() {
return false, fmt.Errorf("%w: %q", ErrInvalidModule, module)
}
if !l.packets.allow(module, key, time.Now()) {
return false, nil
}
logger := l.base.With(
slog.String("module", string(module)),
slog.String("packet_sample", key),
)
logger.LogAttrs(ctx, slog.LevelDebug, message, attrs...)
return true, nil
}
// SampleSecurityWarning emits rate-limited metadata for rejected or dropped
// packets. It is always active because these are security/diagnostic events,
// while routine per-packet DEBUG sampling remains opt-in. Raw payload bytes are
// deliberately not accepted by this API.
func (l *Logger) SampleSecurityWarning(
ctx context.Context,
module Module,
key string,
message string,
attrs ...slog.Attr,
) (bool, error) {
if !module.Valid() {
return false, fmt.Errorf("%w: %q", ErrInvalidModule, module)
}
if !l.security.allow(module, key, time.Now()) {
return false, nil
}
logger := l.base.With(
slog.String("module", string(module)),
slog.String("security_sample", key),
)
logger.LogAttrs(ctx, slog.LevelWarn, message, attrs...)
return true, nil
}
// Close flushes and closes the rolling file. It is safe to call more than once.
func (l *Logger) Close() error {
if l == nil || l.file == nil {
return nil
}
return l.file.Close()
}
func applyRotationDefaults(config *RotationConfig) {
if config.MaxSizeMB <= 0 {
config.MaxSizeMB = 50
}
if config.MaxAgeDays <= 0 {
config.MaxAgeDays = 14
}
if config.MaxBackups <= 0 {
config.MaxBackups = 5
}
}
type packetSampleKey struct {
module Module
key string
}
type packetSampler struct {
mu sync.Mutex
enabled bool
interval time.Duration
last map[packetSampleKey]time.Time
}
func (s *packetSampler) allow(module Module, key string, now time.Time) bool {
if !s.enabled {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
sample := packetSampleKey{module: module, key: key}
if previous, ok := s.last[sample]; ok && now.Sub(previous) < s.interval {
return false
}
s.last[sample] = now
return true
}