Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions logger/customize_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
17 changes: 16 additions & 1 deletion logger/default_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions logger/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
11 changes: 6 additions & 5 deletions logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
111 changes: 111 additions & 0 deletions logger/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
T "testing"
Expand Down Expand Up @@ -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"])
}
38 changes: 38 additions & 0 deletions logger/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading