chore: initial public snapshot for github upload
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package logger
|
||||
|
||||
import "github.com/Wei-Shaw/sub2api/internal/config"
|
||||
|
||||
func OptionsFromConfig(cfg config.LogConfig) InitOptions {
|
||||
return InitOptions{
|
||||
Level: cfg.Level,
|
||||
Format: cfg.Format,
|
||||
ServiceName: cfg.ServiceName,
|
||||
Environment: cfg.Environment,
|
||||
Caller: cfg.Caller,
|
||||
StacktraceLevel: cfg.StacktraceLevel,
|
||||
Output: OutputOptions{
|
||||
ToStdout: cfg.Output.ToStdout,
|
||||
ToFile: cfg.Output.ToFile,
|
||||
FilePath: cfg.Output.FilePath,
|
||||
},
|
||||
Rotation: RotationOptions{
|
||||
MaxSizeMB: cfg.Rotation.MaxSizeMB,
|
||||
MaxBackups: cfg.Rotation.MaxBackups,
|
||||
MaxAgeDays: cfg.Rotation.MaxAgeDays,
|
||||
Compress: cfg.Rotation.Compress,
|
||||
LocalTime: cfg.Rotation.LocalTime,
|
||||
},
|
||||
Sampling: SamplingOptions{
|
||||
Enabled: cfg.Sampling.Enabled,
|
||||
Initial: cfg.Sampling.Initial,
|
||||
Thereafter: cfg.Sampling.Thereafter,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
type Level = zapcore.Level
|
||||
|
||||
const (
|
||||
LevelDebug = zapcore.DebugLevel
|
||||
LevelInfo = zapcore.InfoLevel
|
||||
LevelWarn = zapcore.WarnLevel
|
||||
LevelError = zapcore.ErrorLevel
|
||||
LevelFatal = zapcore.FatalLevel
|
||||
)
|
||||
|
||||
type Sink interface {
|
||||
WriteLogEvent(event *LogEvent)
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
Time time.Time
|
||||
Level string
|
||||
Component string
|
||||
Message string
|
||||
LoggerName string
|
||||
Fields map[string]any
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
global atomic.Pointer[zap.Logger]
|
||||
sugar atomic.Pointer[zap.SugaredLogger]
|
||||
atomicLevel zap.AtomicLevel
|
||||
initOptions InitOptions
|
||||
currentSink atomic.Value // sinkState
|
||||
stdLogUndo func()
|
||||
bootstrapOnce sync.Once
|
||||
)
|
||||
|
||||
type sinkState struct {
|
||||
sink Sink
|
||||
}
|
||||
|
||||
func InitBootstrap() {
|
||||
bootstrapOnce.Do(func() {
|
||||
if err := Init(bootstrapOptions()); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "logger bootstrap init failed: %v\n", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Init(options InitOptions) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return initLocked(options)
|
||||
}
|
||||
|
||||
func initLocked(options InitOptions) error {
|
||||
normalized := options.normalized()
|
||||
zl, al, err := buildLogger(normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prev := global.Load()
|
||||
global.Store(zl)
|
||||
sugar.Store(zl.Sugar())
|
||||
atomicLevel = al
|
||||
initOptions = normalized
|
||||
|
||||
bridgeSlogLocked()
|
||||
bridgeStdLogLocked()
|
||||
|
||||
if prev != nil {
|
||||
_ = prev.Sync()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Reconfigure(mutator func(*InitOptions) error) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
next := initOptions
|
||||
if mutator != nil {
|
||||
if err := mutator(&next); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return initLocked(next)
|
||||
}
|
||||
|
||||
func SetLevel(level string) error {
|
||||
lv, ok := parseLevel(level)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid log level: %s", level)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
atomicLevel.SetLevel(lv)
|
||||
initOptions.Level = strings.ToLower(strings.TrimSpace(level))
|
||||
return nil
|
||||
}
|
||||
|
||||
func CurrentLevel() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
if global.Load() == nil {
|
||||
return "info"
|
||||
}
|
||||
return atomicLevel.Level().String()
|
||||
}
|
||||
|
||||
func SetSink(sink Sink) {
|
||||
currentSink.Store(sinkState{sink: sink})
|
||||
}
|
||||
|
||||
func loadSink() Sink {
|
||||
v := currentSink.Load()
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
state, ok := v.(sinkState)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return state.sink
|
||||
}
|
||||
|
||||
// WriteSinkEvent 直接写入日志 sink,不经过全局日志级别门控。
|
||||
// 用于需要“可观测性入库”与“业务输出级别”解耦的场景(例如 ops 系统日志索引)。
|
||||
func WriteSinkEvent(level, component, message string, fields map[string]any) {
|
||||
sink := loadSink()
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
if level == "" {
|
||||
level = "info"
|
||||
}
|
||||
component = strings.TrimSpace(component)
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
|
||||
eventFields := make(map[string]any, len(fields)+1)
|
||||
for k, v := range fields {
|
||||
eventFields[k] = v
|
||||
}
|
||||
if component != "" {
|
||||
if _, ok := eventFields["component"]; !ok {
|
||||
eventFields["component"] = component
|
||||
}
|
||||
}
|
||||
|
||||
sink.WriteLogEvent(&LogEvent{
|
||||
Time: time.Now(),
|
||||
Level: level,
|
||||
Component: component,
|
||||
Message: message,
|
||||
LoggerName: component,
|
||||
Fields: eventFields,
|
||||
})
|
||||
}
|
||||
|
||||
func L() *zap.Logger {
|
||||
if l := global.Load(); l != nil {
|
||||
return l
|
||||
}
|
||||
return zap.NewNop()
|
||||
}
|
||||
|
||||
func S() *zap.SugaredLogger {
|
||||
if s := sugar.Load(); s != nil {
|
||||
return s
|
||||
}
|
||||
return zap.NewNop().Sugar()
|
||||
}
|
||||
|
||||
func With(fields ...zap.Field) *zap.Logger {
|
||||
return L().With(fields...)
|
||||
}
|
||||
|
||||
func Sync() {
|
||||
l := global.Load()
|
||||
if l != nil {
|
||||
_ = l.Sync()
|
||||
}
|
||||
}
|
||||
|
||||
func bridgeStdLogLocked() {
|
||||
if stdLogUndo != nil {
|
||||
stdLogUndo()
|
||||
stdLogUndo = nil
|
||||
}
|
||||
|
||||
prevFlags := log.Flags()
|
||||
prevPrefix := log.Prefix()
|
||||
prevWriter := log.Writer()
|
||||
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
base := global.Load()
|
||||
if base == nil {
|
||||
base = zap.NewNop()
|
||||
}
|
||||
log.SetOutput(newStdLogBridge(base.Named("stdlog")))
|
||||
|
||||
stdLogUndo = func() {
|
||||
log.SetOutput(prevWriter)
|
||||
log.SetFlags(prevFlags)
|
||||
log.SetPrefix(prevPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func bridgeSlogLocked() {
|
||||
base := global.Load()
|
||||
if base == nil {
|
||||
base = zap.NewNop()
|
||||
}
|
||||
slog.SetDefault(slog.New(newSlogZapHandler(base.Named("slog"))))
|
||||
}
|
||||
|
||||
func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, error) {
|
||||
level, _ := parseLevel(options.Level)
|
||||
atomic := zap.NewAtomicLevelAt(level)
|
||||
|
||||
encoderCfg := zapcore.EncoderConfig{
|
||||
TimeKey: "time",
|
||||
LevelKey: "level",
|
||||
NameKey: "logger",
|
||||
CallerKey: "caller",
|
||||
MessageKey: "msg",
|
||||
StacktraceKey: "stacktrace",
|
||||
LineEnding: zapcore.DefaultLineEnding,
|
||||
EncodeLevel: zapcore.CapitalLevelEncoder,
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||
EncodeDuration: zapcore.MillisDurationEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
}
|
||||
|
||||
var enc zapcore.Encoder
|
||||
if options.Format == "console" {
|
||||
enc = zapcore.NewConsoleEncoder(encoderCfg)
|
||||
} else {
|
||||
enc = zapcore.NewJSONEncoder(encoderCfg)
|
||||
}
|
||||
|
||||
sinkCore := newSinkCore()
|
||||
cores := make([]zapcore.Core, 0, 3)
|
||||
|
||||
if options.Output.ToStdout {
|
||||
infoPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||||
return lvl >= atomic.Level() && lvl < zapcore.WarnLevel
|
||||
})
|
||||
errPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||||
return lvl >= atomic.Level() && lvl >= zapcore.WarnLevel
|
||||
})
|
||||
cores = append(cores, zapcore.NewCore(enc, zapcore.Lock(os.Stdout), infoPriority))
|
||||
cores = append(cores, zapcore.NewCore(enc, zapcore.Lock(os.Stderr), errPriority))
|
||||
}
|
||||
|
||||
if options.Output.ToFile {
|
||||
fileCore, filePath, fileErr := buildFileCore(enc, atomic, options)
|
||||
if fileErr != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"日志文件输出初始化失败,降级为仅标准输出\" path=%s err=%v\n",
|
||||
time.Now().Format(time.RFC3339Nano),
|
||||
filePath,
|
||||
fileErr,
|
||||
)
|
||||
} else {
|
||||
cores = append(cores, fileCore)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cores) == 0 {
|
||||
cores = append(cores, zapcore.NewCore(enc, zapcore.Lock(os.Stdout), atomic))
|
||||
}
|
||||
|
||||
core := zapcore.NewTee(cores...)
|
||||
if options.Sampling.Enabled {
|
||||
core = zapcore.NewSamplerWithOptions(core, samplingTick(), options.Sampling.Initial, options.Sampling.Thereafter)
|
||||
}
|
||||
core = sinkCore.Wrap(core)
|
||||
|
||||
stacktraceLevel, _ := parseStacktraceLevel(options.StacktraceLevel)
|
||||
zapOpts := make([]zap.Option, 0, 5)
|
||||
if options.Caller {
|
||||
zapOpts = append(zapOpts, zap.AddCaller())
|
||||
}
|
||||
if stacktraceLevel <= zapcore.FatalLevel {
|
||||
zapOpts = append(zapOpts, zap.AddStacktrace(stacktraceLevel))
|
||||
}
|
||||
|
||||
logger := zap.New(core, zapOpts...).With(
|
||||
zap.String("service", options.ServiceName),
|
||||
zap.String("env", options.Environment),
|
||||
)
|
||||
return logger, atomic, nil
|
||||
}
|
||||
|
||||
func buildFileCore(enc zapcore.Encoder, atomic zap.AtomicLevel, options InitOptions) (zapcore.Core, string, error) {
|
||||
filePath := options.Output.FilePath
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
filePath = resolveLogFilePath("")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, filePath, err
|
||||
}
|
||||
lj := &lumberjack.Logger{
|
||||
Filename: filePath,
|
||||
MaxSize: options.Rotation.MaxSizeMB,
|
||||
MaxBackups: options.Rotation.MaxBackups,
|
||||
MaxAge: options.Rotation.MaxAgeDays,
|
||||
Compress: options.Rotation.Compress,
|
||||
LocalTime: options.Rotation.LocalTime,
|
||||
}
|
||||
return zapcore.NewCore(enc, zapcore.AddSync(lj), atomic), filePath, nil
|
||||
}
|
||||
|
||||
type sinkCore struct {
|
||||
core zapcore.Core
|
||||
fields []zapcore.Field
|
||||
}
|
||||
|
||||
func newSinkCore() *sinkCore {
|
||||
return &sinkCore{}
|
||||
}
|
||||
|
||||
func (s *sinkCore) Wrap(core zapcore.Core) zapcore.Core {
|
||||
cp := *s
|
||||
cp.core = core
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (s *sinkCore) Enabled(level zapcore.Level) bool {
|
||||
return s.core.Enabled(level)
|
||||
}
|
||||
|
||||
func (s *sinkCore) With(fields []zapcore.Field) zapcore.Core {
|
||||
nextFields := append([]zapcore.Field{}, s.fields...)
|
||||
nextFields = append(nextFields, fields...)
|
||||
return &sinkCore{
|
||||
core: s.core.With(fields),
|
||||
fields: nextFields,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sinkCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||
// Delegate to inner core (tee) so each sub-core's level enabler is respected.
|
||||
// Then add ourselves for sink forwarding only.
|
||||
ce = s.core.Check(entry, ce)
|
||||
if ce != nil {
|
||||
ce = ce.AddCore(entry, s)
|
||||
}
|
||||
return ce
|
||||
}
|
||||
|
||||
func (s *sinkCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
||||
// Only handle sink forwarding — the inner cores write via their own
|
||||
// Write methods (added to CheckedEntry by s.core.Check above).
|
||||
sink := loadSink()
|
||||
if sink == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enc := zapcore.NewMapObjectEncoder()
|
||||
for _, f := range s.fields {
|
||||
f.AddTo(enc)
|
||||
}
|
||||
for _, f := range fields {
|
||||
f.AddTo(enc)
|
||||
}
|
||||
|
||||
event := &LogEvent{
|
||||
Time: entry.Time,
|
||||
Level: strings.ToLower(entry.Level.String()),
|
||||
Component: entry.LoggerName,
|
||||
Message: entry.Message,
|
||||
LoggerName: entry.LoggerName,
|
||||
Fields: enc.Fields,
|
||||
}
|
||||
sink.WriteLogEvent(event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sinkCore) Sync() error {
|
||||
return s.core.Sync()
|
||||
}
|
||||
|
||||
type stdLogBridge struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func newStdLogBridge(l *zap.Logger) io.Writer {
|
||||
if l == nil {
|
||||
l = zap.NewNop()
|
||||
}
|
||||
return &stdLogBridge{logger: l}
|
||||
}
|
||||
|
||||
func (b *stdLogBridge) Write(p []byte) (int, error) {
|
||||
msg := normalizeStdLogMessage(string(p))
|
||||
if msg == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
level := inferStdLogLevel(msg)
|
||||
entry := b.logger.WithOptions(zap.AddCallerSkip(4))
|
||||
|
||||
switch level {
|
||||
case LevelDebug:
|
||||
entry.Debug(msg, zap.Bool("legacy_stdlog", true))
|
||||
case LevelWarn:
|
||||
entry.Warn(msg, zap.Bool("legacy_stdlog", true))
|
||||
case LevelError, LevelFatal:
|
||||
entry.Error(msg, zap.Bool("legacy_stdlog", true))
|
||||
default:
|
||||
entry.Info(msg, zap.Bool("legacy_stdlog", true))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func normalizeStdLogMessage(raw string) string {
|
||||
msg := strings.TrimSpace(strings.ReplaceAll(raw, "\n", " "))
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(strings.Fields(msg), " ")
|
||||
}
|
||||
|
||||
func inferStdLogLevel(msg string) Level {
|
||||
lower := strings.ToLower(strings.TrimSpace(msg))
|
||||
if lower == "" {
|
||||
return LevelInfo
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "[debug]") || strings.HasPrefix(lower, "debug:") {
|
||||
return LevelDebug
|
||||
}
|
||||
if strings.HasPrefix(lower, "[warn]") || strings.HasPrefix(lower, "[warning]") || strings.HasPrefix(lower, "warn:") || strings.HasPrefix(lower, "warning:") {
|
||||
return LevelWarn
|
||||
}
|
||||
if strings.HasPrefix(lower, "[error]") || strings.HasPrefix(lower, "error:") || strings.HasPrefix(lower, "fatal:") || strings.HasPrefix(lower, "panic:") {
|
||||
return LevelError
|
||||
}
|
||||
|
||||
if strings.Contains(lower, " failed") || strings.Contains(lower, "error") || strings.Contains(lower, "panic") || strings.Contains(lower, "fatal") {
|
||||
return LevelError
|
||||
}
|
||||
if strings.Contains(lower, "warning") || strings.Contains(lower, "warn") || strings.Contains(lower, " queue full") || strings.Contains(lower, "fallback") {
|
||||
return LevelWarn
|
||||
}
|
||||
return LevelInfo
|
||||
}
|
||||
|
||||
// LegacyPrintf 用于平滑迁移历史的 printf 风格日志到结构化 logger。
|
||||
func LegacyPrintf(component, format string, args ...any) {
|
||||
msg := normalizeStdLogMessage(fmt.Sprintf(format, args...))
|
||||
if msg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
initialized := global.Load() != nil
|
||||
if !initialized {
|
||||
// 在日志系统未初始化前,回退到标准库 log,避免测试/工具链丢日志。
|
||||
log.Print(msg)
|
||||
return
|
||||
}
|
||||
|
||||
l := L()
|
||||
if component != "" {
|
||||
l = l.With(zap.String("component", component))
|
||||
}
|
||||
l = l.WithOptions(zap.AddCallerSkip(1))
|
||||
|
||||
switch inferStdLogLevel(msg) {
|
||||
case LevelDebug:
|
||||
l.Debug(msg, zap.Bool("legacy_printf", true))
|
||||
case LevelWarn:
|
||||
l.Warn(msg, zap.Bool("legacy_printf", true))
|
||||
case LevelError, LevelFatal:
|
||||
l.Error(msg, zap.Bool("legacy_printf", true))
|
||||
default:
|
||||
l.Info(msg, zap.Bool("legacy_printf", true))
|
||||
}
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const loggerContextKey contextKey = "ctx_logger"
|
||||
|
||||
func IntoContext(ctx context.Context, l *zap.Logger) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if l == nil {
|
||||
l = L()
|
||||
}
|
||||
return context.WithValue(ctx, loggerContextKey, l)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *zap.Logger {
|
||||
if ctx == nil {
|
||||
return L()
|
||||
}
|
||||
if l, ok := ctx.Value(loggerContextKey).(*zap.Logger); ok && l != nil {
|
||||
return l
|
||||
}
|
||||
return L()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInit_DualOutput(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logPath := filepath.Join(tmpDir, "logs", "sub2api.log")
|
||||
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout pipe: %v", err)
|
||||
}
|
||||
stderrR, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stderr pipe: %v", err)
|
||||
}
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
_ = stdoutR.Close()
|
||||
_ = stderrR.Close()
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
|
||||
err = Init(InitOptions{
|
||||
Level: "debug",
|
||||
Format: "json",
|
||||
ServiceName: "sub2api",
|
||||
Environment: "test",
|
||||
Output: OutputOptions{
|
||||
ToStdout: true,
|
||||
ToFile: true,
|
||||
FilePath: logPath,
|
||||
},
|
||||
Rotation: RotationOptions{
|
||||
MaxSizeMB: 10,
|
||||
MaxBackups: 2,
|
||||
MaxAgeDays: 1,
|
||||
},
|
||||
Sampling: SamplingOptions{Enabled: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Init() error: %v", err)
|
||||
}
|
||||
|
||||
L().Info("dual-output-info")
|
||||
L().Warn("dual-output-warn")
|
||||
Sync()
|
||||
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
stdoutBytes, _ := io.ReadAll(stdoutR)
|
||||
stderrBytes, _ := io.ReadAll(stderrR)
|
||||
stdoutText := string(stdoutBytes)
|
||||
stderrText := string(stderrBytes)
|
||||
|
||||
if !strings.Contains(stdoutText, "dual-output-info") {
|
||||
t.Fatalf("stdout missing info log: %s", stdoutText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "dual-output-warn") {
|
||||
t.Fatalf("stderr missing warn log: %s", stderrText)
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read log file: %v", err)
|
||||
}
|
||||
fileText := string(fileBytes)
|
||||
if !strings.Contains(fileText, "dual-output-info") || !strings.Contains(fileText, "dual-output-warn") {
|
||||
t.Fatalf("file missing logs: %s", fileText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_FileOutputFailureDowngrade(t *testing.T) {
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
_, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout pipe: %v", err)
|
||||
}
|
||||
stderrR, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stderr pipe: %v", err)
|
||||
}
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrR.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
|
||||
err = Init(InitOptions{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
Output: OutputOptions{
|
||||
ToStdout: true,
|
||||
ToFile: true,
|
||||
FilePath: filepath.Join(os.DevNull, "logs", "sub2api.log"),
|
||||
},
|
||||
Rotation: RotationOptions{
|
||||
MaxSizeMB: 10,
|
||||
MaxBackups: 1,
|
||||
MaxAgeDays: 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Init() should downgrade instead of failing, got: %v", err)
|
||||
}
|
||||
|
||||
_ = stderrW.Close()
|
||||
stderrBytes, _ := io.ReadAll(stderrR)
|
||||
if !strings.Contains(string(stderrBytes), "日志文件输出初始化失败") {
|
||||
t.Fatalf("stderr should contain fallback warning, got: %s", string(stderrBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_CallerShouldPointToCallsite(t *testing.T) {
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout pipe: %v", err)
|
||||
}
|
||||
_, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stderr pipe: %v", err)
|
||||
}
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
_ = stdoutR.Close()
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
|
||||
if err := Init(InitOptions{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
ServiceName: "sub2api",
|
||||
Environment: "test",
|
||||
Caller: true,
|
||||
Output: OutputOptions{
|
||||
ToStdout: true,
|
||||
ToFile: false,
|
||||
},
|
||||
Sampling: SamplingOptions{Enabled: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("Init() error: %v", err)
|
||||
}
|
||||
|
||||
L().Info("caller-check")
|
||||
Sync()
|
||||
_ = stdoutW.Close()
|
||||
logBytes, _ := io.ReadAll(stdoutR)
|
||||
|
||||
var line string
|
||||
for _, item := range strings.Split(string(logBytes), "\n") {
|
||||
if strings.Contains(item, "caller-check") {
|
||||
line = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if line == "" {
|
||||
t.Fatalf("log output missing caller-check: %s", string(logBytes))
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &payload); err != nil {
|
||||
t.Fatalf("parse log json failed: %v, line=%s", err, line)
|
||||
}
|
||||
caller, _ := payload["caller"].(string)
|
||||
if !strings.Contains(caller, "logger_test.go:") {
|
||||
t.Fatalf("caller should point to this test file, got: %s", caller)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultContainerLogPath 为容器内默认日志文件路径。
|
||||
DefaultContainerLogPath = "/app/data/logs/sub2api.log"
|
||||
defaultLogFilename = "sub2api.log"
|
||||
)
|
||||
|
||||
type InitOptions struct {
|
||||
Level string
|
||||
Format string
|
||||
ServiceName string
|
||||
Environment string
|
||||
Caller bool
|
||||
StacktraceLevel string
|
||||
Output OutputOptions
|
||||
Rotation RotationOptions
|
||||
Sampling SamplingOptions
|
||||
}
|
||||
|
||||
type OutputOptions struct {
|
||||
ToStdout bool
|
||||
ToFile bool
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type RotationOptions struct {
|
||||
MaxSizeMB int
|
||||
MaxBackups int
|
||||
MaxAgeDays int
|
||||
Compress bool
|
||||
LocalTime bool
|
||||
}
|
||||
|
||||
type SamplingOptions struct {
|
||||
Enabled bool
|
||||
Initial int
|
||||
Thereafter int
|
||||
}
|
||||
|
||||
func (o InitOptions) normalized() InitOptions {
|
||||
out := o
|
||||
out.Level = strings.ToLower(strings.TrimSpace(out.Level))
|
||||
if out.Level == "" {
|
||||
out.Level = "info"
|
||||
}
|
||||
out.Format = strings.ToLower(strings.TrimSpace(out.Format))
|
||||
if out.Format == "" {
|
||||
out.Format = "console"
|
||||
}
|
||||
out.ServiceName = strings.TrimSpace(out.ServiceName)
|
||||
if out.ServiceName == "" {
|
||||
out.ServiceName = "sub2api"
|
||||
}
|
||||
out.Environment = strings.TrimSpace(out.Environment)
|
||||
if out.Environment == "" {
|
||||
out.Environment = "production"
|
||||
}
|
||||
out.StacktraceLevel = strings.ToLower(strings.TrimSpace(out.StacktraceLevel))
|
||||
if out.StacktraceLevel == "" {
|
||||
out.StacktraceLevel = "error"
|
||||
}
|
||||
if !out.Output.ToStdout && !out.Output.ToFile {
|
||||
out.Output.ToStdout = true
|
||||
}
|
||||
out.Output.FilePath = resolveLogFilePath(out.Output.FilePath)
|
||||
if out.Rotation.MaxSizeMB <= 0 {
|
||||
out.Rotation.MaxSizeMB = 100
|
||||
}
|
||||
if out.Rotation.MaxBackups < 0 {
|
||||
out.Rotation.MaxBackups = 10
|
||||
}
|
||||
if out.Rotation.MaxAgeDays < 0 {
|
||||
out.Rotation.MaxAgeDays = 7
|
||||
}
|
||||
if out.Sampling.Enabled {
|
||||
if out.Sampling.Initial <= 0 {
|
||||
out.Sampling.Initial = 100
|
||||
}
|
||||
if out.Sampling.Thereafter <= 0 {
|
||||
out.Sampling.Thereafter = 100
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveLogFilePath(explicit string) string {
|
||||
explicit = strings.TrimSpace(explicit)
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
dataDir := strings.TrimSpace(os.Getenv("DATA_DIR"))
|
||||
if dataDir != "" {
|
||||
return filepath.Join(dataDir, "logs", defaultLogFilename)
|
||||
}
|
||||
return DefaultContainerLogPath
|
||||
}
|
||||
|
||||
func bootstrapOptions() InitOptions {
|
||||
return InitOptions{
|
||||
Level: "info",
|
||||
Format: "console",
|
||||
ServiceName: "sub2api",
|
||||
Environment: "bootstrap",
|
||||
Output: OutputOptions{
|
||||
ToStdout: true,
|
||||
ToFile: false,
|
||||
},
|
||||
Rotation: RotationOptions{
|
||||
MaxSizeMB: 100,
|
||||
MaxBackups: 10,
|
||||
MaxAgeDays: 7,
|
||||
Compress: true,
|
||||
LocalTime: true,
|
||||
},
|
||||
Sampling: SamplingOptions{
|
||||
Enabled: false,
|
||||
Initial: 100,
|
||||
Thereafter: 100,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseLevel(level string) (Level, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "debug":
|
||||
return LevelDebug, true
|
||||
case "info":
|
||||
return LevelInfo, true
|
||||
case "warn":
|
||||
return LevelWarn, true
|
||||
case "error":
|
||||
return LevelError, true
|
||||
default:
|
||||
return LevelInfo, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseStacktraceLevel(level string) (Level, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "none":
|
||||
return LevelFatal + 1, true
|
||||
case "error":
|
||||
return LevelError, true
|
||||
case "fatal":
|
||||
return LevelFatal, true
|
||||
default:
|
||||
return LevelError, false
|
||||
}
|
||||
}
|
||||
|
||||
func samplingTick() time.Duration {
|
||||
return time.Second
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func TestResolveLogFilePath_Default(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", "")
|
||||
got := resolveLogFilePath("")
|
||||
if got != DefaultContainerLogPath {
|
||||
t.Fatalf("resolveLogFilePath() = %q, want %q", got, DefaultContainerLogPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLogFilePath_WithDataDir(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", "/tmp/sub2api-data")
|
||||
got := resolveLogFilePath("")
|
||||
want := filepath.Join("/tmp/sub2api-data", "logs", "sub2api.log")
|
||||
if got != want {
|
||||
t.Fatalf("resolveLogFilePath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLogFilePath_ExplicitPath(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", "/tmp/ignore")
|
||||
got := resolveLogFilePath("/var/log/custom.log")
|
||||
if got != "/var/log/custom.log" {
|
||||
t.Fatalf("resolveLogFilePath() = %q, want explicit path", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizedOptions_InvalidFallback(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", "")
|
||||
opts := InitOptions{
|
||||
Level: "TRACE",
|
||||
Format: "TEXT",
|
||||
ServiceName: "",
|
||||
Environment: "",
|
||||
StacktraceLevel: "panic",
|
||||
Output: OutputOptions{
|
||||
ToStdout: false,
|
||||
ToFile: false,
|
||||
},
|
||||
Rotation: RotationOptions{
|
||||
MaxSizeMB: 0,
|
||||
MaxBackups: -1,
|
||||
MaxAgeDays: -1,
|
||||
},
|
||||
Sampling: SamplingOptions{
|
||||
Enabled: true,
|
||||
Initial: 0,
|
||||
Thereafter: 0,
|
||||
},
|
||||
}
|
||||
out := opts.normalized()
|
||||
if out.Level != "trace" {
|
||||
// normalized 仅做 trim/lower,不做校验;校验在 config 层。
|
||||
t.Fatalf("normalized level should preserve value for upstream validation, got %q", out.Level)
|
||||
}
|
||||
if !out.Output.ToStdout {
|
||||
t.Fatalf("normalized output should fallback to stdout")
|
||||
}
|
||||
if out.Output.FilePath != DefaultContainerLogPath {
|
||||
t.Fatalf("normalized file path = %q", out.Output.FilePath)
|
||||
}
|
||||
if out.Rotation.MaxSizeMB != 100 {
|
||||
t.Fatalf("normalized max_size_mb = %d", out.Rotation.MaxSizeMB)
|
||||
}
|
||||
if out.Rotation.MaxBackups != 10 {
|
||||
t.Fatalf("normalized max_backups = %d", out.Rotation.MaxBackups)
|
||||
}
|
||||
if out.Rotation.MaxAgeDays != 7 {
|
||||
t.Fatalf("normalized max_age_days = %d", out.Rotation.MaxAgeDays)
|
||||
}
|
||||
if out.Sampling.Initial != 100 || out.Sampling.Thereafter != 100 {
|
||||
t.Fatalf("normalized sampling defaults invalid: %+v", out.Sampling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileCore_InvalidPathFallback(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", "")
|
||||
opts := bootstrapOptions()
|
||||
opts.Output.ToFile = true
|
||||
opts.Output.FilePath = filepath.Join(os.DevNull, "logs", "sub2api.log")
|
||||
encoderCfg := zapcore.EncoderConfig{
|
||||
TimeKey: "time",
|
||||
LevelKey: "level",
|
||||
MessageKey: "msg",
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||
EncodeLevel: zapcore.CapitalLevelEncoder,
|
||||
}
|
||||
encoder := zapcore.NewJSONEncoder(encoderCfg)
|
||||
_, _, err := buildFileCore(encoder, zap.NewAtomicLevel(), opts)
|
||||
if err == nil {
|
||||
t.Fatalf("buildFileCore() expected error for invalid path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
type slogZapHandler struct {
|
||||
logger *zap.Logger
|
||||
attrs []slog.Attr
|
||||
groups []string
|
||||
}
|
||||
|
||||
func newSlogZapHandler(logger *zap.Logger) slog.Handler {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &slogZapHandler{
|
||||
logger: logger,
|
||||
attrs: make([]slog.Attr, 0, 8),
|
||||
groups: make([]string, 0, 4),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *slogZapHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
switch {
|
||||
case level >= slog.LevelError:
|
||||
return h.logger.Core().Enabled(LevelError)
|
||||
case level >= slog.LevelWarn:
|
||||
return h.logger.Core().Enabled(LevelWarn)
|
||||
case level <= slog.LevelDebug:
|
||||
return h.logger.Core().Enabled(LevelDebug)
|
||||
default:
|
||||
return h.logger.Core().Enabled(LevelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *slogZapHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
fields := make([]zap.Field, 0, len(h.attrs)+record.NumAttrs()+3)
|
||||
fields = append(fields, slogAttrsToZapFields(h.groups, h.attrs)...)
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
fields = append(fields, slogAttrToZapField(h.groups, attr))
|
||||
return true
|
||||
})
|
||||
|
||||
switch {
|
||||
case record.Level >= slog.LevelError:
|
||||
h.logger.Error(record.Message, fields...)
|
||||
case record.Level >= slog.LevelWarn:
|
||||
h.logger.Warn(record.Message, fields...)
|
||||
case record.Level <= slog.LevelDebug:
|
||||
h.logger.Debug(record.Message, fields...)
|
||||
default:
|
||||
h.logger.Info(record.Message, fields...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *slogZapHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
next := *h
|
||||
next.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...)
|
||||
return &next
|
||||
}
|
||||
|
||||
func (h *slogZapHandler) WithGroup(name string) slog.Handler {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
next := *h
|
||||
next.groups = append(append([]string{}, h.groups...), name)
|
||||
return &next
|
||||
}
|
||||
|
||||
func slogAttrsToZapFields(groups []string, attrs []slog.Attr) []zap.Field {
|
||||
fields := make([]zap.Field, 0, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
fields = append(fields, slogAttrToZapField(groups, attr))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func slogAttrToZapField(groups []string, attr slog.Attr) zap.Field {
|
||||
if len(groups) > 0 {
|
||||
attr.Key = strings.Join(append(append([]string{}, groups...), attr.Key), ".")
|
||||
}
|
||||
value := attr.Value.Resolve()
|
||||
switch value.Kind() {
|
||||
case slog.KindBool:
|
||||
return zap.Bool(attr.Key, value.Bool())
|
||||
case slog.KindInt64:
|
||||
return zap.Int64(attr.Key, value.Int64())
|
||||
case slog.KindUint64:
|
||||
return zap.Uint64(attr.Key, value.Uint64())
|
||||
case slog.KindFloat64:
|
||||
return zap.Float64(attr.Key, value.Float64())
|
||||
case slog.KindDuration:
|
||||
return zap.Duration(attr.Key, value.Duration())
|
||||
case slog.KindTime:
|
||||
return zap.Time(attr.Key, value.Time())
|
||||
case slog.KindString:
|
||||
return zap.String(attr.Key, value.String())
|
||||
case slog.KindGroup:
|
||||
groupFields := make([]zap.Field, 0, len(value.Group()))
|
||||
for _, nested := range value.Group() {
|
||||
groupFields = append(groupFields, slogAttrToZapField(nil, nested))
|
||||
}
|
||||
return zap.Object(attr.Key, zapObjectFields(groupFields))
|
||||
case slog.KindAny:
|
||||
if t, ok := value.Any().(time.Time); ok {
|
||||
return zap.Time(attr.Key, t)
|
||||
}
|
||||
return zap.Any(attr.Key, value.Any())
|
||||
default:
|
||||
return zap.String(attr.Key, value.String())
|
||||
}
|
||||
}
|
||||
|
||||
type zapObjectFields []zap.Field
|
||||
|
||||
func (z zapObjectFields) MarshalLogObject(enc zapcore.ObjectEncoder) error {
|
||||
for _, field := range z {
|
||||
field.AddTo(enc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
type captureState struct {
|
||||
writes []capturedWrite
|
||||
}
|
||||
|
||||
type capturedWrite struct {
|
||||
fields []zapcore.Field
|
||||
}
|
||||
|
||||
type captureCore struct {
|
||||
state *captureState
|
||||
withFields []zapcore.Field
|
||||
}
|
||||
|
||||
func newCaptureCore() *captureCore {
|
||||
return &captureCore{state: &captureState{}}
|
||||
}
|
||||
|
||||
func (c *captureCore) Enabled(zapcore.Level) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *captureCore) With(fields []zapcore.Field) zapcore.Core {
|
||||
nextFields := make([]zapcore.Field, 0, len(c.withFields)+len(fields))
|
||||
nextFields = append(nextFields, c.withFields...)
|
||||
nextFields = append(nextFields, fields...)
|
||||
return &captureCore{
|
||||
state: c.state,
|
||||
withFields: nextFields,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *captureCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||
return ce.AddCore(entry, c)
|
||||
}
|
||||
|
||||
func (c *captureCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
||||
allFields := make([]zapcore.Field, 0, len(c.withFields)+len(fields))
|
||||
allFields = append(allFields, c.withFields...)
|
||||
allFields = append(allFields, fields...)
|
||||
c.state.writes = append(c.state.writes, capturedWrite{
|
||||
fields: allFields,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureCore) Sync() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSlogZapHandler_Handle_DoesNotAppendTimeField(t *testing.T) {
|
||||
core := newCaptureCore()
|
||||
handler := newSlogZapHandler(zap.New(core))
|
||||
|
||||
record := slog.NewRecord(time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), slog.LevelInfo, "hello", 0)
|
||||
record.AddAttrs(slog.String("component", "http.access"))
|
||||
|
||||
if err := handler.Handle(context.Background(), record); err != nil {
|
||||
t.Fatalf("handle slog record: %v", err)
|
||||
}
|
||||
if len(core.state.writes) != 1 {
|
||||
t.Fatalf("write calls = %d, want 1", len(core.state.writes))
|
||||
}
|
||||
|
||||
var hasComponent bool
|
||||
for _, field := range core.state.writes[0].fields {
|
||||
if field.Key == "time" {
|
||||
t.Fatalf("unexpected duplicate time field in slog adapter output")
|
||||
}
|
||||
if field.Key == "component" {
|
||||
hasComponent = true
|
||||
}
|
||||
}
|
||||
if !hasComponent {
|
||||
t.Fatalf("component field should be preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInferStdLogLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want Level
|
||||
}{
|
||||
{msg: "Warning: queue full", want: LevelWarn},
|
||||
{msg: "Forward request failed: timeout", want: LevelError},
|
||||
{msg: "[ERROR] upstream unavailable", want: LevelError},
|
||||
{msg: "[OpenAI WS Mode] reconnect_retry account_id=22 retry=1 max_retries=5", want: LevelInfo},
|
||||
{msg: "service started", want: LevelInfo},
|
||||
{msg: "debug: cache miss", want: LevelDebug},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := inferStdLogLevel(tc.msg)
|
||||
if got != tc.want {
|
||||
t.Fatalf("inferStdLogLevel(%q)=%v want=%v", tc.msg, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStdLogMessage(t *testing.T) {
|
||||
raw := " [TokenRefresh] cycle complete \n total=1 failed=0 \n"
|
||||
got := normalizeStdLogMessage(raw)
|
||||
want := "[TokenRefresh] cycle complete total=1 failed=0"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeStdLogMessage()=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdLogBridgeRoutesLevels(t *testing.T) {
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout pipe: %v", err)
|
||||
}
|
||||
stderrR, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stderr pipe: %v", err)
|
||||
}
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
_ = stdoutR.Close()
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrR.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
|
||||
if err := Init(InitOptions{
|
||||
Level: "debug",
|
||||
Format: "json",
|
||||
ServiceName: "sub2api",
|
||||
Environment: "test",
|
||||
Output: OutputOptions{
|
||||
ToStdout: true,
|
||||
ToFile: false,
|
||||
},
|
||||
Sampling: SamplingOptions{Enabled: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("Init() error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("service started")
|
||||
log.Printf("Warning: queue full")
|
||||
log.Printf("Forward request failed: timeout")
|
||||
Sync()
|
||||
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
stdoutBytes, _ := io.ReadAll(stdoutR)
|
||||
stderrBytes, _ := io.ReadAll(stderrR)
|
||||
stdoutText := string(stdoutBytes)
|
||||
stderrText := string(stderrBytes)
|
||||
|
||||
if !strings.Contains(stdoutText, "service started") {
|
||||
t.Fatalf("stdout missing info log: %s", stdoutText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "Warning: queue full") {
|
||||
t.Fatalf("stderr missing warn log: %s", stderrText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "Forward request failed: timeout") {
|
||||
t.Fatalf("stderr missing error log: %s", stderrText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "\"legacy_stdlog\":true") {
|
||||
t.Fatalf("stderr missing legacy_stdlog marker: %s", stderrText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPrintfRoutesLevels(t *testing.T) {
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout pipe: %v", err)
|
||||
}
|
||||
stderrR, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stderr pipe: %v", err)
|
||||
}
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
_ = stdoutR.Close()
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrR.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
|
||||
if err := Init(InitOptions{
|
||||
Level: "debug",
|
||||
Format: "json",
|
||||
ServiceName: "sub2api",
|
||||
Environment: "test",
|
||||
Output: OutputOptions{
|
||||
ToStdout: true,
|
||||
ToFile: false,
|
||||
},
|
||||
Sampling: SamplingOptions{Enabled: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("Init() error: %v", err)
|
||||
}
|
||||
|
||||
LegacyPrintf("service.test", "request started")
|
||||
LegacyPrintf("service.test", "Warning: queue full")
|
||||
LegacyPrintf("service.test", "forward failed: timeout")
|
||||
Sync()
|
||||
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
stdoutBytes, _ := io.ReadAll(stdoutR)
|
||||
stderrBytes, _ := io.ReadAll(stderrR)
|
||||
stdoutText := string(stdoutBytes)
|
||||
stderrText := string(stderrBytes)
|
||||
|
||||
if !strings.Contains(stdoutText, "request started") {
|
||||
t.Fatalf("stdout missing info log: %s", stdoutText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "Warning: queue full") {
|
||||
t.Fatalf("stderr missing warn log: %s", stderrText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "forward failed: timeout") {
|
||||
t.Fatalf("stderr missing error log: %s", stderrText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "\"legacy_printf\":true") {
|
||||
t.Fatalf("stderr missing legacy_printf marker: %s", stderrText)
|
||||
}
|
||||
if !strings.Contains(stderrText, "\"component\":\"service.test\"") {
|
||||
t.Fatalf("stderr missing component field: %s", stderrText)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user