允许不同级别的写入info
This commit is contained in:
@@ -108,8 +108,17 @@ func (l *Logger) logger(ctx context.Context, event string, v ...any) {
|
||||
l.reportError(fmt.Errorf("loggerx: 写入日志失败: %w", err))
|
||||
}
|
||||
|
||||
if l.option.errorToInfo && event == "error" {
|
||||
if _, err := l.write("info", line1); err != nil {
|
||||
// allLevelToInfo:除 info 之外的所有级别(debug/warn/error)都额外往同一个
|
||||
// info 日志里再写一份,info.log 因此成为「一个文件看全量」的汇总文件。
|
||||
// event 只会是包内固定的四个字面量(info/debug/warn/error,见 loggerx.go),
|
||||
// 所以 event != "info" 恰好等价于「其它所有级别」
|
||||
//
|
||||
// 两个细节:
|
||||
// - 复制出来的行保留原始 level 字段,info.log 里依然能区分级别;
|
||||
// - 走 writeFileOnly 而不是 write:镜像不再经过额外驱动,
|
||||
// 否则控制台/自定义 writer 上每条非 info 日志都会重复输出两遍
|
||||
if l.option.allLevelToInfo && event != "info" {
|
||||
if _, err := l.writeFileOnly("info", line1); err != nil {
|
||||
l.reportError(fmt.Errorf("loggerx: 写入 info 日志失败: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
package loggerx_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/yuninks/loggerx"
|
||||
)
|
||||
|
||||
// levelsOf 解析 JSON 日志的每一行,返回「level -> 出现次数」
|
||||
func levelsOf(t *testing.T, raw string) map[string]int {
|
||||
t.Helper()
|
||||
out := make(map[string]int)
|
||||
for _, line := range splitLines(raw) {
|
||||
var fd struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &fd); err != nil {
|
||||
t.Fatalf("日志行不是合法 JSON: %q: %v", line, err)
|
||||
}
|
||||
out[fd.Level]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitLines 去掉空行,只保留有效日志行
|
||||
func splitLines(raw string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 开启 SetAllLevelToInfo 后:debug / warn / error 都要同时进 info.log,
|
||||
// 各原级别文件仍只写自己那一条;info 本身不重复写
|
||||
func TestAllLevelToInfoMirrorsEveryLevel(t *testing.T) {
|
||||
l, dir := newTestLogger(t, loggerx.SetAllLevelToInfo())
|
||||
|
||||
ctx := context.Background()
|
||||
l.Debug(ctx, "M-DEBUG")
|
||||
l.Info(ctx, "M-INFO")
|
||||
l.Warn(ctx, "M-WARN")
|
||||
l.Error(ctx, "M-ERROR")
|
||||
|
||||
info := syncAndRead(t, l, dir, "info")
|
||||
|
||||
for _, ev := range []string{"debug", "info", "warn", "error"} {
|
||||
marker := "M-" + strings.ToUpper(ev)
|
||||
if n := strings.Count(info, marker); n != 1 {
|
||||
t.Errorf("info.log 中 %s 出现 %d 次,期望 1 次\n%s", marker, n, info)
|
||||
}
|
||||
}
|
||||
|
||||
// 副本必须保留原始 level,而不是统一改写成 info
|
||||
want := map[string]int{"debug": 1, "info": 1, "warn": 1, "error": 1}
|
||||
got := levelsOf(t, info)
|
||||
for lv, n := range want {
|
||||
if got[lv] != n {
|
||||
t.Errorf("info.log 中 level=%s 的行数 = %d,期望 %d(实际 %v)", lv, got[lv], n, got)
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("info.log 出现了预期外的 level: %v", got)
|
||||
}
|
||||
|
||||
// 原级别文件不受影响:各写各的,且只写一条
|
||||
for _, ev := range []string{"debug", "warn", "error"} {
|
||||
own := syncAndRead(t, l, dir, ev)
|
||||
marker := "M-" + strings.ToUpper(ev)
|
||||
if n := strings.Count(own, marker); n != 1 {
|
||||
t.Errorf("%s.log 中 %s 出现 %d 次,期望 1 次\n%s", ev, marker, n, own)
|
||||
}
|
||||
for _, other := range []string{"M-DEBUG", "M-INFO", "M-WARN", "M-ERROR"} {
|
||||
if other == marker {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(own, other) {
|
||||
t.Errorf("%s.log 混入了 %s\n%s", ev, other, own)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未开启选项时(默认):info.log 只放 info,其它级别不得混入
|
||||
func TestAllLevelToInfoOffByDefault(t *testing.T) {
|
||||
l, dir := newTestLogger(t)
|
||||
|
||||
ctx := context.Background()
|
||||
l.Debug(ctx, "N-DEBUG")
|
||||
l.Info(ctx, "N-INFO")
|
||||
l.Warn(ctx, "N-WARN")
|
||||
l.Error(ctx, "N-ERROR")
|
||||
|
||||
info := syncAndRead(t, l, dir, "info")
|
||||
for _, marker := range []string{"N-DEBUG", "N-WARN", "N-ERROR"} {
|
||||
if strings.Contains(info, marker) {
|
||||
t.Errorf("未开启 SetAllLevelToInfo 时 info.log 不应包含 %s\n%s", marker, info)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(info, "N-INFO"); n != 1 {
|
||||
t.Errorf("info.log 中 info 日志出现 %d 次,期望 1 次\n%s", n, info)
|
||||
}
|
||||
}
|
||||
|
||||
// 副本只落文件:控制台 / 自定义驱动上每条日志只能出现一次(同步与异步都成立)
|
||||
func TestAllLevelToInfoDoesNotDuplicateDrivers(t *testing.T) {
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
opts []loggerx.Option
|
||||
}{
|
||||
{name: "sync"},
|
||||
{name: "async", opts: []loggerx.Option{loggerx.SetWriteAsync()}},
|
||||
} {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
opts := append([]loggerx.Option{loggerx.SetAllLevelToInfo(), loggerx.SetExtraDriver(&buf)}, mode.opts...)
|
||||
l, dir := newTestLogger(t, opts...)
|
||||
|
||||
ctx := context.Background()
|
||||
l.Debug(ctx, "D-DEBUG")
|
||||
l.Info(ctx, "D-INFO")
|
||||
l.Warn(ctx, "D-WARN")
|
||||
l.Error(ctx, "D-ERROR")
|
||||
|
||||
// 异步模式下驱动由消费协程写,必须先 Close 再读 buf,否则是数据竞争
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
info := syncAndRead(t, l, dir, "info")
|
||||
out := buf.String()
|
||||
|
||||
for _, marker := range []string{"D-DEBUG", "D-INFO", "D-WARN", "D-ERROR"} {
|
||||
if n := strings.Count(out, marker); n != 1 {
|
||||
t.Errorf("额外驱动里 %s 出现 %d 次,期望 1 次\n%s", marker, n, out)
|
||||
}
|
||||
if !strings.Contains(info, marker) {
|
||||
t.Errorf("info.log 里缺少 %s\n%s", marker, info)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 异步 + channel 组合下镜像同样成立,且不能串 channel
|
||||
func TestAllLevelToInfoAsyncChannel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
l := loggerx.NewLogger(context.Background(),
|
||||
loggerx.SetDir(dir),
|
||||
loggerx.SetWriteAsync(),
|
||||
loggerx.SetAllLevelToInfo(),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
l.Channel("sub").Error(ctx, "A-SUBERR")
|
||||
l.Channel("sub").Warn(ctx, "A-SUBWARN")
|
||||
l.Channel("sub").Info(ctx, "A-SUBINFO")
|
||||
l.Error(ctx, "A-ROOTERR")
|
||||
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
subInfo := readLog(t, filepath.Join(dir, "sub"), "info")
|
||||
for _, marker := range []string{"A-SUBERR", "A-SUBWARN", "A-SUBINFO"} {
|
||||
if n := strings.Count(subInfo, marker); n != 1 {
|
||||
t.Errorf("sub/info.log 中 %s 出现 %d 次,期望 1 次\n%s", marker, n, subInfo)
|
||||
}
|
||||
}
|
||||
// error 仍然落在自己的文件里
|
||||
subErr := readLog(t, filepath.Join(dir, "sub"), "error")
|
||||
if n := strings.Count(subErr, "A-SUBERR"); n != 1 {
|
||||
t.Errorf("sub/error.log 中 A-SUBERR 出现 %d 次,期望 1 次\n%s", n, subErr)
|
||||
}
|
||||
|
||||
// 根 channel 的 info.log 不能混入 sub 的内容
|
||||
rootInfo := readLog(t, dir, "info")
|
||||
if n := strings.Count(rootInfo, "A-ROOTERR"); n != 1 {
|
||||
t.Errorf("根 info.log 中 A-ROOTERR 出现 %d 次,期望 1 次\n%s", n, rootInfo)
|
||||
}
|
||||
if strings.Contains(rootInfo, "A-SUBERR") {
|
||||
t.Errorf("根 info.log 混入了 sub channel 的日志\n%s", rootInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// 级别过滤优先:被 SetMinLevel 丢弃的日志不会因为它出现在 info.log 里
|
||||
func TestAllLevelToInfoRespectsMinLevel(t *testing.T) {
|
||||
l, dir := newTestLogger(t, loggerx.SetAllLevelToInfo(), loggerx.SetMinLevel(loggerx.LevelWarn))
|
||||
|
||||
ctx := context.Background()
|
||||
l.Debug(ctx, "F-DEBUG")
|
||||
l.Info(ctx, "F-INFO")
|
||||
l.Warn(ctx, "F-WARN")
|
||||
l.Error(ctx, "F-ERROR")
|
||||
|
||||
info := syncAndRead(t, l, dir, "info")
|
||||
for _, marker := range []string{"F-DEBUG", "F-INFO"} {
|
||||
if strings.Contains(info, marker) {
|
||||
t.Errorf("低于 MinLevel 的 %s 不应出现在 info.log\n%s", marker, info)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"F-WARN", "F-ERROR"} {
|
||||
if n := strings.Count(info, marker); n != 1 {
|
||||
t.Errorf("info.log 中 %s 出现 %d 次,期望 1 次\n%s", marker, n, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// text 格式下镜像同样是一行一条
|
||||
func TestAllLevelToInfoTextFormat(t *testing.T) {
|
||||
l, dir := newTestLogger(t, loggerx.SetAllLevelToInfo(), loggerx.SetFormat(loggerx.FormatText))
|
||||
|
||||
ctx := context.Background()
|
||||
l.Warn(ctx, "T-WARN")
|
||||
l.Error(ctx, "T-ERROR")
|
||||
|
||||
info := syncAndRead(t, l, dir, "info")
|
||||
lines := splitLines(info)
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("info.log 行数 = %d,期望 2\n%s", len(lines), info)
|
||||
}
|
||||
for i, marker := range []string{"T-WARN", "T-ERROR"} {
|
||||
if !strings.Contains(lines[i], marker) {
|
||||
t.Errorf("info.log 第 %d 行缺少 %s: %s", i+1, marker, lines[i])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(lines[1], "level=error") {
|
||||
t.Errorf("text 副本应保留原始 level: %s", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 并发下镜像不能丢、不能重复:info.log 的条数必须是四个级别之和
|
||||
func TestAllLevelToInfoConcurrentExactCounts(t *testing.T) {
|
||||
const (
|
||||
workers = 8
|
||||
rounds = 60
|
||||
)
|
||||
l, dir := newTestLogger(t, loggerx.SetAllLevelToInfo())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < rounds; i++ {
|
||||
l.Debugf(ctx, "C-DEBUG-%d-%d", w, i)
|
||||
l.Infof(ctx, "C-INFO-%d-%d", w, i)
|
||||
l.Warnf(ctx, "C-WARN-%d-%d", w, i)
|
||||
l.Errorf(ctx, "C-ERROR-%d-%d", w, i)
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 原级别文件:debug/warn/error 各自只放自己那 rounds*workers 条
|
||||
for _, ev := range []string{"debug", "warn", "error"} {
|
||||
got := syncAndRead(t, l, dir, ev)
|
||||
if n := len(splitLines(got)); n != workers*rounds {
|
||||
t.Errorf("%s.log 行数 = %d,期望 %d", ev, n, workers*rounds)
|
||||
}
|
||||
}
|
||||
|
||||
// info.log:info 本身 + debug/warn/error 三份副本 = 4 倍
|
||||
info := syncAndRead(t, l, dir, "info")
|
||||
if n := len(splitLines(info)); n != 4*workers*rounds {
|
||||
t.Errorf("info.log 行数 = %d,期望 %d", n, 4*workers*rounds)
|
||||
}
|
||||
for _, ev := range []string{"DEBUG", "INFO", "WARN", "ERROR"} {
|
||||
if n := strings.Count(info, "C-"+ev+"-"); n != workers*rounds {
|
||||
t.Errorf("info.log 中 %s 出现 %d 次,期望 %d", ev, n, workers*rounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 异步 + 并发:镜像任务不能丢、不能串级别文件
|
||||
func TestAllLevelToInfoAsyncConcurrentCounts(t *testing.T) {
|
||||
const (
|
||||
workers = 4
|
||||
rounds = 50
|
||||
)
|
||||
dir := t.TempDir()
|
||||
l := loggerx.NewLogger(context.Background(),
|
||||
loggerx.SetDir(dir),
|
||||
loggerx.SetWriteAsync(),
|
||||
loggerx.SetAllLevelToInfo(),
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < rounds; i++ {
|
||||
l.Warnf(ctx, "Q-WARN-%d-%d", w, i)
|
||||
l.Errorf(ctx, "Q-ERROR-%d-%d", w, i)
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
for _, ev := range []string{"warn", "error"} {
|
||||
own := readLog(t, dir, ev)
|
||||
if n := strings.Count(own, "Q-"+strings.ToUpper(ev)+"-"); n != workers*rounds {
|
||||
t.Errorf("%s.log 中自身日志出现 %d 次,期望 %d", ev, n, workers*rounds)
|
||||
}
|
||||
}
|
||||
info := readLog(t, dir, "info")
|
||||
for _, ev := range []string{"WARN", "ERROR"} {
|
||||
if n := strings.Count(info, "Q-"+ev+"-"); n != workers*rounds {
|
||||
t.Errorf("info.log 中 %s 出现 %d 次,期望 %d", ev, n, workers*rounds)
|
||||
}
|
||||
}
|
||||
if n := len(splitLines(info)); n != 2*workers*rounds {
|
||||
t.Errorf("info.log 行数 = %d,期望 %d", n, 2*workers*rounds)
|
||||
}
|
||||
}
|
||||
|
||||
// readLevelAll 读取某级别在目录下的全部文件(含大小切割产生的 _N 归档)
|
||||
func readLevelAll(t *testing.T, dir, event string) string {
|
||||
t.Helper()
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*_"+event+"*.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob %s: %v", event, err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", f, err)
|
||||
}
|
||||
sb.Write(b)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 开启大小切割时镜像同样要正常滚动归档,一条都不能丢、也不能重复
|
||||
func TestAllLevelToInfoWithSizeSplit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
l := loggerx.NewLogger(context.Background(),
|
||||
loggerx.SetDir(dir),
|
||||
loggerx.SetAllLevelToInfo(),
|
||||
loggerx.SetSizeSplit(2*1024), // 很小,强制滚动多次
|
||||
loggerx.SetCompress(false), // 不压缩,便于直接读归档
|
||||
)
|
||||
t.Cleanup(func() {
|
||||
if err := l.Close(); err != nil {
|
||||
t.Errorf("Close 返回错误: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
const n = 200
|
||||
ctx := context.Background()
|
||||
for i := 0; i < n; i++ {
|
||||
l.Warnf(ctx, "S-WARN-%03d", i)
|
||||
l.Infof(ctx, "S-INFO-%03d", i)
|
||||
}
|
||||
if err := l.MustSync(); err != nil {
|
||||
t.Fatalf("MustSync: %v", err)
|
||||
}
|
||||
|
||||
info := readLevelAll(t, dir, "info")
|
||||
warn := readLevelAll(t, dir, "warn")
|
||||
for i := 0; i < n; i++ {
|
||||
if got := strings.Count(info, fmt.Sprintf("S-WARN-%03d", i)); got != 1 {
|
||||
t.Fatalf("info.log 中 S-WARN-%03d 出现 %d 次,期望 1 次", i, got)
|
||||
}
|
||||
if got := strings.Count(info, fmt.Sprintf("S-INFO-%03d", i)); got != 1 {
|
||||
t.Fatalf("info.log 中 S-INFO-%03d 出现 %d 次,期望 1 次", i, got)
|
||||
}
|
||||
if got := strings.Count(warn, fmt.Sprintf("S-WARN-%03d", i)); got != 1 {
|
||||
t.Fatalf("warn.log 中 S-WARN-%03d 出现 %d 次,期望 1 次", i, got)
|
||||
}
|
||||
}
|
||||
if got, want := len(splitLines(info)), 2*n; got != want {
|
||||
t.Errorf("info.log 总行数 = %d,期望 %d", got, want)
|
||||
}
|
||||
if got, want := len(splitLines(warn)), n; got != want {
|
||||
t.Errorf("warn.log 总行数 = %d,期望 %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 异步模式下镜像写入失败必须上报(否则 info.log 写不进去是静默的),
|
||||
// 且不能影响原级别文件落盘
|
||||
func TestAllLevelToInfoAsyncReportsMirrorFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 用一个同名目录占住 info.log:镜像必然打不开,而 warn.log 正常
|
||||
if err := os.Mkdir(filepath.Join(dir, "info.log"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir info.log: %v", err)
|
||||
}
|
||||
|
||||
reported := make(chan error, 32)
|
||||
l := loggerx.NewLogger(context.Background(),
|
||||
loggerx.SetDir(dir),
|
||||
loggerx.SetFileSplit(loggerx.FileSplitNone),
|
||||
loggerx.SetWriteAsync(),
|
||||
loggerx.SetAllLevelToInfo(),
|
||||
loggerx.SetErrorHandler(func(err error) {
|
||||
select {
|
||||
case reported <- err:
|
||||
default:
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
l.Warn(context.Background(), "R-WARN")
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-reported:
|
||||
if !strings.Contains(err.Error(), "info") {
|
||||
t.Errorf("上报的错误与 info 镜像无关: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Error("异步模式下 info 镜像写入失败没有被上报")
|
||||
}
|
||||
|
||||
if !strings.Contains(readLog(t, dir, "warn"), "R-WARN") {
|
||||
t.Error("镜像失败不应影响 warn.log 落盘")
|
||||
}
|
||||
}
|
||||
|
||||
// SetPrintFile(false):没有文件可镜像,选项不能引发重复驱动输出,也不能建出 info.log
|
||||
func TestAllLevelToInfoWithoutFile(t *testing.T) {
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
opts []loggerx.Option
|
||||
}{
|
||||
{name: "sync"},
|
||||
{name: "async", opts: []loggerx.Option{loggerx.SetWriteAsync()}},
|
||||
} {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
dir := t.TempDir()
|
||||
opts := append([]loggerx.Option{
|
||||
loggerx.SetDir(dir),
|
||||
loggerx.SetPrintFile(false),
|
||||
loggerx.SetAllLevelToInfo(),
|
||||
loggerx.SetExtraDriver(&buf),
|
||||
}, mode.opts...)
|
||||
l := loggerx.NewLogger(context.Background(), opts...)
|
||||
|
||||
l.Error(context.Background(), "P-ERROR")
|
||||
l.Warn(context.Background(), "P-WARN")
|
||||
|
||||
// 异步模式下驱动由消费协程写,必须先 Close 再读 buf
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if n := strings.Count(out, "P-ERROR"); n != 1 {
|
||||
t.Errorf("驱动里 P-ERROR 出现 %d 次,期望 1 次\n%s", n, out)
|
||||
}
|
||||
if n := strings.Count(out, "P-WARN"); n != 1 {
|
||||
t.Errorf("驱动里 P-WARN 出现 %d 次,期望 1 次\n%s", n, out)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*_info.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("SetPrintFile(false) 时不应产生文件,实际有 %v", files)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -20,7 +20,7 @@ func TestLogger(t *testing.T) {
|
||||
|
||||
l := loggerx.NewLogger(
|
||||
context.Background(),
|
||||
loggerx.SetErrorToInfo(),
|
||||
loggerx.SetAllLevelToInfo(),
|
||||
loggerx.SetToConsole(),
|
||||
loggerx.SetTimeZone(time.UTC),
|
||||
// loggerx.SetExtraDriver(b, Print{}),
|
||||
|
||||
+39
-25
@@ -8,28 +8,28 @@ import (
|
||||
)
|
||||
|
||||
type loggerOption struct {
|
||||
prefix string // 日志前缀
|
||||
format Format // json / text
|
||||
dir string // 文件目录
|
||||
isGinLog bool
|
||||
isGid bool
|
||||
isPrintFile bool
|
||||
asGlobalLog bool // 是否接管全局 stdlib log(默认 false)
|
||||
writeType writeType // 是否异步罗盘
|
||||
traceField string // trace字段
|
||||
errorToInfo bool // 错误日志是否写入info日志
|
||||
days int // 日志保存天数
|
||||
drivers []io.Writer // 文件落盘驱动器
|
||||
fileSplit FileSplit // 文件切割规则
|
||||
sizeSplit int // 根据文件大小切割(字节,<=0 不切割)
|
||||
compress bool // 归档文件是否压缩为 .gz
|
||||
compressLvl int // gzip 压缩级别
|
||||
flushEvery time.Duration // 定时刷盘间隔,0 表示不开启
|
||||
minLevel Level // 最低输出级别
|
||||
onError func(error) // 内部错误回调
|
||||
timeZone *time.Location // 时区
|
||||
escapeHTML bool
|
||||
expandData map[string]string // 扩展字段
|
||||
prefix string // 日志前缀
|
||||
format Format // json / text
|
||||
dir string // 文件目录
|
||||
isGinLog bool
|
||||
isGid bool
|
||||
isPrintFile bool
|
||||
asGlobalLog bool // 是否接管全局 stdlib log(默认 false)
|
||||
writeType writeType // 是否异步罗盘
|
||||
traceField string // trace字段
|
||||
allLevelToInfo bool // 非 info 级别的日志是否同时写入 info 日志
|
||||
days int // 日志保存天数
|
||||
drivers []io.Writer // 文件落盘驱动器
|
||||
fileSplit FileSplit // 文件切割规则
|
||||
sizeSplit int // 根据文件大小切割(字节,<=0 不切割)
|
||||
compress bool // 归档文件是否压缩为 .gz
|
||||
compressLvl int // gzip 压缩级别
|
||||
flushEvery time.Duration // 定时刷盘间隔,0 表示不开启
|
||||
minLevel Level // 最低输出级别
|
||||
onError func(error) // 内部错误回调
|
||||
timeZone *time.Location // 时区
|
||||
escapeHTML bool
|
||||
expandData map[string]string // 扩展字段
|
||||
}
|
||||
|
||||
type writeType uint8
|
||||
@@ -95,10 +95,24 @@ func SetToConsole() Option {
|
||||
}
|
||||
}
|
||||
|
||||
// 错误日志是否写入info日志
|
||||
func SetErrorToInfo() Option {
|
||||
// SetAllLevelToInfo 开启后,除 info 以外的所有级别(debug / warn / error)
|
||||
// 都会额外往同一个 channel 的 info 日志里再写一份,
|
||||
// info.log 因此成为「一个文件看全量日志」的汇总文件。
|
||||
//
|
||||
// 具体行为:
|
||||
// - 各原级别文件照旧写(warn 进 warn.log、error 进 error.log),info.log 是副本;
|
||||
// - 副本保留原始 level 字段,info.log 里仍能按级别区分和过滤;
|
||||
// - info 本身不重复写,不会在 info.log 里出现两行同样的 info;
|
||||
// - 副本只落文件,不会再走一遍额外驱动(控制台/自定义 writer),避免重复输出;
|
||||
// - 级别过滤(SetMinLevel)仍然先生效:被丢弃的日志不会因为它而出现在 info.log 里;
|
||||
// - SetPrintFile(false) 时该选项无意义(没有文件可写)。
|
||||
//
|
||||
// 代价:开启后 info.log 的体积约等于所有级别日志之和(保留期内磁盘占用同样成倍
|
||||
// 增长);异步模式下每条非 info 日志会多占一个队列槽位。
|
||||
// 按大小/时间切割与过期清理仍然照常生效。
|
||||
func SetAllLevelToInfo() Option {
|
||||
return func(o *loggerOption) {
|
||||
o.errorToInfo = true
|
||||
o.allLevelToInfo = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ log := loggerx.NewLogger(ctx,
|
||||
loggerx.SetMinLevel(loggerx.LevelInfo), // 最低输出级别,默认 LevelDebug(全输出)
|
||||
loggerx.SetErrorHandler(onLogError), // 日志库自身故障回调(磁盘满/句柄失效等)
|
||||
loggerx.SetTraceField("trace_id"), // trace 字段名,默认 trace_id
|
||||
loggerx.SetErrorToInfo(), // error 是否同时写入 info 日志
|
||||
loggerx.SetAllLevelToInfo(), // debug/warn/error 是否同时写入 info 日志
|
||||
loggerx.SetExpandData("app", "order"), // 每条日志追加固定字段
|
||||
loggerx.SetExtraDriver(f, hooks), // 额外落盘驱动(实现 io.Writer 即可)
|
||||
loggerx.SetPrintFile(false), // 不写文件,只走驱动
|
||||
@@ -172,6 +172,31 @@ loggerx.SetMinLevel(loggerx.LevelInfo) // 生产常用:Debug 直接丢弃,
|
||||
loggerx.SetMinLevel(loggerx.LevelOff) // 全关
|
||||
```
|
||||
|
||||
### 全量日志汇总到一个文件(SetAllLevelToInfo)
|
||||
|
||||
开启后,除 `info` 以外的所有级别(`debug` / `warn` / `error`)都会额外往同一个 channel 的
|
||||
`info` 日志里再写一份,`info.log` 就是「一个文件看全量」的汇总文件:
|
||||
|
||||
```go
|
||||
log := loggerx.NewLogger(ctx, loggerx.SetDir("./log"), loggerx.SetAllLevelToInfo())
|
||||
|
||||
log.Info(ctx, "只进 info.log")
|
||||
log.Warn(ctx, "进 warn.log,同时进 info.log")
|
||||
log.Error(ctx, "进 error.log,同时进 info.log")
|
||||
```
|
||||
|
||||
行为细节:
|
||||
|
||||
- **原级别文件照旧写**:`warn.log` / `error.log` 内容不变,`info.log` 里是副本
|
||||
- **副本保留原始 level 字段**,因此在 `info.log` 里仍然能按级别区分和过滤(不会统一改写成 `info`)
|
||||
- **`info` 本身不重复写**,`info.log` 里不会出现两行相同的 info
|
||||
- **副本只落文件**,不会再走一遍额外驱动(`SetToConsole` / `SetExtraDriver`),控制台不会出现重复行
|
||||
- **级别过滤优先**:被 `SetMinLevel` 丢弃的日志不会因为它出现在 `info.log` 里
|
||||
- **`SetPrintFile(false)` 时该选项无意义**(没有文件可写)
|
||||
- **代价**:`info.log` 体积约等于所有级别日志之和;异步模式下每条非 info 日志会多占一个
|
||||
队列槽位(队列容量 1000,非 info 级别的有效缓冲因此减半)
|
||||
- 按时间/大小切割、过期清理照常生效
|
||||
|
||||
### 把日志库自身的故障暴露出来
|
||||
|
||||
磁盘满、句柄失效、归档失败、刷盘超时这类问题默认只体现在返回值里,而调用方通常忽略返回值。
|
||||
|
||||
+40
-3
@@ -15,12 +15,39 @@ const closeDrainTimeout = 5 * time.Second
|
||||
|
||||
// 写入,需要判断同步还是异步
|
||||
func (l *Logger) write(event string, b []byte) (n int, err error) {
|
||||
if l.toAsync(event, b) {
|
||||
if l.toAsync(event, b, false) {
|
||||
return len(b), nil
|
||||
}
|
||||
return l.store(event, b)
|
||||
}
|
||||
|
||||
// writeFileOnly 只写文件,不写额外驱动
|
||||
//
|
||||
// 用于 allLevelToInfo 的镜像写入:这条日志的内容已经在原级别文件里走过一次驱动了,
|
||||
// 再走一遍会让控制台/自定义 writer 出现完全重复的两行
|
||||
func (l *Logger) writeFileOnly(event string, b []byte) (n int, err error) {
|
||||
if !l.option.isPrintFile {
|
||||
// 不落文件时镜像没有去处:直接返回,连队列和内存拷贝都省掉
|
||||
return len(b), nil
|
||||
}
|
||||
if l.toAsync(event, b, true) {
|
||||
return len(b), nil
|
||||
}
|
||||
return l.storeFileOnlyTo(l.channel, event, b)
|
||||
}
|
||||
|
||||
// storeFileOnlyTo 把日志只写进指定 channel 的文件,跳过全部驱动
|
||||
// 句柄缓冲不是并发安全的,因此和 storeFileTo 一样必须在 writeMu 内写
|
||||
func (l *Logger) storeFileOnlyTo(channel, event string, b []byte) (n int, err error) {
|
||||
if !l.option.isPrintFile {
|
||||
// 不落文件时该选项没有意义:镜像的唯一去处就是文件,直接当成功
|
||||
return len(b), nil
|
||||
}
|
||||
l.writeMu.Lock()
|
||||
defer l.writeMu.Unlock()
|
||||
return l.storeFileTo(channel, event, b)
|
||||
}
|
||||
|
||||
// 实际的存储
|
||||
// 必须满足 io.Writer 契约:成功时 n == len(b),否则调用方(log / io.MultiWriter)
|
||||
// 会认为发生了短写并把日志吞掉
|
||||
@@ -219,11 +246,14 @@ type cacheData struct {
|
||||
Channel string
|
||||
Event string
|
||||
Data []byte
|
||||
// FileOnly 只落文件、不写额外驱动(allLevelToInfo 的镜像任务用)
|
||||
FileOnly bool
|
||||
}
|
||||
|
||||
// toAsync 尝试异步写入
|
||||
// fileOnly 表示这条任务只落文件、不走驱动(见 writeFileOnly)
|
||||
// 返回 true 表示已经交给异步队列,返回 false 表示需要同步写入
|
||||
func (l *Logger) toAsync(event string, b []byte) bool {
|
||||
func (l *Logger) toAsync(event string, b []byte, fileOnly bool) bool {
|
||||
if l.writeType == writeTypeSync || // 指定同步模式
|
||||
(l.writeType == writeTypeDefault && l.option.writeType != writeTypeAsync) { // 默认同步模式
|
||||
return false
|
||||
@@ -259,7 +289,7 @@ func (l *Logger) toAsync(event string, b []byte) bool {
|
||||
data := make([]byte, len(b))
|
||||
copy(data, b)
|
||||
|
||||
ch <- cacheData{Channel: l.channel, Event: event, Data: data}
|
||||
ch <- cacheData{Channel: l.channel, Event: event, Data: data, FileOnly: fileOnly}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -285,6 +315,13 @@ func (l *Logger) consumeOne(val cacheData) {
|
||||
}
|
||||
}()
|
||||
// 按任务里记的 channel 落盘,避免多个 channel 的日志混到根目录
|
||||
if val.FileOnly {
|
||||
// 镜像只落文件;失败必须上报,否则异步模式下 info.log 写不进去是静默的
|
||||
if _, err := l.storeFileOnlyTo(val.Channel, val.Event, val.Data); err != nil {
|
||||
l.reportError(fmt.Errorf("loggerx: 写入 info 日志失败: %w", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
_, _ = l.storeTo(val.Channel, val.Event, val.Data)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user