- config: add optional configPaths parameter to load() for test isolation (tests now use t.TempDir() to avoid interference from D:\app\data) - handler: fix stubAccountRepoForHandler.ListByPlatform to return accounts (was returning nil, nil causing "no available accounts" errors) - logger: fix Sync() blocking on Windows pipes by closing write end first (also add Reset() function to close lumberjack file handles for cleanup)
554 lines
12 KiB
Go
554 lines
12 KiB
Go
package logger
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"go.uber.org/zap/zapcore"
|
||
"gopkg.in/natefinch/lumberjack.v2"
|
||
)
|
||
|
||
type Level = zapcore.Level
|
||
|
||
const (
|
||
LevelDebug = zapcore.DebugLevel
|
||
LevelInfo = zapcore.InfoLevel
|
||
LevelWarn = zapcore.WarnLevel
|
||
LevelError = zapcore.ErrorLevel
|
||
LevelFatal = zapcore.FatalLevel
|
||
)
|
||
|
||
type Sink interface {
|
||
WriteLogEvent(event *LogEvent)
|
||
}
|
||
|
||
type LogEvent struct {
|
||
Time time.Time
|
||
Level string
|
||
Component string
|
||
Message string
|
||
LoggerName string
|
||
Fields map[string]any
|
||
}
|
||
|
||
var (
|
||
mu sync.RWMutex
|
||
global atomic.Pointer[zap.Logger]
|
||
sugar atomic.Pointer[zap.SugaredLogger]
|
||
atomicLevel zap.AtomicLevel
|
||
initOptions InitOptions
|
||
currentSink atomic.Value // sinkState
|
||
stdLogUndo func()
|
||
bootstrapOnce sync.Once
|
||
fileCloser func() error // tracks the lumberjack closer for Reset()
|
||
)
|
||
|
||
type sinkState struct {
|
||
sink Sink
|
||
}
|
||
|
||
func InitBootstrap() {
|
||
bootstrapOnce.Do(func() {
|
||
if err := Init(bootstrapOptions()); err != nil {
|
||
_, _ = fmt.Fprintf(os.Stderr, "logger bootstrap init failed: %v\n", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
func Init(options InitOptions) error {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
return initLocked(options)
|
||
}
|
||
|
||
func initLocked(options InitOptions) error {
|
||
normalized := options.normalized()
|
||
zl, al, err := buildLogger(normalized)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
prev := global.Load()
|
||
global.Store(zl)
|
||
sugar.Store(zl.Sugar())
|
||
atomicLevel = al
|
||
initOptions = normalized
|
||
|
||
bridgeSlogLocked()
|
||
bridgeStdLogLocked()
|
||
|
||
if prev != nil {
|
||
_ = prev.Sync()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func Reconfigure(mutator func(*InitOptions) error) error {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
next := initOptions
|
||
if mutator != nil {
|
||
if err := mutator(&next); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return initLocked(next)
|
||
}
|
||
|
||
func SetLevel(level string) error {
|
||
lv, ok := parseLevel(level)
|
||
if !ok {
|
||
return fmt.Errorf("invalid log level: %s", level)
|
||
}
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
atomicLevel.SetLevel(lv)
|
||
initOptions.Level = strings.ToLower(strings.TrimSpace(level))
|
||
return nil
|
||
}
|
||
|
||
func CurrentLevel() string {
|
||
mu.RLock()
|
||
defer mu.RUnlock()
|
||
if global.Load() == nil {
|
||
return "info"
|
||
}
|
||
return atomicLevel.Level().String()
|
||
}
|
||
|
||
func SetSink(sink Sink) {
|
||
currentSink.Store(sinkState{sink: sink})
|
||
}
|
||
|
||
func loadSink() Sink {
|
||
v := currentSink.Load()
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
state, ok := v.(sinkState)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return state.sink
|
||
}
|
||
|
||
// WriteSinkEvent 直接写入日志 sink,不经过全局日志级别门控。
|
||
// 用于需要“可观测性入库”与“业务输出级别”解耦的场景(例如 ops 系统日志索引)。
|
||
func WriteSinkEvent(level, component, message string, fields map[string]any) {
|
||
sink := loadSink()
|
||
if sink == nil {
|
||
return
|
||
}
|
||
|
||
level = strings.ToLower(strings.TrimSpace(level))
|
||
if level == "" {
|
||
level = "info"
|
||
}
|
||
component = strings.TrimSpace(component)
|
||
message = strings.TrimSpace(message)
|
||
if message == "" {
|
||
return
|
||
}
|
||
|
||
eventFields := make(map[string]any, len(fields)+1)
|
||
for k, v := range fields {
|
||
eventFields[k] = v
|
||
}
|
||
if component != "" {
|
||
if _, ok := eventFields["component"]; !ok {
|
||
eventFields["component"] = component
|
||
}
|
||
}
|
||
|
||
sink.WriteLogEvent(&LogEvent{
|
||
Time: time.Now(),
|
||
Level: level,
|
||
Component: component,
|
||
Message: message,
|
||
LoggerName: component,
|
||
Fields: eventFields,
|
||
})
|
||
}
|
||
|
||
func L() *zap.Logger {
|
||
if l := global.Load(); l != nil {
|
||
return l
|
||
}
|
||
return zap.NewNop()
|
||
}
|
||
|
||
func S() *zap.SugaredLogger {
|
||
if s := sugar.Load(); s != nil {
|
||
return s
|
||
}
|
||
return zap.NewNop().Sugar()
|
||
}
|
||
|
||
func With(fields ...zap.Field) *zap.Logger {
|
||
return L().With(fields...)
|
||
}
|
||
|
||
func Sync() {
|
||
l := global.Load()
|
||
if l != nil {
|
||
_ = l.Sync()
|
||
}
|
||
}
|
||
|
||
// Reset clears the global logger state. Used for test cleanup.
|
||
func Reset() {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if fileCloser != nil {
|
||
_ = fileCloser()
|
||
fileCloser = nil
|
||
}
|
||
global.Store(zap.NewNop())
|
||
sugar.Store(zap.NewNop().Sugar())
|
||
atomicLevel = zap.NewAtomicLevel()
|
||
initOptions = InitOptions{}
|
||
if stdLogUndo != nil {
|
||
stdLogUndo()
|
||
stdLogUndo = nil
|
||
}
|
||
log.SetFlags(0)
|
||
log.SetPrefix("")
|
||
log.SetOutput(os.Stderr)
|
||
}
|
||
|
||
func bridgeStdLogLocked() {
|
||
if stdLogUndo != nil {
|
||
stdLogUndo()
|
||
stdLogUndo = nil
|
||
}
|
||
|
||
prevFlags := log.Flags()
|
||
prevPrefix := log.Prefix()
|
||
prevWriter := log.Writer()
|
||
|
||
log.SetFlags(0)
|
||
log.SetPrefix("")
|
||
base := global.Load()
|
||
if base == nil {
|
||
base = zap.NewNop()
|
||
}
|
||
log.SetOutput(newStdLogBridge(base.Named("stdlog")))
|
||
|
||
stdLogUndo = func() {
|
||
log.SetOutput(prevWriter)
|
||
log.SetFlags(prevFlags)
|
||
log.SetPrefix(prevPrefix)
|
||
}
|
||
}
|
||
|
||
func bridgeSlogLocked() {
|
||
base := global.Load()
|
||
if base == nil {
|
||
base = zap.NewNop()
|
||
}
|
||
slog.SetDefault(slog.New(newSlogZapHandler(base.Named("slog"))))
|
||
}
|
||
|
||
func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, error) {
|
||
level, _ := parseLevel(options.Level)
|
||
atomic := zap.NewAtomicLevelAt(level)
|
||
|
||
encoderCfg := zapcore.EncoderConfig{
|
||
TimeKey: "time",
|
||
LevelKey: "level",
|
||
NameKey: "logger",
|
||
CallerKey: "caller",
|
||
MessageKey: "msg",
|
||
StacktraceKey: "stacktrace",
|
||
LineEnding: zapcore.DefaultLineEnding,
|
||
EncodeLevel: zapcore.CapitalLevelEncoder,
|
||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||
EncodeDuration: zapcore.MillisDurationEncoder,
|
||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||
}
|
||
|
||
var enc zapcore.Encoder
|
||
if options.Format == "console" {
|
||
enc = zapcore.NewConsoleEncoder(encoderCfg)
|
||
} else {
|
||
enc = zapcore.NewJSONEncoder(encoderCfg)
|
||
}
|
||
|
||
sinkCore := newSinkCore()
|
||
cores := make([]zapcore.Core, 0, 3)
|
||
|
||
if options.Output.ToStdout {
|
||
infoPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||
return lvl >= atomic.Level() && lvl < zapcore.WarnLevel
|
||
})
|
||
errPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||
return lvl >= atomic.Level() && lvl >= zapcore.WarnLevel
|
||
})
|
||
cores = append(cores, zapcore.NewCore(enc, zapcore.Lock(os.Stdout), infoPriority))
|
||
cores = append(cores, zapcore.NewCore(enc, zapcore.Lock(os.Stderr), errPriority))
|
||
}
|
||
|
||
if options.Output.ToFile {
|
||
fileCore, filePath, fileErr := buildFileCore(enc, atomic, options)
|
||
if fileErr != nil {
|
||
_, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"日志文件输出初始化失败,降级为仅标准输出\" path=%s err=%v\n",
|
||
time.Now().Format(time.RFC3339Nano),
|
||
filePath,
|
||
fileErr,
|
||
)
|
||
} else {
|
||
cores = append(cores, fileCore)
|
||
}
|
||
}
|
||
|
||
if len(cores) == 0 {
|
||
cores = append(cores, zapcore.NewCore(enc, zapcore.Lock(os.Stdout), atomic))
|
||
}
|
||
|
||
core := zapcore.NewTee(cores...)
|
||
if options.Sampling.Enabled {
|
||
core = zapcore.NewSamplerWithOptions(core, samplingTick(), options.Sampling.Initial, options.Sampling.Thereafter)
|
||
}
|
||
core = sinkCore.Wrap(core)
|
||
|
||
stacktraceLevel, _ := parseStacktraceLevel(options.StacktraceLevel)
|
||
zapOpts := make([]zap.Option, 0, 5)
|
||
if options.Caller {
|
||
zapOpts = append(zapOpts, zap.AddCaller())
|
||
}
|
||
if stacktraceLevel <= zapcore.FatalLevel {
|
||
zapOpts = append(zapOpts, zap.AddStacktrace(stacktraceLevel))
|
||
}
|
||
|
||
logger := zap.New(core, zapOpts...).With(
|
||
zap.String("service", options.ServiceName),
|
||
zap.String("env", options.Environment),
|
||
)
|
||
return logger, atomic, nil
|
||
}
|
||
|
||
func buildFileCore(enc zapcore.Encoder, atomic zap.AtomicLevel, options InitOptions) (zapcore.Core, string, error) {
|
||
filePath := options.Output.FilePath
|
||
if strings.TrimSpace(filePath) == "" {
|
||
filePath = resolveLogFilePath("")
|
||
}
|
||
|
||
dir := filepath.Dir(filePath)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, filePath, err
|
||
}
|
||
lj := &lumberjack.Logger{
|
||
Filename: filePath,
|
||
MaxSize: options.Rotation.MaxSizeMB,
|
||
MaxBackups: options.Rotation.MaxBackups,
|
||
MaxAge: options.Rotation.MaxAgeDays,
|
||
Compress: options.Rotation.Compress,
|
||
LocalTime: options.Rotation.LocalTime,
|
||
}
|
||
fileCloser = lj.Close
|
||
return zapcore.NewCore(enc, zapcore.AddSync(lj), atomic), filePath, nil
|
||
}
|
||
|
||
type sinkCore struct {
|
||
core zapcore.Core
|
||
fields []zapcore.Field
|
||
}
|
||
|
||
func newSinkCore() *sinkCore {
|
||
return &sinkCore{}
|
||
}
|
||
|
||
func (s *sinkCore) Wrap(core zapcore.Core) zapcore.Core {
|
||
cp := *s
|
||
cp.core = core
|
||
return &cp
|
||
}
|
||
|
||
func (s *sinkCore) Enabled(level zapcore.Level) bool {
|
||
return s.core.Enabled(level)
|
||
}
|
||
|
||
func (s *sinkCore) With(fields []zapcore.Field) zapcore.Core {
|
||
nextFields := append([]zapcore.Field{}, s.fields...)
|
||
nextFields = append(nextFields, fields...)
|
||
return &sinkCore{
|
||
core: s.core.With(fields),
|
||
fields: nextFields,
|
||
}
|
||
}
|
||
|
||
func (s *sinkCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||
// Delegate to inner core (tee) so each sub-core's level enabler is respected.
|
||
// Then add ourselves for sink forwarding only.
|
||
ce = s.core.Check(entry, ce)
|
||
if ce != nil {
|
||
ce = ce.AddCore(entry, s)
|
||
}
|
||
return ce
|
||
}
|
||
|
||
func (s *sinkCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
||
// Only handle sink forwarding — the inner cores write via their own
|
||
// Write methods (added to CheckedEntry by s.core.Check above).
|
||
sink := loadSink()
|
||
if sink == nil {
|
||
return nil
|
||
}
|
||
|
||
enc := zapcore.NewMapObjectEncoder()
|
||
for _, f := range s.fields {
|
||
f.AddTo(enc)
|
||
}
|
||
for _, f := range fields {
|
||
f.AddTo(enc)
|
||
}
|
||
|
||
event := &LogEvent{
|
||
Time: entry.Time,
|
||
Level: strings.ToLower(entry.Level.String()),
|
||
Component: entry.LoggerName,
|
||
Message: entry.Message,
|
||
LoggerName: entry.LoggerName,
|
||
Fields: enc.Fields,
|
||
}
|
||
sink.WriteLogEvent(event)
|
||
return nil
|
||
}
|
||
|
||
func (s *sinkCore) Sync() error {
|
||
return s.core.Sync()
|
||
}
|
||
|
||
type stdLogBridge struct {
|
||
logger *zap.Logger
|
||
}
|
||
|
||
func newStdLogBridge(l *zap.Logger) io.Writer {
|
||
if l == nil {
|
||
l = zap.NewNop()
|
||
}
|
||
return &stdLogBridge{logger: l}
|
||
}
|
||
|
||
func (b *stdLogBridge) Write(p []byte) (int, error) {
|
||
msg := normalizeStdLogMessage(string(p))
|
||
if msg == "" {
|
||
return len(p), nil
|
||
}
|
||
|
||
level := inferStdLogLevel(msg)
|
||
entry := b.logger.WithOptions(zap.AddCallerSkip(4))
|
||
|
||
switch level {
|
||
case LevelDebug:
|
||
entry.Debug(msg, zap.Bool("legacy_stdlog", true))
|
||
case LevelWarn:
|
||
entry.Warn(msg, zap.Bool("legacy_stdlog", true))
|
||
case LevelError, LevelFatal:
|
||
entry.Error(msg, zap.Bool("legacy_stdlog", true))
|
||
default:
|
||
entry.Info(msg, zap.Bool("legacy_stdlog", true))
|
||
}
|
||
return len(p), nil
|
||
}
|
||
|
||
func normalizeStdLogMessage(raw string) string {
|
||
msg := strings.TrimSpace(strings.ReplaceAll(raw, "\n", " "))
|
||
if msg == "" {
|
||
return ""
|
||
}
|
||
return strings.Join(strings.Fields(msg), " ")
|
||
}
|
||
|
||
func inferStdLogLevel(msg string) Level {
|
||
lower := strings.ToLower(strings.TrimSpace(msg))
|
||
if lower == "" {
|
||
return LevelInfo
|
||
}
|
||
|
||
if strings.HasPrefix(lower, "[debug]") || strings.HasPrefix(lower, "debug:") {
|
||
return LevelDebug
|
||
}
|
||
if strings.HasPrefix(lower, "[warn]") || strings.HasPrefix(lower, "[warning]") || strings.HasPrefix(lower, "warn:") || strings.HasPrefix(lower, "warning:") {
|
||
return LevelWarn
|
||
}
|
||
if strings.HasPrefix(lower, "[error]") || strings.HasPrefix(lower, "error:") || strings.HasPrefix(lower, "fatal:") || strings.HasPrefix(lower, "panic:") {
|
||
return LevelError
|
||
}
|
||
|
||
if strings.Contains(lower, " failed") || strings.Contains(lower, "error") || strings.Contains(lower, "panic") || strings.Contains(lower, "fatal") {
|
||
return LevelError
|
||
}
|
||
if strings.Contains(lower, "warning") || strings.Contains(lower, "warn") || strings.Contains(lower, " queue full") || strings.Contains(lower, "fallback") {
|
||
return LevelWarn
|
||
}
|
||
return LevelInfo
|
||
}
|
||
|
||
// LegacyPrintf 用于平滑迁移历史的 printf 风格日志到结构化 logger。
|
||
func LegacyPrintf(component, format string, args ...any) {
|
||
msg := normalizeStdLogMessage(fmt.Sprintf(format, args...))
|
||
if msg == "" {
|
||
return
|
||
}
|
||
|
||
initialized := global.Load() != nil
|
||
if !initialized {
|
||
// 在日志系统未初始化前,回退到标准库 log,避免测试/工具链丢日志。
|
||
log.Print(msg)
|
||
return
|
||
}
|
||
|
||
l := L()
|
||
if component != "" {
|
||
l = l.With(zap.String("component", component))
|
||
}
|
||
l = l.WithOptions(zap.AddCallerSkip(1))
|
||
|
||
switch inferStdLogLevel(msg) {
|
||
case LevelDebug:
|
||
l.Debug(msg, zap.Bool("legacy_printf", true))
|
||
case LevelWarn:
|
||
l.Warn(msg, zap.Bool("legacy_printf", true))
|
||
case LevelError, LevelFatal:
|
||
l.Error(msg, zap.Bool("legacy_printf", true))
|
||
default:
|
||
l.Info(msg, zap.Bool("legacy_printf", true))
|
||
}
|
||
}
|
||
|
||
type contextKey string
|
||
|
||
const loggerContextKey contextKey = "ctx_logger"
|
||
|
||
func IntoContext(ctx context.Context, l *zap.Logger) context.Context {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
if l == nil {
|
||
l = L()
|
||
}
|
||
return context.WithValue(ctx, loggerContextKey, l)
|
||
}
|
||
|
||
func FromContext(ctx context.Context) *zap.Logger {
|
||
if ctx == nil {
|
||
return L()
|
||
}
|
||
if l, ok := ctx.Value(loggerContextKey).(*zap.Logger); ok && l != nil {
|
||
return l
|
||
}
|
||
return L()
|
||
}
|