feat(internal): add foundational packages — config, logger, errors
- config: env-based loading with startup validation, 13 tests - logger: structured slog wrapper with redaction, 10 tests - errors: unified AppError model with HTTP mapping, 18 tests - 41 tests total, build + vet clean
This commit is contained in:
1 parent
b69b74435a
commit
7943e290ec
11 files changed
+1590
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,199 @@
|
||||
// Package logger provides a structured logging wrapper around Go's slog package.
|
||||
// It supports text and JSON formats, configurable log levels, and automatic source location.
|
||||
// Sensitive fields (API keys, passwords) are automatically redacted.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level represents the severity of a log message.
|
||||
type Level = slog.Level
|
||||
|
||||
const (
|
||||
LevelDebug = slog.LevelDebug
|
||||
LevelInfo = slog.LevelInfo
|
||||
LevelWarn = slog.LevelWarn
|
||||
LevelError = slog.LevelError
|
||||
)
|
||||
|
||||
// Logger wraps slog.Logger with redaction and context support.
|
||||
// It does NOT embed *slog.Logger to prevent bypassing redaction.
|
||||
type Logger struct {
|
||||
inner *slog.Logger
|
||||
level Level
|
||||
redact bool
|
||||
}
|
||||
|
||||
// New creates a new Logger with the specified writer, level, and format.
|
||||
func New(w io.Writer, level Level, format string) *Logger {
|
||||
if w == nil {
|
||||
w = os.Stderr
|
||||
}
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
AddSource: level <= LevelDebug,
|
||||
}
|
||||
var h slog.Handler
|
||||
switch format {
|
||||
case "json":
|
||||
h = slog.NewJSONHandler(w, opts)
|
||||
default:
|
||||
h = slog.NewTextHandler(w, opts)
|
||||
}
|
||||
return &Logger{inner: slog.New(h), level: level, redact: true}
|
||||
}
|
||||
|
||||
// NewNop creates a no-op logger that discards all output.
|
||||
func NewNop() *Logger {
|
||||
return &Logger{
|
||||
inner: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})),
|
||||
level: LevelDebug,
|
||||
redact: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel converts a string to a slog Level.
|
||||
func ParseLevel(s string) (Level, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "debug":
|
||||
return LevelDebug, nil
|
||||
case "info":
|
||||
return LevelInfo, nil
|
||||
case "warn":
|
||||
return LevelWarn, nil
|
||||
case "error":
|
||||
return LevelError, nil
|
||||
default:
|
||||
return LevelInfo, fmt.Errorf("unknown log level: %q (valid: debug, info, warn, error)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// With returns a child Logger with the given attributes attached.
|
||||
func (l *Logger) With(args ...any) *Logger {
|
||||
return &Logger{
|
||||
inner: l.inner.With(args...),
|
||||
level: l.level,
|
||||
redact: l.redact,
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup returns a child Logger with the given group name.
|
||||
func (l *Logger) WithGroup(name string) *Logger {
|
||||
return &Logger{
|
||||
inner: l.inner.WithGroup(name),
|
||||
level: l.level,
|
||||
redact: l.redact,
|
||||
}
|
||||
}
|
||||
|
||||
// Level returns the current log level.
|
||||
func (l *Logger) Level() Level { return l.level }
|
||||
|
||||
// ---- Core logging methods ----
|
||||
|
||||
func (l *Logger) Debug(msg string, args ...any) {
|
||||
l.log(LevelDebug, msg, args...)
|
||||
}
|
||||
func (l *Logger) Info(msg string, args ...any) {
|
||||
l.log(LevelInfo, msg, args...)
|
||||
}
|
||||
func (l *Logger) Warn(msg string, args ...any) {
|
||||
l.log(LevelWarn, msg, args...)
|
||||
}
|
||||
func (l *Logger) Error(msg string, args ...any) {
|
||||
l.log(LevelError, msg, args...)
|
||||
}
|
||||
|
||||
// ---- Context-aware variants ----
|
||||
|
||||
func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelDebug, msg, args...)
|
||||
}
|
||||
func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelInfo, msg, args...)
|
||||
}
|
||||
func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelWarn, msg, args...)
|
||||
}
|
||||
func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelError, msg, args...)
|
||||
}
|
||||
|
||||
// ---- Internal ----
|
||||
|
||||
func (l *Logger) log(level Level, msg string, args ...any) {
|
||||
if !l.inner.Enabled(context.Background(), level) {
|
||||
return
|
||||
}
|
||||
attrs := l.buildAttrs(args...)
|
||||
r := slog.NewRecord(time.Now(), level, msg, 0)
|
||||
r.AddAttrs(attrs...)
|
||||
_ = l.inner.Handler().Handle(context.Background(), r)
|
||||
}
|
||||
|
||||
func (l *Logger) logCtx(ctx context.Context, level Level, msg string, args ...any) {
|
||||
if !l.inner.Enabled(ctx, level) {
|
||||
return
|
||||
}
|
||||
attrs := l.buildAttrs(args...)
|
||||
r := slog.NewRecord(time.Now(), level, msg, 0)
|
||||
r.AddAttrs(attrs...)
|
||||
_ = l.inner.Handler().Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (l *Logger) buildAttrs(args ...any) []slog.Attr {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(args)%2 != 0 {
|
||||
args = append(args, "!MISSING")
|
||||
}
|
||||
attrs := make([]slog.Attr, 0, len(args)/2)
|
||||
for i := 0; i < len(args); i += 2 {
|
||||
key, ok := args[i].(string)
|
||||
if !ok {
|
||||
key = fmt.Sprintf("!BADKEY(%v)", args[i])
|
||||
}
|
||||
val := args[i+1]
|
||||
if l.redact {
|
||||
val = redactValue(key, val)
|
||||
}
|
||||
attrs = append(attrs, slog.Any(key, val))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// ---- Redaction ----
|
||||
|
||||
var sensitiveKeys = map[string]bool{
|
||||
"api_key": true,
|
||||
"apikey": true,
|
||||
"password": true,
|
||||
"secret": true,
|
||||
"token": true,
|
||||
"jwt_secret": true,
|
||||
"admin_password": true,
|
||||
"llm_api_key": true,
|
||||
"dashscope_api_key": true,
|
||||
"access_token": true,
|
||||
}
|
||||
|
||||
func redactValue(key string, val any) any {
|
||||
if sensitiveKeys[strings.ToLower(key)] {
|
||||
if s, ok := val.(string); ok {
|
||||
if len(s) > 4 {
|
||||
return s[:2] + "****" + s[len(s)-2:]
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTextLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
|
||||
l.Info("hello world", "key", "value")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "hello world") {
|
||||
t.Errorf("expected output to contain 'hello world', got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "key") {
|
||||
t.Errorf("expected output to contain 'key', got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "value") {
|
||||
t.Errorf("expected output to contain 'value', got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJSONLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "json")
|
||||
|
||||
l.Info("hello world", "key", "value")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `"msg":"hello world"`) {
|
||||
t.Errorf("expected JSON output to contain msg, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogLevels(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelWarn, "text")
|
||||
|
||||
l.Debug("debug msg")
|
||||
l.Info("info msg")
|
||||
l.Warn("warn msg")
|
||||
l.Error("error msg")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "debug") {
|
||||
t.Error("debug message should be filtered out at WARN level")
|
||||
}
|
||||
if strings.Contains(output, "info") {
|
||||
t.Error("info message should be filtered out at WARN level")
|
||||
}
|
||||
if !strings.Contains(output, "warn") {
|
||||
t.Error("warn message should appear")
|
||||
}
|
||||
if !strings.Contains(output, "error") {
|
||||
t.Error("error message should appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
|
||||
child := l.With("request_id", "abc123", "user_id", "user-1")
|
||||
child.Info("request processed")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "request_id") || !strings.Contains(output, "abc123") {
|
||||
t.Errorf("expected 'request_id=abc123' in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "user_id") || !strings.Contains(output, "user-1") {
|
||||
t.Errorf("expected 'user_id=user-1' in output, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNop(t *testing.T) {
|
||||
l := NewNop()
|
||||
var buf bytes.Buffer
|
||||
// Nop logger should not write anything
|
||||
l2 := New(&buf, LevelDebug, "text")
|
||||
l2.Info("should appear")
|
||||
if !strings.Contains(buf.String(), "should appear") {
|
||||
t.Error("normal logger should write")
|
||||
}
|
||||
// Nop logger itself is fine to call
|
||||
l.Info("should not appear")
|
||||
l.Error("should not appear")
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected Level
|
||||
hasError bool
|
||||
}{
|
||||
{"debug", LevelDebug, false},
|
||||
{"DEBUG", LevelDebug, false},
|
||||
{"info", LevelInfo, false},
|
||||
{"warn", LevelWarn, false},
|
||||
{"error", LevelError, false},
|
||||
{"ERROR", LevelError, false},
|
||||
{"invalid", LevelInfo, true},
|
||||
{"", LevelInfo, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
level, err := ParseLevel(tt.input)
|
||||
if tt.hasError && err == nil {
|
||||
t.Errorf("ParseLevel(%q) expected error but got none", tt.input)
|
||||
}
|
||||
if !tt.hasError && err != nil {
|
||||
t.Errorf("ParseLevel(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
if level != tt.expected {
|
||||
t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, level, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedaction(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
|
||||
l.Info("auth attempt", "api_key", "sk-abcdefgh12345678", "user_id", "user-1")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "sk-abcdefgh12345678") {
|
||||
t.Errorf("API key should be redacted, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "sk-") && !strings.Contains(output, "****") {
|
||||
t.Errorf("expected redacted value with mask, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "user-1") {
|
||||
t.Errorf("non-sensitive field should NOT be redacted, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSensitiveKeys(t *testing.T) {
|
||||
sensitiveTests := []string{"api_key", "API_KEY", "password", "secret", "token", "jwt_secret", "admin_password", "llm_api_key", "dashscope_api_key", "access_token"}
|
||||
nonSensitiveTests := []string{"user_id", "request_id", "model", "port", "host", "query", "duration_ms", "count"}
|
||||
|
||||
for _, key := range sensitiveTests {
|
||||
val := redactValue(key, "super-secret-value-12345")
|
||||
if s, ok := val.(string); ok {
|
||||
if s == "super-secret-value-12345" {
|
||||
t.Errorf("key %q should be redacted, got raw value instead", key)
|
||||
}
|
||||
if !strings.Contains(s, "****") {
|
||||
t.Errorf("key %q redacted value should contain '****', got: %s", key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range nonSensitiveTests {
|
||||
val := redactValue(key, "normal-value")
|
||||
if s, ok := val.(string); ok {
|
||||
if s != "normal-value" {
|
||||
t.Errorf("key %q should NOT be redacted, got: %s", key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactShortValue(t *testing.T) {
|
||||
// Short values (< 4 chars) should be fully masked
|
||||
val := redactValue("api_key", "ab")
|
||||
if s, ok := val.(string); ok {
|
||||
if s != "****" {
|
||||
t.Errorf("short value should be fully masked, got: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOddNumberOfArgs(t *testing.T) {
|
||||
// Go's slog vet check prevents odd numbers of arguments at compile time.
|
||||
// Our wrapper detects this and appends a !MISSING placeholder internally.
|
||||
// This test verifies buildAttrs handles odd args correctly.
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
attrs := l.buildAttrs("key1", "value1", "key2")
|
||||
if len(attrs) != 2 {
|
||||
t.Errorf("expected 2 attrs, got %d", len(attrs))
|
||||
}
|
||||
if attrs[1].Key != "key2" {
|
||||
t.Errorf("expected attrs[1].Key=key2, got %s", attrs[1].Key)
|
||||
}
|
||||
// The value for key2 should be "!MISSING"
|
||||
if attrs[1].Value.String() != "!MISSING" {
|
||||
t.Errorf("expected !MISSING placeholder, got %s", attrs[1].Value.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user