package log import ( "bufio" "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "time" "github.com/rs/zerolog" ) const maxCaptureLine = 10 << 20 // 10MB,支持 GetReqMap 等大 body 单行日志 var ( origStderr io.Writer origStderrOnce sync.Once ) func saveOrigStderr() { origStderrOnce.Do(func() { origStderr = os.Stderr }) } func seqWriteStderr(format string, args ...interface{}) { if origStderr != nil { fmt.Fprintf(origStderr, format, args...) } else { fmt.Fprintf(os.Stderr, format, args...) } } // ErrorRecord 错误历史记录条目 type ErrorRecord struct { Err error Msg string Time time.Time Caller string } // errorStore 错误历史环形缓冲(指针共享,供 WithFields 派生子 Logger 共用) type errorStore struct { mu sync.RWMutex errors []ErrorRecord errIdx int errCount int maxErrors int } // Logger 日志核心结构体,封装 zerolog type Logger struct { zl zerolog.Logger mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等 console *levelFilterWriter // 控制台出口,挂 Seq 后可降到 Warn+ logLevel int store *errorStore seqs []*SeqWriter // 本 Logger 挂载过的 SeqWriter,供 CloseSeq 冲刷 seqsMu sync.Mutex } // levelFilterWriter 按级别过滤的 Writer(实现 zerolog.LevelWriter) type levelFilterWriter struct { inner io.Writer minLevel atomic.Int32 // zerolog.Level 存为 int32 } func newLevelFilterWriter(inner io.Writer, min zerolog.Level) *levelFilterWriter { w := &levelFilterWriter{inner: inner} w.minLevel.Store(int32(min)) return w } func (w *levelFilterWriter) setMinLevel(min zerolog.Level) { if w == nil { return } w.minLevel.Store(int32(min)) } func (w *levelFilterWriter) Write(p []byte) (int, error) { // 无级别信息时按 Info 处理:低于门槛则丢弃 if zerolog.Level(w.minLevel.Load()) > zerolog.InfoLevel { return len(p), nil } return w.inner.Write(p) } func (w *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) { if l < zerolog.Level(w.minLevel.Load()) { return len(p), nil } if lw, ok := w.inner.(zerolog.LevelWriter); ok { return lw.WriteLevel(l, p) } return w.inner.Write(p) } const timeFormatMS = "2006-01-02 15:04:05.000" // NewLogger 创建日志实例 // logLevel: 0=仅 error,>=1=全部 // logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件 // maxErrors: 错误历史最大条数,0 则不记录 func NewLogger(logLevel int, logFile string, maxErrors int) *Logger { saveOrigStderr() zerolog.CallerMarshalFunc = callerMarshalFunc zerolog.TimeFieldFormat = timeFormatMS var level zerolog.Level if logLevel == 0 { level = zerolog.ErrorLevel } else { level = zerolog.DebugLevel } consoleWriter := zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: timeFormatMS, NoColor: false, FormatLevel: formatLevelColor, FormatCaller: func(i interface{}) string { if i == nil { return "" } return fmt.Sprintf("[%s]", i) }, } console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel) var writers []io.Writer writers = append(writers, console) if logFile != "" { fw := &TemplateFileWriter{pathTemplate: logFile} writers = append(writers, fw) } mw := &multiWriter{writers: writers} zl := zerolog.New(mw). Level(level). With(). Timestamp(). Caller(). Logger() l := &Logger{ zl: zl, mw: mw, console: console, logLevel: logLevel, store: &errorStore{maxErrors: maxErrors}, } if maxErrors > 0 { l.store.errors = make([]ErrorRecord, maxErrors) } return l } // NewLoggerNoCaller 创建不带自动 Caller 字段的日志实例(用于访问日志等 caller 无意义的场景) func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger { saveOrigStderr() zerolog.CallerMarshalFunc = callerMarshalFunc zerolog.TimeFieldFormat = timeFormatMS var level zerolog.Level if logLevel == 0 { level = zerolog.ErrorLevel } else { level = zerolog.DebugLevel } consoleWriter := zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: timeFormatMS, NoColor: false, FormatLevel: formatLevelColor, } console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel) var writers []io.Writer writers = append(writers, console) if logFile != "" { fw := &TemplateFileWriter{pathTemplate: logFile} writers = append(writers, fw) } mw := &multiWriter{writers: writers} zl := zerolog.New(mw). Level(level). With(). Timestamp(). Logger() l := &Logger{ zl: zl, mw: mw, console: console, logLevel: logLevel, store: &errorStore{maxErrors: maxErrors}, } if maxErrors > 0 { l.store.errors = make([]ErrorRecord, maxErrors) } return l } // WithFields 派生子 Logger,附加成对 key,value 字段;mw/错误历史与父共享,父不受污染。 func (l *Logger) WithFields(kvs ...string) *Logger { if l == nil { return nil } ctx := l.zl.With() for i := 0; i+1 < len(kvs); i += 2 { if kvs[i] == "" { continue } ctx = ctx.Str(kvs[i], kvs[i+1]) } return &Logger{ zl: ctx.Logger(), mw: l.mw, console: l.console, logLevel: l.logLevel, store: l.store, } } // NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard) func NewTestLogger() *Logger { zl := zerolog.New(io.Discard).Level(zerolog.Disabled) return &Logger{zl: zl, logLevel: 0} } // SetOutput 设置日志输出(用于测试等场景) func (l *Logger) SetOutput(w io.Writer) { if w == io.Discard { l.zl = zerolog.New(w).Level(zerolog.Disabled) l.mw = nil return } consoleWriter := zerolog.ConsoleWriter{ Out: w, TimeFormat: timeFormatMS, NoColor: true, FormatLevel: formatLevelPlain, FormatCaller: func(i interface{}) string { if i == nil { return "" } return fmt.Sprintf("[%s]", i) }, } mw := &multiWriter{writers: []io.Writer{consoleWriter}} l.mw = mw l.zl = zerolog.New(mw). With(). Timestamp(). Caller(). Logger() } // GetLevel 获取当前日志等级 func (l *Logger) GetLevel() int { return l.logLevel } // EmitCaptured 将捕获的 stdout/stderr 等输出直写 multiWriter,绕过主 Logger 的 level 过滤。 // 保证 logLevel=0 时 fmt.Println / log.Println 仍能进 Seq。 func (l *Logger) EmitCaptured(level zerolog.Level, source, msg string) { if l == nil || l.mw == nil || msg == "" { return } zl := zerolog.New(l.mw) zl.WithLevel(level).Timestamp().Str("source", source).Msg(msg) } // EmitRecoveredPanic 记录 recover 到的 panic:错误原因 + 调用栈,source=panic。 func (l *Logger) EmitRecoveredPanic(err interface{}) { if l == nil || l.mw == nil || err == nil { return } zl := zerolog.New(l.mw) zl.WithLevel(zerolog.ErrorLevel).Timestamp(). Str("source", "panic"). Str("stack", string(debugStack())). Msg(fmt.Sprint(err)) } func debugStack() []byte { buf := make([]byte, 64*1024) n := runtime.Stack(buf, false) return buf[:n] } // CaptureStream 从 reader 持续读取行并写入 Logger(用于 stdout/stderr 管道)。 // 单行超过 maxCaptureLine 时截断并标注,读取出错后重启读取,避免永久失效。 func CaptureStream(l *Logger, level zerolog.Level, source string, r io.Reader) { rd := bufio.NewReader(r) for { line, err := rd.ReadString('\n') if len(line) > 0 { msg := strings.TrimRight(line, "\r\n") if msg != "" { if len(msg) > maxCaptureLine { msg = msg[:maxCaptureLine] + "...(truncated)" } l.EmitCaptured(level, source, msg) } } if err != nil { if err == io.EOF { if len(line) == 0 { return } continue } l.EmitCaptured(zerolog.ErrorLevel, source+"-capture", err.Error()) return } } } // --- 链式调用 API --- // 以下方法兼容两种调用风格: // 无参数:返回 *zerolog.Event 用于链式调用,如 l.Error().Str("k","v").Msg("...") // 有参数:直接拼接并打印日志(logrus 兼容),返回 no-op Event func (l *Logger) Debug(args ...interface{}) *zerolog.Event { if len(args) > 0 { l.zl.Debug().Msg(fmt.Sprint(args...)) nop := zerolog.Nop() return nop.Debug() } return l.zl.Debug() } func (l *Logger) Info(args ...interface{}) *zerolog.Event { if len(args) > 0 { l.zl.Info().Msg(fmt.Sprint(args...)) nop := zerolog.Nop() return nop.Info() } return l.zl.Info() } func (l *Logger) Warn(args ...interface{}) *zerolog.Event { if len(args) > 0 { l.zl.Warn().Msg(fmt.Sprint(args...)) nop := zerolog.Nop() return nop.Warn() } return l.zl.Warn() } func (l *Logger) Error(args ...interface{}) *zerolog.Event { if len(args) > 0 { msg := fmt.Sprint(args...) if l.store != nil && l.store.maxErrors > 0 { l.recordError(msg, nil) } l.zl.Error().Msg(msg) nop := zerolog.Nop() return nop.Error() } if l.store != nil && l.store.maxErrors > 0 { l.recordError("", nil) } return l.zl.Error() } // RecordError 手动记录一条错误到历史(用于需要指定详情的场景) func (l *Logger) RecordError(err error, msg string, caller string) { if l == nil || l.store == nil || l.store.maxErrors <= 0 { return } s := l.store s.mu.Lock() defer s.mu.Unlock() s.errors[s.errIdx] = ErrorRecord{ Err: err, Msg: msg, Time: time.Now(), Caller: caller, } s.errIdx = (s.errIdx + 1) % s.maxErrors s.errCount++ } func (l *Logger) recordError(msg string, err error) { _, file, line, ok := runtime.Caller(2) caller := "" if ok { caller = formatCaller(file, line) } l.RecordError(err, msg, caller) } // GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的) func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord { if l == nil || l.store == nil || l.store.maxErrors <= 0 { return nil } s := l.store s.mu.RLock() defer s.mu.RUnlock() total := s.errCount if total > s.maxErrors { total = s.maxErrors } if total == 0 { return nil } want := total if len(n) > 0 && n[0] > 0 && n[0] < want { want = n[0] } result := make([]ErrorRecord, 0, want) for i := 0; i < want; i++ { idx := (s.errIdx - 1 - i + s.maxErrors) % s.maxErrors if s.errors[idx].Time.IsZero() { break } result = append(result, s.errors[idx]) } return result } // --- 格式化快捷方法 --- func (l *Logger) Debugf(format string, v ...interface{}) { l.zl.Debug().Msgf(format, v...) } func (l *Logger) Infof(format string, v ...interface{}) { l.zl.Info().Msgf(format, v...) } func (l *Logger) Warnf(format string, v ...interface{}) { l.zl.Warn().Msgf(format, v...) } func (l *Logger) Errorf(format string, v ...interface{}) { if l.store != nil && l.store.maxErrors > 0 { l.recordError(fmt.Sprintf(format, v...), nil) } l.zl.Error().Msgf(format, v...) } // --- 日志级别颜色 --- func formatLevelColor(i interface{}) string { level := strings.ToUpper(fmt.Sprintf("%s", i)) switch level { case "DEBUG": return fmt.Sprintf("\x1b[36m|%s|\x1b[0m", level) // cyan case "INFO": return fmt.Sprintf("\x1b[32m|%s|\x1b[0m", level) // green case "WARN": return fmt.Sprintf("\x1b[33m|%s|\x1b[0m", level) // yellow case "ERROR": return fmt.Sprintf("\x1b[31m|%s|\x1b[0m", level) // red case "FATAL": return fmt.Sprintf("\x1b[35m|%s|\x1b[0m", level) // magenta default: return fmt.Sprintf("|%s|", level) } } func formatLevelPlain(i interface{}) string { return fmt.Sprintf("|%s|", strings.ToUpper(fmt.Sprintf("%s", i))) } // --- TemplateFileWriter --- // TemplateFileWriter 按时间模板切换文件路径的 Writer type TemplateFileWriter struct { pathTemplate string mu sync.Mutex currentPath string file *os.File writer *bufio.Writer } func (w *TemplateFileWriter) Write(p []byte) (n int, err error) { w.mu.Lock() defer w.mu.Unlock() path := time.Now().Format(w.pathTemplate) if path != w.currentPath { if w.writer != nil { _ = w.writer.Flush() } if w.file != nil { _ = w.file.Close() } _ = os.MkdirAll(filepath.Dir(path), 0755) w.file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return 0, err } w.writer = bufio.NewWriterSize(w.file, 4096) w.currentPath = path } n, err = w.writer.Write(p) _ = w.writer.Flush() return } // --- 调用者智能过滤 --- func callerMarshalFunc(_ uintptr, file string, line int) string { return findCaller(file, line) } func findCaller(origFile string, origLine int) string { var lastInfraFile string var lastInfraLine int var primaryCaller string var appLayerCaller string for i := 1; i < 20; i++ { _, file, line, ok := runtime.Caller(i) if !ok { break } shortFile := shortenPath(file) if isInfrastructureFile(shortFile) { lastInfraFile = shortFile lastInfraLine = line continue } if primaryCaller == "" { primaryCaller = fmt.Sprintf("%s:%d", shortFile, line) } if appLayerCaller == "" && !strings.Contains(strings.ToLower(file), "hotime") { appLayerCaller = fmt.Sprintf("%s:%d", shortFile, line) } } result := "" if primaryCaller != "" { result = primaryCaller } else if lastInfraFile != "" { result = fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine) } else { result = fmt.Sprintf("%s:%d", shortenPath(origFile), origLine) } if appLayerCaller != "" && result != appLayerCaller { result += " <- " + appLayerCaller } return result } func formatCaller(file string, line int) string { return fmt.Sprintf("%s:%d", shortenPath(file), line) } func shortenPath(file string) string { n := 0 for i := len(file) - 1; i > 0; i-- { if file[i] == '/' || file[i] == '\\' { n++ if n >= 2 { return file[i+1:] } } } return file } // --- multiWriter: 支持运行时追加输出目标 --- // multiWriter 实现 zerolog.LevelWriter,允许在 Logger 创建后动态追加新的输出(如 SeqWriter) type multiWriter struct { mu sync.RWMutex writers []io.Writer } func (mw *multiWriter) Write(p []byte) (int, error) { mw.mu.RLock() defer mw.mu.RUnlock() for _, w := range mw.writers { _, _ = w.Write(p) } return len(p), nil } // WriteLevel 实现 zerolog.LevelWriter,使 zerolog 的级别过滤正确传递给各子 Writer func (mw *multiWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) { mw.mu.RLock() defer mw.mu.RUnlock() for _, w := range mw.writers { if lw, ok := w.(zerolog.LevelWriter); ok { _, _ = lw.WriteLevel(l, p) } else { _, _ = w.Write(p) } } return len(p), nil } func (mw *multiWriter) add(w io.Writer) { mw.mu.Lock() mw.writers = append(mw.writers, w) mw.mu.Unlock() } // --- SeqWriter: 异步推送到 Seq(CLEF 格式)--- // SeqWriter 将 zerolog JSON 日志通过 channel 队列异步发送到 Seq。 // Write() 仅做 channel <- bytes,O(1) 非阻塞,绝不阻塞 web 请求处理 goroutine。 // channel 容量 10000,满时丢弃新条目(dropped 计数),不会影响主服务。 type SeqWriter struct { seqUrl string apiKey string instance string queue chan []byte dropped int64 client *http.Client closed int32 done chan struct{} } func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter { w := &SeqWriter{ seqUrl: strings.TrimRight(seqUrl, "/"), apiKey: apiKey, instance: instance, queue: make(chan []byte, 10000), client: &http.Client{Timeout: 5 * time.Second}, done: make(chan struct{}), } go w.run() return w } // Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送 func (w *SeqWriter) Write(p []byte) (int, error) { if atomic.LoadInt32(&w.closed) != 0 { return len(p), nil } clef := w.toClef(p) select { case w.queue <- clef: default: n := atomic.AddInt64(&w.dropped, 1) if n == 1 || n%100 == 0 { seqWriteStderr("[seq] queue full, dropped=%d\n", n) } } return len(p), nil } // Close 停止接收新条目,并在约 2s 内冲刷残留批次(幂等) func (w *SeqWriter) Close() { if w == nil { return } if !atomic.CompareAndSwapInt32(&w.closed, 0, 1) { return } close(w.queue) select { case <-w.done: case <-time.After(2 * time.Second): seqWriteStderr("[seq] Close timeout, some events may be dropped\n") } } // toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式 // zerolog: time/level/message(msg) → CLEF: @t/@l/@m func (w *SeqWriter) toClef(p []byte) []byte { trimmed := bytes.TrimSpace(p) var m map[string]interface{} if err := json.Unmarshal(trimmed, &m); err != nil { // 非 JSON(如 fmt.Println 直接输出),包装为纯文本日志 return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@m":%q,"source":"stdout","instance":%q}`, time.Now().Format("2006-01-02T15:04:05.000Z07:00"), strings.TrimSpace(string(p)), w.instance)) } clef := make(map[string]interface{}, len(m)+3) for k, v := range m { clef[k] = v } // time → @t(优先毫秒格式,回退秒级) if t, ok := m["time"].(string); ok { delete(clef, "time") if pt, err := time.ParseInLocation(timeFormatMS, t, time.Local); err == nil { clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00") } else if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil { clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00") } else { clef["@t"] = t } } else { clef["@t"] = time.Now().Format("2006-01-02T15:04:05.000Z07:00") } // level → @l if lv, ok := m["level"].(string); ok { delete(clef, "level") switch lv { case "debug": clef["@l"] = "Debug" case "info": clef["@l"] = "Information" case "warn": clef["@l"] = "Warning" case "error": clef["@l"] = "Error" case "fatal": clef["@l"] = "Fatal" default: clef["@l"] = lv } } // message/msg → @m(不用 @mt,避免 Seq 把 {xxx} 当消息模板) if msg, ok := m["message"].(string); ok { delete(clef, "message") clef["@m"] = msg } else if msg, ok := m["msg"].(string); ok { delete(clef, "msg") clef["@m"] = msg } if w.instance != "" { clef["instance"] = w.instance } b, _ := json.Marshal(clef) return b } // run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq;queue 关闭后冲刷并退出 func (w *SeqWriter) run() { defer close(w.done) batch := make([][]byte, 0, 100) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case entry, ok := <-w.queue: if !ok { if len(batch) > 0 { w.flush(batch) } return } batch = append(batch, entry) if len(batch) >= 100 { w.flush(batch) batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { w.flush(batch) batch = batch[:0] } } } } func (w *SeqWriter) flush(batch [][]byte) { if err := w.postBatch(batch); err != nil { time.Sleep(1 * time.Second) if err2 := w.postBatch(batch); err2 != nil { seqWriteStderr("[seq] send failed after retry: %v (dropped=%d)\n", err2, atomic.LoadInt64(&w.dropped)) } } } func (w *SeqWriter) postBatch(batch [][]byte) error { var buf bytes.Buffer for _, entry := range batch { buf.Write(entry) buf.WriteByte('\n') } req, err := http.NewRequest("POST", w.seqUrl+"/api/events/raw?clef", &buf) if err != nil { return err } req.Header.Set("Content-Type", "application/vnd.serilog.clef") if w.apiKey != "" { req.Header.Set("X-Seq-ApiKey", w.apiKey) } resp, err := w.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) } return nil } // SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出) // seqUrl: Seq 服务地址,如 "http://127.0.0.1:5341" // apiKey: Seq API Key,免费单用户版留空 // instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085" func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) { if l == nil || seqUrl == "" || l.mw == nil { return } sw := newSeqWriter(seqUrl, apiKey, instance) l.mw.add(sw) l.seqsMu.Lock() l.seqs = append(l.seqs, sw) l.seqsMu.Unlock() // 挂上 Seq 后控制台只打 Warn+,Info/Debug/SQL/访问日志仍进 Seq 与文件 l.console.setMinLevel(zerolog.WarnLevel) } // CloseSeq 冲刷并关闭本 Logger 挂载的全部 SeqWriter(幂等、nil 安全) func (l *Logger) CloseSeq() { if l == nil { return } l.seqsMu.Lock() seqs := l.seqs l.seqs = nil l.seqsMu.Unlock() for _, sw := range seqs { sw.Close() } } // isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线, // 这些文件在调用栈中会被跳过,以显示真正的业务调用者。 // context.go 是 Display/View 的纯中间层,也作为基础设施跳过。 // application.go、code/makecode.go 等保留,会作为有意义的调用者返回。 func isInfrastructureFile(file string) bool { if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") { return true } if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") { return true } if strings.HasPrefix(file, "runtime/") { return true } if strings.HasPrefix(file, "log/") { return true } infraPrefixes := []string{"db/", "cache/", "common/", "dri/"} for _, prefix := range infraPrefixes { if strings.HasPrefix(file, prefix) { return true } } lowerFile := strings.ToLower(file) if strings.Contains(lowerFile, "hotime") { infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"} for _, dir := range infraDirs { if strings.Contains(file, dir) { return true } } if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") { return true } } return false }