package logging import ( "context" "fmt" "log/slog" "os" "path/filepath" "strings" "sync" "time" "github.com/go-kratos/kratos/contrib/otel/v3/tracing" kratoslog "github.com/go-kratos/kratos/v3/log" "go.uber.org/zap" "go.uber.org/zap/exp/zapslog" "go.uber.org/zap/zapcore" ) type Options struct { Level, Format, EncodeLevel, Prefix, StacktraceKey string LogInConsole, ShowLine bool RetentionDay int FileOnlyModules []string } // ErrorEntry is the storage-neutral representation of an Error-level log. // Keeping it in pkg/logging lets the log core report failures without taking a // dependency on the application service or persistence layers. type ErrorEntry struct { Form, Info, Level, RequestID, TraceID string } // ErrorSink receives Error-level log entries. The sink must not log failures // through the same logger, otherwise a storage failure could recurse forever. type ErrorSink interface { RecordLogError(context.Context, ErrorEntry) error } type ErrorSinkFunc func(context.Context, ErrorEntry) error func (f ErrorSinkFunc) RecordLogError(ctx context.Context, entry ErrorEntry) error { return f(ctx, entry) } type errorSinkState struct { mu sync.RWMutex sink ErrorSink } func (s *errorSinkState) record(entry ErrorEntry) { if s == nil { return } s.mu.RLock() sink := s.sink s.mu.RUnlock() if sink != nil { _ = sink.RecordLogError(context.Background(), entry) } } type handlerOperation struct { attrs []slog.Attr group string } type reloadableHandlerState struct { mu sync.RWMutex handler slog.Handler cleanup func() } type reloadableHandler struct { state *reloadableHandlerState ops []handlerOperation } func (h *reloadableHandler) resolved() slog.Handler { current := h.state.handler for _, operation := range h.ops { if operation.group != "" { current = current.WithGroup(operation.group) } else { current = current.WithAttrs(operation.attrs) } } return current } func (h *reloadableHandler) Enabled(ctx context.Context, level slog.Level) bool { h.state.mu.RLock() defer h.state.mu.RUnlock() return h.resolved().Enabled(ctx, level) } func (h *reloadableHandler) Handle(ctx context.Context, record slog.Record) error { h.state.mu.RLock() defer h.state.mu.RUnlock() return h.resolved().Handle(ctx, record) } func (h *reloadableHandler) WithAttrs(attrs []slog.Attr) slog.Handler { next := append([]handlerOperation(nil), h.ops...) next = append(next, handlerOperation{attrs: append([]slog.Attr(nil), attrs...)}) return &reloadableHandler{state: h.state, ops: next} } func (h *reloadableHandler) WithGroup(name string) slog.Handler { next := append([]handlerOperation(nil), h.ops...) next = append(next, handlerOperation{group: name}) return &reloadableHandler{state: h.state, ops: next} } type ReloadableLogger struct { state *reloadableHandlerState filename string errorSink *errorSinkState } func (l *ReloadableLogger) Reload(root string, options Options) { handler, cleanup := newZapHandler(root, l.filename, options, l.errorSink) l.state.mu.Lock() previous := l.state.cleanup l.state.handler = handler l.state.cleanup = cleanup l.state.mu.Unlock() if previous != nil { previous() } } // SetErrorSink changes the database/audit target while keeping the current // logger and its hot-reload state intact. func (l *ReloadableLogger) SetErrorSink(sink ErrorSink) { if l == nil || l.errorSink == nil { return } l.errorSink.mu.Lock() l.errorSink.sink = sink l.errorSink.mu.Unlock() } func (l *ReloadableLogger) Close() { l.state.mu.Lock() cleanup := l.state.cleanup l.state.cleanup = nil l.state.mu.Unlock() if cleanup != nil { cleanup() } } type moduleFilterCore struct { zapcore.Core fileOnly map[string]struct{} mod string } func (c *moduleFilterCore) With(fields []zapcore.Field) zapcore.Core { mod := c.mod if value := moduleField(fields); value != "" { mod = value } return &moduleFilterCore{Core: c.Core.With(fields), fileOnly: c.fileOnly, mod: mod} } func (c *moduleFilterCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { if c.Enabled(entry.Level) { return checked.AddCore(entry, c) } return checked } func (c *moduleFilterCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { mod := c.mod if value := moduleField(fields); value != "" { mod = value } if _, excluded := c.fileOnly[mod]; excluded { return nil } return c.Core.Write(entry, fields) } func moduleField(fields []zapcore.Field) string { for _, field := range fields { if field.Key == "mod" { if field.String != "" { return field.String } if field.Interface != nil { return fmt.Sprint(field.Interface) } } } return "" } type routedFileState struct { mu sync.Mutex writers map[string]*DailyWriter } type routedFileCore struct { base zapcore.Core encoder zapcore.Encoder level zapcore.LevelEnabler root string retentionDay int state *routedFileState fields []zapcore.Field errorSink *errorSinkState } func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) } func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core { inherited := append([]zapcore.Field(nil), c.fields...) inherited = append(inherited, fields...) return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited, errorSink: c.errorSink} } func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { if c.Enabled(entry.Level) { return checked.AddCore(entry, c) } return checked } func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { baseErr := c.base.Write(entry, fields) allFields := append([]zapcore.Field(nil), c.fields...) allFields = append(allFields, fields...) if entry.Level >= zapcore.ErrorLevel && !isGORMLoggerEntry(entry.Caller.File, allFields) { c.errorSink.record(errorEntryFromZap(entry, allFields)) } paths := routedLogPaths(moduleField(allFields), entry.Level) if len(paths) == 0 { return baseErr } buffer, err := c.encoder.Clone().EncodeEntry(entry, allFields) if err != nil { if baseErr != nil { return baseErr } return err } defer buffer.Free() for _, name := range paths { writer := c.writer(name) if _, writeErr := writer.Write(buffer.Bytes()); writeErr != nil && baseErr == nil { baseErr = writeErr } } return baseErr } func isGORMLoggerEntry(filename string, fields []zapcore.Field) bool { for _, field := range fields { if field.Key == "gorm_logger" { return true } } normalized := strings.ReplaceAll(filename, "\\", "/") return strings.HasSuffix(normalized, "/gorm_logger_writer.go") || strings.HasSuffix(normalized, "/pkg/database/gormkit/logger.go") } func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry { requestID, traceID, errorText := "", "", "" for _, field := range fields { switch field.Key { case "request_id": if requestID == "" { requestID = zapFieldString(field) } case "trace_id": if traceID == "" { traceID = zapFieldString(field) } case "error", "err": if errorText == "" { errorText = zapFieldString(field) } } } info := entry.Message if errorText != "" { info += " | 错误: " + errorText } if entry.Caller.File != "" { info += fmt.Sprintf(" \n 源文件:%s:%d", entry.Caller.File, entry.Caller.Line) } if entry.Stack != "" { info += " \n 调用栈:" + entry.Stack if frame, ok := finalApplicationCaller(entry.Stack); ok { functionName, source, startLine, endLine, err := functionSourceAt(frame.File, frame.Line) if err == nil { info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", frame.File, frame.Line, functionName, startLine, endLine, source) } else { info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s) | extract_err=%v", frame.File, frame.Line, functionName, err) } } } return ErrorEntry{Form: "后端", Info: info, Level: entry.Level.String(), RequestID: requestID, TraceID: traceID} } func zapFieldString(field zapcore.Field) string { if field.String != "" { return field.String } if field.Interface != nil { return fmt.Sprint(field.Interface) } return "" } func (c *routedFileCore) Sync() error { result := c.base.Sync() c.state.mu.Lock() defer c.state.mu.Unlock() for _, writer := range c.state.writers { if err := writer.Sync(); err != nil && result == nil { result = err } } return result } func (c *routedFileCore) writer(name string) *DailyWriter { c.state.mu.Lock() defer c.state.mu.Unlock() writer := c.state.writers[name] if writer == nil { writer = NewDailyWriter(c.root, name, c.retentionDay) c.state.writers[name] = writer } return writer } func (c *routedFileCore) Close() { c.state.mu.Lock() defer c.state.mu.Unlock() for name, writer := range c.state.writers { _ = writer.Close() delete(c.state.writers, name) } } func routedLogPaths(module string, level zapcore.Level) []string { paths := make([]string, 0, 2) if module = safeModuleName(module); module != "" { switch module { case "http": paths = append(paths, filepath.Join("http", "access.log")) case "timedTask": paths = append(paths, filepath.Join("timedTask", "task.log")) case "error": paths = append(paths, filepath.Join("error", "error.log")) default: paths = append(paths, filepath.Join(module, "application.log")) } } if level >= zapcore.ErrorLevel { errorPath := filepath.Join("error", "error.log") if len(paths) == 0 || paths[len(paths)-1] != errorPath { paths = append(paths, errorPath) } } return paths } func safeModuleName(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } return strings.Map(func(r rune) rune { if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' { return r } return -1 }, value) } // NewZapLogger adapts a Zap core to the slog logger used by Kratos v3. // The file layout remains compatible with the administration log viewer. func newZapHandler(root, filename string, options Options, errorSink *errorSinkState) (slog.Handler, func()) { file := NewDailyWriter(root, filename, options.RetentionDay) encoder := zap.NewProductionEncoderConfig() encoder.EncodeTime = zapcore.RFC3339NanoTimeEncoder if options.StacktraceKey != "" { encoder.StacktraceKey = options.StacktraceKey } switch options.EncodeLevel { case "CapitalLevelEncoder": encoder.EncodeLevel = zapcore.CapitalLevelEncoder case "CapitalColorLevelEncoder": encoder.EncodeLevel = zapcore.CapitalColorLevelEncoder case "LowercaseColorLevelEncoder": encoder.EncodeLevel = zapcore.LowercaseColorLevelEncoder default: encoder.EncodeLevel = zapcore.LowercaseLevelEncoder } var outputEncoder zapcore.Encoder = zapcore.NewJSONEncoder(encoder) if options.Format != "" && options.Format != "json" { if options.Prefix != "" { encoder.EncodeTime = func(value time.Time, output zapcore.PrimitiveArrayEncoder) { output.AppendString(options.Prefix + value.Format("2006-01-02 15:04:05.000")) } } outputEncoder = zapcore.NewConsoleEncoder(encoder) } level := zap.DebugLevel // zapcore.Level.Set reports an error but does not make the intended // configuration handling obvious here. Parse the configured value directly // so debug/warn/error are actually applied after startup and hot reload. if value := strings.TrimSpace(strings.ToLower(options.Level)); value != "" { if err := level.UnmarshalText([]byte(value)); err != nil { level = zap.DebugLevel } } levelEnabler := zap.NewAtomicLevelAt(level) fileCore := zapcore.NewCore( outputEncoder.Clone(), zapcore.AddSync(file), levelEnabler, ) core := zapcore.Core(fileCore) if options.LogInConsole { fileOnly := make(map[string]struct{}, len(options.FileOnlyModules)) for _, module := range options.FileOnlyModules { fileOnly[module] = struct{}{} } consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler) core = zapcore.NewTee(fileCore, &moduleFilterCore{Core: consoleCore, fileOnly: fileOnly}) } routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: errorSink} zapLogger := zap.New(routed) handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)} if options.ShowLine { handlerOptions = append(handlerOptions, zapslog.WithCaller(true)) } handler := zapslog.NewHandler(zapLogger.Core(), handlerOptions...) cleanup := func() { _ = zapLogger.Sync() routed.Close() _ = file.Close() } return handler, cleanup } // NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing // the underlying Zap core when the runtime configuration changes. func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) { errorSink := &errorSinkState{} baseHandler, cleanup := newZapHandler(root, filename, options, errorSink) state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup} control := &ReloadableLogger{state: state, filename: filename, errorSink: errorSink} handler := &contextHandler{handler: &reloadableHandler{state: state}} logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...) return logger, control } func NewZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, func()) { logger, control := NewReloadableZapLogger(root, filename, options, attrs...) return logger, control.Close }