From 0236010136f10df3a69144f4bbbedfe409277c24 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 04:13:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(logger):=20slog=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E6=97=A5=E5=BF=97=E5=B0=81=E8=A3=85=EF=BC=88text/json?= =?UTF-8?q?=20+=20=E7=BA=A7=E5=88=AB=E8=A7=A3=E6=9E=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logger/logger.go | 70 +++++++++++++++++++++++++ internal/logger/logger_test.go | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 internal/logger/logger.go create mode 100644 internal/logger/logger_test.go diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..a373f2b --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,70 @@ +// Package logger provides the project-wide structured logging facade built on +// log/slog. All modules must use this package instead of creating their own +// logger instances. Development builds render human-friendly TextHandler output; +// production builds render JSON for machine parsing. +package logger + +import ( + "io" + "log/slog" + "os" + "strings" +) + +// Format selects the structured log output encoding. +type Format string + +const ( + // FormatText is the human-readable development format. + FormatText Format = "text" + // FormatJSON is the machine-parseable production format. + FormatJSON Format = "json" +) + +// Options configures a Logger. +type Options struct { + // Level is the minimum level to record, one of debug, info, warn, error. + // Unknown values fall back to info. + Level string + // Format is the output encoding, one of text, json. Unknown values fall + // back to text. + Format string + // Out is the destination writer. Defaults to os.Stderr when nil. + Out io.Writer +} + +// New builds a slog.Logger from opts. +func New(opts Options) *slog.Logger { + level := ParseLevel(opts.Level) + out := opts.Out + if out == nil { + out = os.Stderr + } + + handlerOpts := &slog.HandlerOptions{Level: level} + var handler slog.Handler + if Format(opts.Format) == FormatJSON { + handler = slog.NewJSONHandler(out, handlerOpts) + } else { + handler = slog.NewTextHandler(out, handlerOpts) + } + + return slog.New(handler) +} + +// ParseLevel maps a string level name to a slog.Level. Unknown or empty values +// produce slog.LevelInfo. +func ParseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..5451a2f --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,93 @@ +package logger + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestParseLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + expected slog.Level + }{ + {name: "debug", in: "debug", expected: slog.LevelDebug}, + {name: "debug upper", in: "DEBUG", expected: slog.LevelDebug}, + {name: "info", in: "info", expected: slog.LevelInfo}, + {name: "warn", in: "warn", expected: slog.LevelWarn}, + {name: "warning alias", in: "warning", expected: slog.LevelWarn}, + {name: "error", in: "error", expected: slog.LevelError}, + {name: "empty defaults info", in: "", expected: slog.LevelInfo}, + {name: "unknown defaults info", in: "trace", expected: slog.LevelInfo}, + {name: "whitespace trimmed", in: " debug ", expected: slog.LevelDebug}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := ParseLevel(tt.in); got != tt.expected { + t.Fatalf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.expected) + } + }) + } +} + +func TestNew_TextFormat(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := New(Options{Level: "debug", Format: "text", Out: &buf}) + log.Info("hello") + + got := buf.String() + if !strings.Contains(got, "level=INFO") || !strings.Contains(got, "msg=hello") { + t.Fatalf("text output missing level/msg, got: %q", got) + } +} + +func TestNew_JSONFormat(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := New(Options{Level: "debug", Format: "json", Out: &buf}) + log.Warn("hello", "k", "v") + + got := buf.String() + if !strings.Contains(got, `"level":"WARN"`) || !strings.Contains(got, `"msg":"hello"`) || !strings.Contains(got, `"k":"v"`) { + t.Fatalf("json output missing fields, got: %q", got) + } +} + +func TestNew_LevelFiltersBelowConfigured(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + // Configured at error level: info/warn must not be emitted. + log := New(Options{Level: "error", Format: "json", Out: &buf}) + log.Info("low", "k", "v") + log.Warn("mid", "k", "v") + log.Error("high", "k", "v") + + got := buf.String() + if strings.Contains(got, "low") || strings.Contains(got, "mid") { + t.Fatalf("lower-than-configured levels leaked, got: %q", got) + } + if !strings.Contains(got, "high") { + t.Fatalf("configured level not emitted, got: %q", got) + } +} + +func TestNew_DefaultsToErrAndText(t *testing.T) { + t.Parallel() + + // No options should not panic and should produce a working logger. + log := New(Options{}) + if log == nil { + t.Fatal("New(Options{}) returned nil logger") + } +}