package logging import ( "context" "io" "log/slog" "os" "path/filepath" "sync" ) // 默认值:现场不配 config.yaml 的 log 段也能开箱即用,且不会把盘写满。 const ( // DirOperations 与 DirInterceptions 是两类日志的子目录名。 DirOperations = "operations" DirInterceptions = "interceptions" DefaultMaxSizeMB = 20 DefaultKeepFiles = 10 operationsPrefix = "operations" interceptionPrefix = "interceptions" ) // Options 是日志装配参数。 type Options struct { // Dir 是日志根目录,其下分 operations/ 与 interceptions/ 两个子目录。 Dir string // Level 是放行类(与控制台)的采集级别,来自 config.yaml 的 log.level。 Level slog.Level // MaxSizeMB 是单个文件的大小上限(MB),<=0 用默认值。 MaxSizeMB int // KeepFiles 是每个类别保留的份数(含当前文件),<=0 用默认值。 KeepFiles int // Console 是控制台输出目标,留空用 os.Stdout(保持原有控制台行为)。 Console io.Writer } // New 组装日志处理器:控制台保持现状(全量、按 Level 过滤), // 文件按「放行 / 拦截」两类分流——拦截类固定按 DEBUG 采集,不看 log.level, // 因为默认级别是 info,跟着过滤就看不到现场要的那份详细拦截日志了。 // 返回的 io.Closer 负责关文件,进程退出前调用。 func New(opts Options) (slog.Handler, io.Closer) { console := opts.Console if console == nil { console = os.Stdout } maxBytes := int64(opts.MaxSizeMB) * 1024 * 1024 if maxBytes <= 0 { maxBytes = int64(DefaultMaxSizeMB) * 1024 * 1024 } keep := opts.KeepFiles if keep <= 0 { keep = DefaultKeepFiles } dir := opts.Dir // 降级告警只发一次:控制台那条链路是现成的,且不会绕回文件处理器造成递归。 consoleLogger := slog.New(slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level})) var warnOnce sync.Once warn := func(err error) { warnOnce.Do(func() { consoleLogger.Warn("日志文件不可写,已降级为只写控制台", "dir", dir, "err", err) }) } ops := newRotator(filepath.Join(dir, DirOperations), operationsPrefix, maxBytes, keep, warn) inter := newRotator(filepath.Join(dir, DirInterceptions), interceptionPrefix, maxBytes, keep, warn) // 放行类跟着 log.level 过滤;拦截类固定 Debug 采集,不跟着过滤(现场要的就是那份详细日志)。 return &handler{ console: slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level}), ops: slog.NewJSONHandler(ops, &slog.HandlerOptions{Level: opts.Level}), inter: slog.NewJSONHandler(inter, &slog.HandlerOptions{Level: slog.LevelDebug}), }, closerFunc(func() error { return closerAll(ops, inter) }) } type handler struct { console slog.Handler ops slog.Handler inter slog.Handler } func (h *handler) Enabled(ctx context.Context, level slog.Level) bool { return h.console.Enabled(ctx, level) || h.ops.Enabled(ctx, level) || h.inter.Enabled(ctx, level) } func (h *handler) Handle(ctx context.Context, rec slog.Record) error { // 控制台照旧:所有类别都打,按配置级别过滤。 if h.console.Enabled(ctx, rec.Level) { if err := h.console.Handle(ctx, rec); err != nil { return err } } // 文件按类别分流:拦截类固定 Debug 采集,放行类按配置级别。 if isInterception(rec) { if h.inter.Enabled(ctx, rec.Level) { return h.inter.Handle(ctx, rec) } return nil } if h.ops.Enabled(ctx, rec.Level) { return h.ops.Handle(ctx, rec) } return nil } func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { return &handler{ console: h.console.WithAttrs(attrs), ops: h.ops.WithAttrs(attrs), inter: h.inter.WithAttrs(attrs), } } func (h *handler) WithGroup(name string) slog.Handler { return &handler{ console: h.console.WithGroup(name), ops: h.ops.WithGroup(name), inter: h.inter.WithGroup(name), } } // isInterception 判断一条日志是不是「我们主动拦截 / 服务端异常」: // 拦截类日志带类别标记;ERROR 级别的异常即使没标记也归拦截类,它们是排查用的。 func isInterception(rec slog.Record) bool { if rec.Level >= slog.LevelError { return true } marked := false rec.Attrs(func(a slog.Attr) bool { if a.Key == ChannelKey && a.Value.String() == ChannelInterception { marked = true return false } return true }) return marked } type closerFunc func() error func (f closerFunc) Close() error { return f() } func closerAll(cs ...io.Closer) error { var first error for _, c := range cs { if err := c.Close(); err != nil && first == nil { first = err } } return first }