544 lines
16 KiB
Go
544 lines
16 KiB
Go
package logging
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"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 internal/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 consoleSummaryCore struct {
|
|
level zapcore.LevelEnabler
|
|
output zapcore.WriteSyncer
|
|
prefix string
|
|
fileOnly map[string]struct{}
|
|
fields []zapcore.Field
|
|
}
|
|
|
|
func (c *consoleSummaryCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
|
|
|
|
func (c *consoleSummaryCore) With(fields []zapcore.Field) zapcore.Core {
|
|
inherited := append([]zapcore.Field(nil), c.fields...)
|
|
inherited = append(inherited, fields...)
|
|
return &consoleSummaryCore{level: c.level, output: c.output, prefix: c.prefix, fileOnly: c.fileOnly, fields: inherited}
|
|
}
|
|
|
|
func (c *consoleSummaryCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
|
if c.Enabled(entry.Level) {
|
|
return checked.AddCore(entry, c)
|
|
}
|
|
return checked
|
|
}
|
|
|
|
func (c *consoleSummaryCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
|
allFields := append([]zapcore.Field(nil), c.fields...)
|
|
allFields = append(allFields, fields...)
|
|
module := moduleField(allFields)
|
|
if _, excluded := c.fileOnly[module]; excluded {
|
|
return nil
|
|
}
|
|
_, err := c.output.Write([]byte(formatConsoleEntry(entry, module, allFields, c.prefix)))
|
|
return err
|
|
}
|
|
|
|
func (c *consoleSummaryCore) Sync() error { return c.output.Sync() }
|
|
|
|
var hiddenConsoleFields = map[string]struct{}{
|
|
"mod": {}, "service.id": {}, "service.name": {}, "service.version": {},
|
|
"node": {}, "app_id": {}, "env": {}, "trace_id": {}, "span_id": {},
|
|
}
|
|
|
|
func formatConsoleEntry(entry zapcore.Entry, module string, fields []zapcore.Field, prefix string) string {
|
|
values := zapcore.NewMapObjectEncoder()
|
|
for _, field := range fields {
|
|
field.AddTo(values)
|
|
}
|
|
keys := make([]string, 0, len(values.Fields))
|
|
for key, value := range values.Fields {
|
|
if _, hidden := hiddenConsoleFields[key]; hidden || consoleValueEmpty(value) {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
caller := ""
|
|
if entry.Caller.Defined {
|
|
callerPath := strings.ReplaceAll(entry.Caller.File, "\\", "/")
|
|
caller = filepath.Base(callerPath) + fmt.Sprintf(":%d", entry.Caller.Line)
|
|
}
|
|
if module == "" {
|
|
module = "app"
|
|
}
|
|
parts := []string{
|
|
prefix + entry.Time.Format("15:04:05.000"),
|
|
consoleLevel(entry.Level),
|
|
fmt.Sprintf("%-10s", module),
|
|
}
|
|
if caller != "" {
|
|
parts = append(parts, fmt.Sprintf("%-24s", caller))
|
|
}
|
|
parts = append(parts, entry.Message)
|
|
for _, key := range keys {
|
|
parts = append(parts, key+"="+formatConsoleValue(values.Fields[key]))
|
|
}
|
|
return strings.Join(parts, " ") + "\n"
|
|
}
|
|
|
|
func consoleLevel(level zapcore.Level) string {
|
|
label := strings.ToUpper(level.String())
|
|
color := "\x1b[36m"
|
|
switch {
|
|
case level >= zapcore.ErrorLevel:
|
|
color = "\x1b[31m"
|
|
case level == zapcore.WarnLevel:
|
|
color = "\x1b[33m"
|
|
case level == zapcore.InfoLevel:
|
|
color = "\x1b[32m"
|
|
}
|
|
return color + fmt.Sprintf("%-5s", label) + "\x1b[0m"
|
|
}
|
|
|
|
func consoleValueEmpty(value any) bool {
|
|
return value == nil || value == ""
|
|
}
|
|
|
|
func formatConsoleValue(value any) string {
|
|
text := fmt.Sprint(value)
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
if len(text) > 180 {
|
|
text = text[:177] + "..."
|
|
}
|
|
if strings.ContainsAny(text, " \t\r\n") {
|
|
return fmt.Sprintf("%q", text)
|
|
}
|
|
return text
|
|
}
|
|
|
|
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 "websocket":
|
|
paths = append(paths, filepath.Join("websocket", "websocket.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)
|
|
}
|
|
|
|
// newZapHandler adapts a Zap core to the slog handler 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 := &consoleSummaryCore{level: levelEnabler, output: zapcore.AddSync(os.Stdout), prefix: options.Prefix, fileOnly: fileOnly}
|
|
core = zapcore.NewTee(fileCore, consoleCore)
|
|
}
|
|
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
|
|
}
|