From 04ffc4df04ef502708532ace205b3835256dba5b Mon Sep 17 00:00:00 2001 From: tanbiao Date: Sat, 10 Jan 2026 12:04:42 +0800 Subject: [PATCH 1/3] Add error log functionality documentation to README - Document separate error log file feature in logger package - Include usage examples and configuration options - Explain behavior for different log levels and output types --- README.md | 42 ++++++++++++++ logger/customize_output.go | 84 ++++++++++++++++++++++++++++ logger/default_output.go | 17 +++++- logger/env.go | 4 +- logger/logger.go | 11 ++-- logger/logger_test.go | 111 +++++++++++++++++++++++++++++++++++++ logger/root.go | 25 ++++++--- logger/stdout.go | 25 ++++++++- 8 files changed, 302 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 68555c45..7688a894 100644 --- a/README.md +++ b/README.md @@ -4,5 +4,47 @@ English | [简体中文](README_cn.md) This library mainly collects various common functional packages in the Guance Cloud. See the README of each package for details. +## Logger Package + +The logger package provides structured logging capabilities with support for separate error log files. + +### Error Logging Feature + +The logger supports writing error-level logs to a separate file for better log organization and monitoring: + +```go +import "github.com/GuanceCloud/cliutils/logger" + +// Initialize logger with separate error file +opt := &logger.Option{ + Path: "/var/log/app/main.log", // Main log file + ErrorPath: "/var/log/app/error.log", // Separate error log file + Level: "debug", + Flags: logger.OPT_DEFAULT, +} + +err := logger.InitRoot(opt) +if err != nil { + panic(err) +} +``` + +**Key Features:** +- Error logs (`ERROR`, `PANIC`, `FATAL`, `DPANIC`) are written to both main log file and separate error file +- Main log file receives all logs up to configured level +- Error file receives only error-level and above logs +- Both files support automatic rotation with configurable size, backup count, and age +- Remote TCP/UDP logging ignores ErrorPath (all logs go to same endpoint) + +**Configuration Options:** +- `Path`: Main log file path +- `ErrorPath`: Separate error log file path (optional) +- `Level`: Minimum log level (`debug`, `info`, `warn`, `error`, `panic`, `fatal`, `dpanic`) +- `Flags`: Bit flags for output options (`OPT_DEFAULT`, `OPT_COLOR`, `OPT_ROTATE`, etc.) +- `MaxSize`: Maximum file size in MB before rotation (default: 32MB) +- `MaxBackups`: Maximum number of old log files to retain (default: 5) +- `MaxAge`: Maximum number of days to retain old log files (default: 30) +- `Compress`: Compress rotated log files (default: false) + [![GoDoc](https://godoc.org/github.com/GuanceCloud/cliutils?status.svg)](https://godoc.org/github.com/GuanceCloud/cliutils) [![MIT License](https://img.shields.io/badge/license-MIT-green?style=plastic)](LICENSE) diff --git a/logger/customize_output.go b/logger/customize_output.go index 2ffb3cd8..6bfc57f9 100644 --- a/logger/customize_output.go +++ b/logger/customize_output.go @@ -7,10 +7,13 @@ package logger import ( "io" + "os" + "path/filepath" "strings" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" ) func newCustomizeRootLogger(level string, options int, ws io.Writer) (*zap.Logger, error) { @@ -92,3 +95,84 @@ func newOnlyMessageRootLogger(ws io.Writer) (*zap.Logger, error) { l := zap.New(core, zap.AddCaller()) return l, nil } + +func newMultiWriterRootLogger(fpath, errorPath, level string, options int) (*zap.Logger, error) { + // Create main log writer + mainWriter := &lumberjack.Logger{ + Filename: fpath, + MaxSize: MaxSize, + MaxBackups: MaxBackups, + MaxAge: MaxAge, + } + + // Create error log writer + errorWriter := &lumberjack.Logger{ + Filename: errorPath, + MaxSize: MaxSize, + MaxBackups: MaxBackups, + MaxAge: MaxAge, + } + + // Ensure error log directory exists + if err := os.MkdirAll(filepath.Dir(errorPath), 0o600); err != nil { + return nil, err + } + + cfg := zapcore.EncoderConfig{ + NameKey: NameKeyMod, + MessageKey: NameKeyMsg, + LevelKey: NameKeyLevel, + TimeKey: NameKeyTime, + CallerKey: NameKeyPos, + + EncodeLevel: zapcore.CapitalLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeCaller: zapcore.FullCallerEncoder, + } + + if options&OPT_COLOR != 0 { + cfg.EncodeLevel = zapcore.CapitalColorLevelEncoder + } + + if options&OPT_SHORT_CALLER != 0 { + cfg.EncodeCaller = zapcore.ShortCallerEncoder + } + + var enc zapcore.Encoder + if options&OPT_ENC_CONSOLE != 0 { + enc = zapcore.NewConsoleEncoder(cfg) + } else { + enc = zapcore.NewJSONEncoder(cfg) + } + + var lvl zapcore.Level + switch strings.ToLower(level) { + case INFO: + lvl = zap.InfoLevel + case DEBUG: + lvl = zap.DebugLevel + case WARN: + lvl = zap.WarnLevel + case ERROR: + lvl = zap.ErrorLevel + case PANIC: + lvl = zap.PanicLevel + case DPANIC: + lvl = zap.DPanicLevel + case FATAL: + lvl = zap.FatalLevel + default: + lvl = zap.DebugLevel + } + + // Combine cores with different level enablers + // Main file gets all logs up to configured level + // Error file gets only error+ level logs + core := zapcore.NewTee( + zapcore.NewCore(enc, zapcore.AddSync(mainWriter), zap.NewAtomicLevelAt(lvl)), // Main file + zapcore.NewCore(enc, zapcore.AddSync(errorWriter), zap.NewAtomicLevelAt(zapcore.ErrorLevel)), // Error file + ) + + l := zap.New(core, zap.AddCaller()) + return l, nil +} diff --git a/logger/default_output.go b/logger/default_output.go index d6d2fdb1..63cbca43 100644 --- a/logger/default_output.go +++ b/logger/default_output.go @@ -19,7 +19,7 @@ const ( STDOUT = "stdout" // log output to stdout ) -func newNormalRootLogger(fpath, level string, options int) (*zap.Logger, error) { +func newNormalRootLogger(fpath, errorPath, level string, options int) (*zap.Logger, error) { cfg := &zap.Config{ Encoding: `json`, EncoderConfig: zapcore.EncoderConfig{ @@ -49,6 +49,21 @@ func newNormalRootLogger(fpath, level string, options int) (*zap.Logger, error) } } + // Set up separate error output path + if errorPath != "" { + cfg.ErrorOutputPaths = []string{errorPath} + + if runtime.GOOS == "windows" { + if err := zap.RegisterSink("winfile", newWinFileSink); err != nil { + return nil, err + } + + cfg.ErrorOutputPaths = []string{ + "winfile:///" + errorPath, + } + } + } + switch strings.ToLower(level) { case DEBUG: cfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) diff --git a/logger/env.go b/logger/env.go index 9fabdb9c..fca19ac9 100644 --- a/logger/env.go +++ b/logger/env.go @@ -11,12 +11,12 @@ func setRootLoggerFromEnv(opt *Option) error { switch opt.Path { case "nul", /* windows */ "/dev/null": /* most UNIX */ - return doSetGlobalRootLogger(os.DevNull, opt.Level, opt.Flags) + return doSetGlobalRootLogger(os.DevNull, opt.ErrorPath, opt.Level, opt.Flags) case "": return doInitStdoutLogger() default: - return doSetGlobalRootLogger(opt.Path, opt.Level, opt.Flags) + return doSetGlobalRootLogger(opt.Path, opt.ErrorPath, opt.Level, opt.Flags) } } diff --git a/logger/logger.go b/logger/logger.go index 0be7b324..0069a26a 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -224,11 +224,12 @@ func (l *Logger) Level() zapcore.Level { } type Option struct { - Path string - Level string - MaxSize int - Flags int - Compress bool + Path string + ErrorPath string + Level string + MaxSize int + Flags int + Compress bool } func Reset() { diff --git a/logger/logger_test.go b/logger/logger_test.go index 459fd405..1d93893c 100644 --- a/logger/logger_test.go +++ b/logger/logger_test.go @@ -13,6 +13,7 @@ import ( "io" "log" "os" + "path/filepath" "strings" "testing" T "testing" @@ -640,3 +641,113 @@ func logLines(f string) int { return len(bytes.Split(logdata, []byte("\n"))) - 1 } + +func TestSeparateErrorFile(t *testing.T) { + // Reset global logger state to avoid interference from other tests + Reset() + + // Create temporary directory for test logs + tempDir := t.TempDir() + + // Create log files directly in temp directory + mainLogFile := filepath.Join(tempDir, "app.log") + errorLogFile := filepath.Join(tempDir, "error.log") + + // Clean up any existing files + os.Remove(mainLogFile) + os.Remove(errorLogFile) + + // Initialize logger with separate error file (JSON format for testing) + opt := &Option{ + Path: mainLogFile, + ErrorPath: errorLogFile, + Level: DEBUG, + Flags: OPT_ROTATE, // Only rotation, keep JSON format + } + + assert.NoError(t, InitRoot(opt)) + + // Get a named logger + l := SLogger("test") + + // Log different levels + l.Debug("this is a debug message") + l.Info("this is an info message") + l.Warn("this is a warning message") + l.Error("this is an error message") + + // Sync to ensure all logs are written + Close() + + // Check main log file contains all logs + mainContent, err := os.ReadFile(mainLogFile) + assert.NoError(t, err) + assert.Contains(t, string(mainContent), "debug message") + assert.Contains(t, string(mainContent), "info message") + assert.Contains(t, string(mainContent), "warning message") + assert.Contains(t, string(mainContent), "error message") + + // Check error log file contains only error+ level logs + errorContent, err := os.ReadFile(errorLogFile) + assert.NoError(t, err) + assert.NotContains(t, string(errorContent), "debug message") + assert.NotContains(t, string(errorContent), "info message") + assert.NotContains(t, string(errorContent), "warning message") + assert.Contains(t, string(errorContent), "error message") + + // Test that both files have proper JSON structure + // Split by newlines since each log entry is a separate JSON line + mainLines := strings.Split(strings.TrimSpace(string(mainContent)), "\n") + errorLines := strings.Split(strings.TrimSpace(string(errorContent)), "\n") + + // Remove empty strings from splits + var validMainLines, validErrorLines []string + for _, line := range mainLines { + if line != "" { + validMainLines = append(validMainLines, line) + } + } + for _, line := range errorLines { + if line != "" { + validErrorLines = append(validErrorLines, line) + } + } + + // Verify main logs have 4 entries + assert.Len(t, validMainLines, 4) + + // Verify error logs have 1 entry + assert.Len(t, validErrorLines, 1) + + // Parse each line as JSON and verify levels + var mainLogs []map[string]interface{} + for _, line := range validMainLines { + var logEntry map[string]interface{} + err = json.Unmarshal([]byte(line), &logEntry) + assert.NoError(t, err) + mainLogs = append(mainLogs, logEntry) + } + + var errorLogs []map[string]interface{} + for _, line := range validErrorLines { + var logEntry map[string]interface{} + err = json.Unmarshal([]byte(line), &logEntry) + assert.NoError(t, err) + errorLogs = append(errorLogs, logEntry) + } + + // Verify error log has correct level + assert.Equal(t, "ERROR", errorLogs[0]["lev"]) + + // Verify main logs contain all expected levels + mainLevels := make(map[string]bool) + for _, log := range mainLogs { + if level, ok := log["lev"].(string); ok { + mainLevels[level] = true + } + } + assert.True(t, mainLevels["DEBUG"]) + assert.True(t, mainLevels["INFO"]) + assert.True(t, mainLevels["WARN"]) + assert.True(t, mainLevels["ERROR"]) +} diff --git a/logger/root.go b/logger/root.go index dc178504..70b6dc64 100644 --- a/logger/root.go +++ b/logger/root.go @@ -38,7 +38,7 @@ const ( NameKeyPos = "pos" ) -func doSetGlobalRootLogger(fpath, level string, options int) error { +func doSetGlobalRootLogger(fpath, errorPath, level string, options int) error { if fpath == "" { return fmt.Errorf("fpath should not empty") } @@ -51,7 +51,7 @@ func doSetGlobalRootLogger(fpath, level string, options int) error { } var err error - root, err = newRootLogger(fpath, level, options) + root, err = newRootLogger(fpath, errorPath, level, options) if err != nil { return err } @@ -61,7 +61,7 @@ func doSetGlobalRootLogger(fpath, level string, options int) error { // SetGlobalRootLogger deprecated, use InitRoot() instead. func SetGlobalRootLogger(fpath, level string, options int) error { - return doSetGlobalRootLogger(fpath, level, options) + return doSetGlobalRootLogger(fpath, "", level, options) } // InitRoot used to setup global root logger, include @@ -102,13 +102,13 @@ func InitRoot(opt *Option) error { return doSetStdoutLogger(opt) default: - return doSetGlobalRootLogger(opt.Path, opt.Level, opt.Flags) + return doSetGlobalRootLogger(opt.Path, opt.ErrorPath, opt.Level, opt.Flags) } } -func newRootLogger(fpath, level string, options int) (*zap.Logger, error) { +func newRootLogger(fpath, errorPath, level string, options int) (*zap.Logger, error) { if fpath == "" { - return newNormalRootLogger(fpath, level, options) + return newNormalRootLogger(fpath, errorPath, level, options) } u, err := url.Parse(fpath) @@ -118,6 +118,7 @@ func newRootLogger(fpath, level string, options int) (*zap.Logger, error) { switch strings.ToLower(u.Scheme) { case SchemeTCP, SchemeUDP: // logs sending to some remote TCP/UDP server + // For remote logging, errorPath is ignored - both go to same endpoint return newCustomizeRootLogger(level, options, &remoteEndpoint{protocol: u.Scheme, host: u.Host}) @@ -139,6 +140,12 @@ func newRootLogger(fpath, level string, options int) (*zap.Logger, error) { if options&OPT_ROTATE != 0 && options&OPT_STDOUT == 0 && // can't rotate stdout fpath != os.DevNull { // can't rotate(rename) /dev/null + + if errorPath != "" { + // Create separate rotating logger for errors + return newMultiWriterRootLogger(fpath, errorPath, level, options) + } + return newCustomizeRootLogger(level, options, &lumberjack.Logger{ Filename: fpath, MaxSize: MaxSize, @@ -147,7 +154,7 @@ func newRootLogger(fpath, level string, options int) (*zap.Logger, error) { }) } - return newNormalRootLogger(fpath, level, options) + return newNormalRootLogger(fpath, errorPath, level, options) } // InitCustomizeRoot used to setup global root logger, include @@ -159,6 +166,10 @@ func InitCustomizeRoot(opt *Option) (*zap.Logger, error) { mtx.Lock() defer mtx.Unlock() + if opt.ErrorPath != "" { + return newMultiWriterRootLogger(opt.Path, opt.ErrorPath, opt.Level, opt.Flags) + } + lumberLog := &lumberjack.Logger{ Filename: opt.Path, MaxSize: opt.MaxSize, diff --git a/logger/stdout.go b/logger/stdout.go index 7d510361..7ca9be92 100644 --- a/logger/stdout.go +++ b/logger/stdout.go @@ -35,7 +35,18 @@ func doSetStdoutLogger(opt *Option) error { // reset default stdout logger defaultStdoutRootLogger = nil var err error - defaultStdoutRootLogger, err = stdoutLogger(opt.Level, opt.Flags) + defaultStdoutRootLogger, err = stdoutLoggerWithErrors(opt.Level, opt.ErrorPath, opt.Flags) + if err != nil { + return fmt.Errorf("stdoutLogger: %w", err) + } + return nil +} + +func doSetStdoutLoggerWithErrors(opt *Option) error { + // reset default stdout logger + defaultStdoutRootLogger = nil + var err error + defaultStdoutRootLogger, err = stdoutLoggerWithErrors(opt.Level, opt.ErrorPath, opt.Flags) if err != nil { return fmt.Errorf("stdoutLogger: %w", err) } @@ -45,7 +56,17 @@ func doSetStdoutLogger(opt *Option) error { func stdoutLogger(level string, options int) (*zap.Logger, error) { opt := options | OPT_STDOUT - if rootlogger, err := newRootLogger("", level, opt); err != nil { + if rootlogger, err := newRootLogger("", "", level, opt); err != nil { + return nil, err + } else { + return rootlogger, err + } +} + +func stdoutLoggerWithErrors(level, errorPath string, options int) (*zap.Logger, error) { + opt := options | OPT_STDOUT + + if rootlogger, err := newRootLogger("", errorPath, level, opt); err != nil { return nil, err } else { return rootlogger, err From 9dbe25373360164c7d7dac45fe94223cbfb976fc Mon Sep 17 00:00:00 2001 From: tanbiao Date: Sat, 10 Jan 2026 12:09:32 +0800 Subject: [PATCH 2/3] Move error log documentation to logger package README - Add comprehensive error logging documentation to logger/readme.md - Remove logger-specific documentation from root README.md - Maintain clean separation between package-specific and general documentation --- README.md | 42 ------------------------------------------ logger/readme.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 7688a894..68555c45 100644 --- a/README.md +++ b/README.md @@ -4,47 +4,5 @@ English | [简体中文](README_cn.md) This library mainly collects various common functional packages in the Guance Cloud. See the README of each package for details. -## Logger Package - -The logger package provides structured logging capabilities with support for separate error log files. - -### Error Logging Feature - -The logger supports writing error-level logs to a separate file for better log organization and monitoring: - -```go -import "github.com/GuanceCloud/cliutils/logger" - -// Initialize logger with separate error file -opt := &logger.Option{ - Path: "/var/log/app/main.log", // Main log file - ErrorPath: "/var/log/app/error.log", // Separate error log file - Level: "debug", - Flags: logger.OPT_DEFAULT, -} - -err := logger.InitRoot(opt) -if err != nil { - panic(err) -} -``` - -**Key Features:** -- Error logs (`ERROR`, `PANIC`, `FATAL`, `DPANIC`) are written to both main log file and separate error file -- Main log file receives all logs up to configured level -- Error file receives only error-level and above logs -- Both files support automatic rotation with configurable size, backup count, and age -- Remote TCP/UDP logging ignores ErrorPath (all logs go to same endpoint) - -**Configuration Options:** -- `Path`: Main log file path -- `ErrorPath`: Separate error log file path (optional) -- `Level`: Minimum log level (`debug`, `info`, `warn`, `error`, `panic`, `fatal`, `dpanic`) -- `Flags`: Bit flags for output options (`OPT_DEFAULT`, `OPT_COLOR`, `OPT_ROTATE`, etc.) -- `MaxSize`: Maximum file size in MB before rotation (default: 32MB) -- `MaxBackups`: Maximum number of old log files to retain (default: 5) -- `MaxAge`: Maximum number of days to retain old log files (default: 30) -- `Compress`: Compress rotated log files (default: false) - [![GoDoc](https://godoc.org/github.com/GuanceCloud/cliutils?status.svg)](https://godoc.org/github.com/GuanceCloud/cliutils) [![MIT License](https://img.shields.io/badge/license-MIT-green?style=plastic)](LICENSE) diff --git a/logger/readme.md b/logger/readme.md index ee8ff166..c63f0c01 100644 --- a/logger/readme.md +++ b/logger/readme.md @@ -106,3 +106,41 @@ log.RLError() ## 提供环境变量来配置日志路径 调用 `InitRoot()` 时,如果传入的路径为空字符串,那么会尝试从 `LOGGER_PATH` 这个环境变量中获取有效的日志路径。某些情况下,可以将该路径设置成 `/dev/null`(UNIX) 或 `nul`(windows),用来屏蔽日志输出。 + +## 错误日志分离功能 + +支持将错误级别的日志写入单独的文件,便于日志组织和监控: + +```golang +package main + +import ( + "github.com/GuanceCloud/cliutils/logger" +) + +func main() { + r, err := logger.InitRoot(&logger.Option{ + Path: "/var/log/app/main.log", // 主日志文件 + ErrorPath: "/var/log/app/error.log", // 单独的错误日志文件 + Level: logger.DEBUG, // 默认为 DEBUG + Flags: logger.OPT_DEFAULT, // 开启了自动切割 + }) +} +``` + +### 核心特性 +- 错误日志(`ERROR`, `PANIC`, `FATAL`, `DPANIC`)会同时写入主日志文件和单独的错误文件 +- 主日志文件接收所有达到配置级别的日志 +- 错误文件只接收错误级别及以上的日志 +- 两个文件都支持自动切割,可配置大小、备份数量和保存时长 +- 远程 TCP/UDP 日志会忽略 ErrorPath(所有日志都发送到同一端点) + +### 配置选项 +- `Path`: 主日志文件路径 +- `ErrorPath`: 单独的错误日志文件路径(可选) +- `Level`: 最小日志级别(`debug`, `info`, `warn`, `error`, `panic`, `fatal`, `dpanic`) +- `Flags`: 输出选项标志位(`OPT_DEFAULT`, `OPT_COLOR`, `OPT_ROTATE` 等) +- `MaxSize`: 切割前的最大文件大小(MB,默认:32MB) +- `MaxBackups`: 保留的旧日志文件最大数量(默认:5) +- `MaxAge`: 保留旧日志文件的最大天数(默认:30) +- `Compress`: 是否压缩切割后的日志文件(默认:false) From 65c14e62b5f4cf78bb20c6395d68ec8f6104c8ab Mon Sep 17 00:00:00 2001 From: coanor Date: Mon, 12 Jan 2026 18:25:22 +0800 Subject: [PATCH 3/3] fix lint --- logger/root.go | 1 - logger/stdout.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/logger/root.go b/logger/root.go index 70b6dc64..9f33ca20 100644 --- a/logger/root.go +++ b/logger/root.go @@ -140,7 +140,6 @@ func newRootLogger(fpath, errorPath, level string, options int) (*zap.Logger, er if options&OPT_ROTATE != 0 && options&OPT_STDOUT == 0 && // can't rotate stdout fpath != os.DevNull { // can't rotate(rename) /dev/null - if errorPath != "" { // Create separate rotating logger for errors return newMultiWriterRootLogger(fpath, errorPath, level, options) diff --git a/logger/stdout.go b/logger/stdout.go index 7ca9be92..c9c82c5b 100644 --- a/logger/stdout.go +++ b/logger/stdout.go @@ -42,7 +42,7 @@ func doSetStdoutLogger(opt *Option) error { return nil } -func doSetStdoutLoggerWithErrors(opt *Option) error { +func doSetStdoutLoggerWithErrors(opt *Option) error { // nolint:deadcode,unused // reset default stdout logger defaultStdoutRootLogger = nil var err error