diff --git a/bench_test.go b/bench_test.go index 237baf7..95f820c 100644 --- a/bench_test.go +++ b/bench_test.go @@ -3,19 +3,11 @@ package log_test import ( "context" "io" - stdlogpkg "log" "log/slog" - "os" "testing" "github.com/go-coldbrew/log" "github.com/go-coldbrew/log/loggers" - "github.com/go-coldbrew/log/loggers/gokit" - "github.com/go-coldbrew/log/loggers/logrus" - cbslog "github.com/go-coldbrew/log/loggers/slog" - "github.com/go-coldbrew/log/loggers/stdlog" - "github.com/go-coldbrew/log/loggers/zap" - "github.com/go-coldbrew/log/wrap" ) // Common options: JSON output, no caller info (to isolate serialization cost). @@ -30,61 +22,18 @@ var ( } ) -// discardStdout redirects os.Stdout to /dev/null for the duration of a -// benchmark. This is necessary for backends (gokit, zap, logrus, stdlog) -// that unconditionally write to os.Stdout. -func discardStdout(b *testing.B) { +func setupHandler(b *testing.B, inner slog.Handler, opts ...loggers.Option) { b.Helper() - devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) - if err != nil { - b.Fatalf("failed to open /dev/null: %v", err) - } - origStdout := os.Stdout - os.Stdout = devNull - b.Cleanup(func() { - os.Stdout = origStdout - devNull.Close() - }) -} - -func setupLogger(b *testing.B, backend loggers.BaseLogger) { - b.Helper() - log.SetLogger(log.NewLogger(backend)) + h := log.NewHandlerWithInner(inner, opts...) + log.SetDefault(h) b.ResetTimer() } -// --- Backend benchmarks: log.Info() with each backend --- - -func BenchmarkBackend_Gokit_JSON(b *testing.B) { - discardStdout(b) - setupLogger(b, gokit.NewLogger(jsonNoCaller...)) - ctx := context.Background() - for b.Loop() { - log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) - } -} - -func BenchmarkBackend_Zap_JSON(b *testing.B) { - discardStdout(b) - setupLogger(b, zap.NewLogger(jsonNoCaller...)) - ctx := context.Background() - for b.Loop() { - log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) - } -} - -func BenchmarkBackend_Logrus_JSON(b *testing.B) { - discardStdout(b) - setupLogger(b, logrus.NewLogger(jsonNoCaller...)) - ctx := context.Background() - for b.Loop() { - log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) - } -} +// --- Backend benchmarks: log.Info() with ColdBrew Handler --- func BenchmarkBackend_Slog_JSON(b *testing.B) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - setupLogger(b, cbslog.NewLoggerWithHandler(handler, jsonNoCaller...)) + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + setupHandler(b, inner, jsonNoCaller...) ctx := context.Background() for b.Loop() { log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) @@ -92,20 +41,8 @@ func BenchmarkBackend_Slog_JSON(b *testing.B) { } func BenchmarkBackend_Slog_Text(b *testing.B) { - handler := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - setupLogger(b, cbslog.NewLoggerWithHandler(handler, loggers.WithJSONLogs(false), loggers.WithCallerInfo(false))) - ctx := context.Background() - for b.Loop() { - log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) - } -} - -func BenchmarkBackend_Stdlog(b *testing.B) { - discardStdout(b) - origWriter := stdlogpkg.Writer() - stdlogpkg.SetOutput(io.Discard) - b.Cleanup(func() { stdlogpkg.SetOutput(origWriter) }) - setupLogger(b, stdlog.NewLogger()) + inner := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + setupHandler(b, inner, loggers.WithJSONLogs(false), loggers.WithCallerInfo(false)) ctx := context.Background() for b.Loop() { log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) @@ -114,18 +51,9 @@ func BenchmarkBackend_Stdlog(b *testing.B) { // --- Backend benchmarks with caller info --- -func BenchmarkBackend_Gokit_JSON_Caller(b *testing.B) { - discardStdout(b) - setupLogger(b, gokit.NewLogger(jsonWithCaller...)) - ctx := context.Background() - for b.Loop() { - log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) - } -} - func BenchmarkBackend_Slog_JSON_Caller(b *testing.B) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - setupLogger(b, cbslog.NewLoggerWithHandler(handler, jsonWithCaller...)) + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + setupHandler(b, inner, jsonWithCaller...) ctx := context.Background() for b.Loop() { log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) @@ -134,33 +62,9 @@ func BenchmarkBackend_Slog_JSON_Caller(b *testing.B) { // --- Backend benchmarks with context fields --- -func BenchmarkBackend_Gokit_JSON_CtxFields(b *testing.B) { - discardStdout(b) - setupLogger(b, gokit.NewLogger(jsonNoCaller...)) - ctx := context.Background() - ctx = loggers.AddToLogContext(ctx, "trace_id", "abc-123") - ctx = loggers.AddToLogContext(ctx, "service", "bench-svc") - ctx = loggers.AddToLogContext(ctx, "request_id", "req-456") - for b.Loop() { - log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) - } -} - func BenchmarkBackend_Slog_JSON_CtxFields(b *testing.B) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - setupLogger(b, cbslog.NewLoggerWithHandler(handler, jsonNoCaller...)) - ctx := context.Background() - ctx = loggers.AddToLogContext(ctx, "trace_id", "abc-123") - ctx = loggers.AddToLogContext(ctx, "service", "bench-svc") - ctx = loggers.AddToLogContext(ctx, "request_id", "req-456") - for b.Loop() { - log.Info(ctx, "msg", "benchmark message", "key1", "value1", "key2", 42) - } -} - -func BenchmarkBackend_Zap_JSON_CtxFields(b *testing.B) { - discardStdout(b) - setupLogger(b, zap.NewLogger(jsonNoCaller...)) + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + setupHandler(b, inner, jsonNoCaller...) ctx := context.Background() ctx = loggers.AddToLogContext(ctx, "trace_id", "abc-123") ctx = loggers.AddToLogContext(ctx, "service", "bench-svc") @@ -170,53 +74,50 @@ func BenchmarkBackend_Zap_JSON_CtxFields(b *testing.B) { } } -// --- Frontend benchmarks: slog.Info() through the bridge --- +// --- Backend benchmarks with typed context attrs --- -func BenchmarkFrontend_SlogBridge_GokitBackend(b *testing.B) { - discardStdout(b) - log.SetLogger(log.NewLogger(gokit.NewLogger(jsonNoCaller...))) - sl := wrap.ToSlogLogger(log.GetLogger()) +func BenchmarkBackend_Slog_JSON_CtxAttrs(b *testing.B) { + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + setupHandler(b, inner, jsonNoCaller...) ctx := context.Background() - b.ResetTimer() + ctx = log.AddAttrsToContext(ctx, + slog.String("trace_id", "abc-123"), + slog.String("service", "bench-svc"), + slog.String("request_id", "req-456"), + ) for b.Loop() { - sl.InfoContext(ctx, "benchmark message", "key1", "value1", "key2", 42) + slog.InfoContext(ctx, "benchmark message", "key1", "value1", "key2", 42) } } -func BenchmarkFrontend_SlogBridge_SlogBackend(b *testing.B) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - log.SetLogger(log.NewLogger(cbslog.NewLoggerWithHandler(handler, jsonNoCaller...))) - sl := wrap.ToSlogLogger(log.GetLogger()) - ctx := context.Background() - b.ResetTimer() - for b.Loop() { - sl.InfoContext(ctx, "benchmark message", "key1", "value1", "key2", 42) - } -} +// --- Native slog benchmarks: slog.InfoContext() through ColdBrew Handler --- -func BenchmarkFrontend_SlogBridge_WithAttrs(b *testing.B) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - log.SetLogger(log.NewLogger(cbslog.NewLoggerWithHandler(handler, jsonNoCaller...))) - sl := wrap.ToSlogLogger(log.GetLogger()).With("service", "bench-svc", "version", "1.0") +func BenchmarkFrontend_NativeSlog_ColdBrewHandler(b *testing.B) { + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := log.NewHandlerWithInner(inner, jsonNoCaller...) + log.SetDefault(h) ctx := context.Background() b.ResetTimer() for b.Loop() { - sl.InfoContext(ctx, "benchmark message", "key1", "value1") + slog.InfoContext(ctx, "benchmark message", "key1", "value1", "key2", 42) } } -func BenchmarkFrontend_SlogBridge_WithGroup(b *testing.B) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - log.SetLogger(log.NewLogger(cbslog.NewLoggerWithHandler(handler, jsonNoCaller...))) - sl := wrap.ToSlogLogger(log.GetLogger()).WithGroup("request") +func BenchmarkFrontend_NativeSlog_ColdBrewHandler_CtxFields(b *testing.B) { + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := log.NewHandlerWithInner(inner, jsonNoCaller...) + log.SetDefault(h) ctx := context.Background() + ctx = loggers.AddToLogContext(ctx, "trace_id", "abc-123") + ctx = loggers.AddToLogContext(ctx, "service", "bench-svc") + ctx = loggers.AddToLogContext(ctx, "request_id", "req-456") b.ResetTimer() for b.Loop() { - sl.InfoContext(ctx, "benchmark message", "method", "GET", "path", "/api/v1") + slog.InfoContext(ctx, "benchmark message", "key1", "value1", "key2", 42) } } -// --- Frontend benchmark: native slog (baseline, no ColdBrew overhead) --- +// --- Baseline: native slog without ColdBrew (no overhead) --- func BenchmarkFrontend_NativeSlog_JSON(b *testing.B) { handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) @@ -238,34 +139,11 @@ func BenchmarkFrontend_NativeSlog_Text(b *testing.B) { } } -// --- Frontend benchmark: go-kit wrapped (existing pattern) --- - -func BenchmarkFrontend_GoKitWrap(b *testing.B) { - discardStdout(b) - log.SetLogger(log.NewLogger(gokit.NewLogger(jsonNoCaller...))) - gk := wrap.ToGoKitLogger(log.GetLogger()) - b.ResetTimer() - for b.Loop() { - _ = gk.Log("msg", "benchmark message", "key1", "value1", "key2", 42) - } -} - // --- Filtered (disabled level) benchmarks --- -func BenchmarkFiltered_Gokit(b *testing.B) { - discardStdout(b) - setupLogger(b, gokit.NewLogger(jsonNoCaller...)) - log.SetLevel(loggers.ErrorLevel) - ctx := context.Background() - b.ResetTimer() - for b.Loop() { - log.Debug(ctx, "msg", "should be filtered") - } -} - func BenchmarkFiltered_Slog(b *testing.B) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - setupLogger(b, cbslog.NewLoggerWithHandler(handler, jsonNoCaller...)) + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + setupHandler(b, inner, jsonNoCaller...) log.SetLevel(loggers.ErrorLevel) ctx := context.Background() b.ResetTimer() @@ -282,4 +160,4 @@ func BenchmarkFiltered_NativeSlog(b *testing.B) { for b.Loop() { sl.DebugContext(ctx, "should be filtered") } -} \ No newline at end of file +} diff --git a/documentation.go b/documentation.go index 3d69cae..386c653 100644 --- a/documentation.go +++ b/documentation.go @@ -1,36 +1,63 @@ /* -Package log provides a minimal interface for structured logging in services. ColdBrew uses this log package for all logs. -It provides a simple interface to log errors, warnings, info and debug messages. -It also provides a mechanism to add contextual information to logs. -available implementations of BaseLogger are in loggers package. You can also implement your own BaseLogger to use with this package. +Package log provides structured logging for ColdBrew microservices. -# How To Use +It uses a custom slog.Handler that automatically injects per-request context +fields (added via [AddToContext] or [AddAttrsToContext]) into every log record. -The simplest way to use this package is by calling static log functions to report particular level (error/warning/info/debug) +# Quick Start - log.Error(...) - log.Warn(...) - log.Info(...) - log.Debug(...) +Use the package-level functions for simple logging: -You can also initialize a new logger by calling 'log.NewLogger' and passing a loggers.BaseLogger implementation (loggers package provides a number of pre built implementations) + log.Info(ctx, "msg", "order processed", "order_id", "ORD-123") + log.Error(ctx, "msg", "connection failed", "host", "db.internal") - logger := log.NewLogger(gokit.NewLogger()) - logger.Info(ctx, "key", "value") +# Native slog Support -Note: +After calling [SetDefault], native slog calls automatically get ColdBrew +context fields: - Preferred logging output is in either logfmt or json format, so to facilitate these log function arguments should be in pairs of key-value + log.SetDefault(log.NewHandler()) + ctx := context.Background() + ctx = log.AddToContext(ctx, "trace_id", "abc-123") + slog.InfoContext(ctx, "request handled", "status", 200) // includes trace_id -# Contextual Logs +# Adding Context Fields -log package uses context.Context to pass additional information to logs, you can use 'loggers.AddToLogContext' function to add additional information to logs. For example in access log from service +Use [AddAttrsToContext] to add typed slog.Attr fields, or [AddToContext] for +untyped key-value pairs. Both are included in all subsequent logs for that +request: - {"@timestamp":"2018-07-30T09:58:18.262948679Z","caller":"http/http.go:66","error":null,"grpcMethod":"/AuthSvc.AuthService/Authenticate","level":"info","method":"POST","path":"/2.0/authenticate/","took":"1.356812ms","trace":"15592e1b-93df-11e8-bdfd-0242ac110002","transport":"http"} + ctx := context.Background() + ctx = log.AddAttrsToContext(ctx, + slog.String("trace_id", id), + slog.Int("user_id", uid), + ) -we pass 'grpcMethod' from context, this information gets automatically added to all log calls called inside the service and makes debugging services much easier. -ColdBrew also generates a 'trace' ID per request, this can be used to trace an entire request path in logs. +[AddAttrsToContext] stores each slog.Attr in the context. At log time, the +Handler recovers the typed Attr and emits it directly. Context storage goes +through an any-typed API internally (one boxing per field per request), but +the Attr's type information is preserved for emission. -this package is based on https://github.com/carousell/Orion/tree/master/utils/log +# High-Performance Logging + +Combine [AddAttrsToContext] with [slog.LogAttrs] for the lowest-overhead path. +Per-call attributes passed to [slog.LogAttrs] avoid interface boxing entirely: + + slog.LogAttrs(ctx, slog.LevelInfo, "request handled", + slog.Int("status", 200), + slog.Duration("latency", elapsed), + ) + +# Custom Handlers + +Use [NewHandlerWithInner] to compose ColdBrew's context injection with any +slog.Handler: + + multi := slogmulti.Fanout(jsonHandler, textHandler) + h := log.NewHandlerWithInner(multi) + log.SetDefault(h) + +ColdBrew interceptors automatically add grpcMethod, trace ID, and HTTP path +to the context, so these fields appear in all service logs. */ package log diff --git a/example_test.go b/example_test.go index 81b717c..d9998ad 100644 --- a/example_test.go +++ b/example_test.go @@ -2,6 +2,7 @@ package log_test import ( "context" + "log/slog" "github.com/go-coldbrew/log" ) @@ -27,3 +28,20 @@ func ExampleError() { ctx := context.Background() log.Error(ctx, "msg", "database connection failed", "host", "db.internal", "port", 5432, "retry_in", "5s") } + +func ExampleAddAttrsToContext() { + // SetDefault wires ColdBrew's Handler into slog so context fields are injected. + // In production, core.New() calls this automatically. + log.SetDefault(log.NewHandler()) + + ctx := context.Background() + + // Typed attrs — the Handler recovers the slog.Attr at log time. + // Per-call attrs via slog.LogAttrs avoid interface boxing entirely. + ctx = log.AddAttrsToContext(ctx, + slog.String("trace_id", "abc-123"), + slog.Int("user_id", 42), + ) + + slog.LogAttrs(ctx, slog.LevelInfo, "request handled", slog.Int("status", 200)) +} diff --git a/go.mod b/go.mod index aaf3f76..b416f22 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,7 @@ module github.com/go-coldbrew/log go 1.25.9 -require ( - github.com/go-coldbrew/options v0.3.0 - github.com/go-kit/log v0.2.1 - github.com/sirupsen/logrus v1.9.4 - go.uber.org/zap v1.27.1 -) +require github.com/go-coldbrew/options v0.3.0 require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect @@ -82,7 +77,6 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-git/go-git/v5 v5.17.1 // indirect - github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -190,6 +184,7 @@ require ( github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sonatard/noctx v0.5.1 // indirect @@ -227,6 +222,7 @@ require ( go.augendre.info/arangolint v0.4.0 // indirect go.augendre.info/fatcontext v0.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect diff --git a/go.sum b/go.sum index b262099..eefe07b 100644 --- a/go.sum +++ b/go.sum @@ -231,13 +231,9 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= -github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= -github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= @@ -718,8 +714,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..6923a0e --- /dev/null +++ b/handler.go @@ -0,0 +1,280 @@ +package log + +import ( + "context" + "fmt" + "log/slog" + "os" + "runtime" + "strconv" + "sync" + "time" + + "github.com/go-coldbrew/log/loggers" +) + +// Handler implements slog.Handler with automatic ColdBrew context field injection. +// Any fields added via loggers.AddToLogContext (or log.AddToContext) are automatically +// included in every log record processed by this handler. +// +// Handler is composable — it can wrap any slog.Handler as its inner handler, +// and it can itself be wrapped by other slog.Handler implementations (e.g., slog-multi). +// WithAttrs and WithGroup return new *Handler instances that preserve context injection. +type Handler struct { + inner slog.Handler + levelVar *slog.LevelVar + opts loggers.Options + callerCache sync.Map // pc (uintptr) → "file:line" (string) +} + +var _ slog.Handler = (*Handler)(nil) + +// NewHandler creates a new Handler with the default inner handler (slog.JSONHandler +// or slog.TextHandler based on options). +func NewHandler(options ...loggers.Option) *Handler { + opt := applyOptions(options) + + levelVar := &slog.LevelVar{} + levelVar.Set(ToSlogLevel(opt.Level)) + + handlerOpts := &slog.HandlerOptions{ + AddSource: false, + Level: levelVar, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + a.Key = opt.TimestampFieldName + } + if a.Key == slog.LevelKey { + a.Key = opt.LevelFieldName + if lvl, ok := a.Value.Any().(slog.Level); ok { + a.Value = slog.StringValue(FromSlogLevel(lvl).String()) + } + } + return a + }, + } + + var inner slog.Handler + if opt.JSONLogs { + inner = slog.NewJSONHandler(os.Stdout, handlerOpts) + } else { + inner = slog.NewTextHandler(os.Stdout, handlerOpts) + } + + return &Handler{ + inner: inner, + levelVar: levelVar, + opts: opt, + } +} + +// NewHandlerWithInner creates a new Handler wrapping the provided slog.Handler. +// Use this to compose ColdBrew's context injection with custom handlers +// (e.g., slog-multi for fan-out, sampling handlers, or custom formatters). +// +// Example: +// +// multi := slogmulti.Fanout(jsonHandler, textHandler) +// h := log.NewHandlerWithInner(multi) +// log.SetDefault(h) +func NewHandlerWithInner(inner slog.Handler, options ...loggers.Option) *Handler { + if inner == nil { + panic("log: NewHandlerWithInner called with nil inner handler") + } + opt := applyOptions(options) + + levelVar := &slog.LevelVar{} + levelVar.Set(ToSlogLevel(opt.Level)) + + return &Handler{ + inner: inner, + levelVar: levelVar, + opts: opt, + } +} + +func applyOptions(options []loggers.Option) loggers.Options { + opt := loggers.GetDefaultOptions() + for _, f := range options { + f(&opt) + } + return opt +} + +// Inner returns the wrapped slog.Handler. +func (h *Handler) Inner() slog.Handler { + return h.inner +} + +// Enabled reports whether the handler handles records at the given level. +// It checks both the configured level and any per-request level override +// set via OverrideLogLevel. This means per-request debug logging works +// even for native slog.DebugContext calls. +func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool { + cbLevel := FromSlogLevel(h.levelVar.Level()) + msgLevel := FromSlogLevel(level) + + // Fast path: base level permits this message. + if cbLevel >= msgLevel { + return true + } + + // Per-request override takes precedence over both ColdBrew's level and the + // inner handler's level — this is what makes OverrideLogLevel work. + if ctx != nil { + if override, found := GetOverridenLogLevel(ctx); found { + return override >= msgLevel + } + } + + return false +} + +// Handle processes the log record, injecting ColdBrew context fields and caller info, +// then delegates to the inner handler. +func (h *Handler) Handle(ctx context.Context, record slog.Record) error { + if ctx == nil { + ctx = context.Background() + } + + // Inject caller info if configured. + if h.opts.CallerInfo && record.PC != 0 { + callerStr := h.cachedCallerInfo(record.PC) + record.AddAttrs(slog.String(h.opts.CallerFieldName, callerStr)) + } + + // Inject context fields from AddToLogContext. + ctxFields := loggers.FromContext(ctx) + if ctxFields != nil { + ctxFields.Range(func(k, v any) bool { + record.AddAttrs(toAttr(stringKey(k), v)) + return true + }) + } + + return h.inner.Handle(ctx, record) +} + +// cloneWithInner returns a new Handler sharing level and options but with a different inner handler. +func (h *Handler) cloneWithInner(inner slog.Handler) *Handler { + return &Handler{ + inner: inner, + levelVar: h.levelVar, + opts: h.opts, + } +} + +// WithAttrs returns a new Handler with the given attributes pre-applied. +// The returned handler preserves ColdBrew context field injection. +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + return h.cloneWithInner(h.inner.WithAttrs(attrs)) +} + +// WithGroup returns a new Handler with the given group name. +// The returned handler preserves ColdBrew context field injection. +func (h *Handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + return h.cloneWithInner(h.inner.WithGroup(name)) +} + +// SetLevel changes the log level dynamically. +// If the inner handler supports SetLevel (e.g., the BaseLogger adapter), +// the level change is propagated. +func (h *Handler) SetLevel(level loggers.Level) { + h.levelVar.Set(ToSlogLevel(level)) + + type levelSetter interface{ SetLevel(loggers.Level) } + if inner, ok := h.inner.(levelSetter); ok { + inner.SetLevel(level) + } +} + +// GetLevel returns the current log level. +func (h *Handler) GetLevel() loggers.Level { + return FromSlogLevel(h.levelVar.Level()) +} + +// cachedCallerInfo returns a "file:line" string for the given program counter, +// using a per-handler cache to avoid repeated frame resolution. +func (h *Handler) cachedCallerInfo(pc uintptr) string { + if v, ok := h.callerCache.Load(pc); ok { + return v.(string) + } + frames := runtime.CallersFrames([]uintptr{pc}) + f, _ := frames.Next() + file := f.File + depth := h.opts.CallerFileDepth + if depth <= 0 { + depth = 2 + } + for i := len(file) - 1; i > 0; i-- { + if file[i] == '/' { + depth-- + if depth == 0 { + file = file[i+1:] + break + } + } + } + s := file + ":" + strconv.Itoa(f.Line) + actual, _ := h.callerCache.LoadOrStore(pc, s) + return actual.(string) +} + +func stringKey(v any) string { + if s, ok := v.(string); ok { + return s + } + return fmt.Sprint(v) +} + +// toAttr converts a key-value pair into an slog.Attr. +// When val is an slog.Attr, it is returned as-is (using its own key). +// When val is an slog.Value, the provided key is used. +func toAttr(key string, val any) slog.Attr { + switch v := val.(type) { + case slog.Attr: + return v + case slog.Value: + return slog.Attr{Key: key, Value: v} + case time.Duration: + return slog.String(key, v.String()) + default: + return slog.Any(key, val) + } +} + +// ToSlogLevel converts a ColdBrew log level to an slog.Level. +func ToSlogLevel(level loggers.Level) slog.Level { + switch level { + case loggers.DebugLevel: + return slog.LevelDebug + case loggers.InfoLevel: + return slog.LevelInfo + case loggers.WarnLevel: + return slog.LevelWarn + case loggers.ErrorLevel: + return slog.LevelError + default: + return slog.LevelError + } +} + +// FromSlogLevel converts an slog.Level to a ColdBrew log level. +func FromSlogLevel(level slog.Level) loggers.Level { + switch { + case level >= slog.LevelError: + return loggers.ErrorLevel + case level >= slog.LevelWarn: + return loggers.WarnLevel + case level >= slog.LevelInfo: + return loggers.InfoLevel + default: + return loggers.DebugLevel + } +} diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..e7a2131 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,365 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + + "github.com/go-coldbrew/log/loggers" +) + +// parseJSON parses a JSON log line from a buffer and resets it. +func parseJSON(t *testing.T, buf *bytes.Buffer) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(buf.Bytes(), &m); err != nil { + t.Fatalf("failed to parse JSON log: %v\nraw: %s", err, buf.String()) + } + buf.Reset() + return m +} + +// setDefaultForTest sets the global ColdBrew handler and restores the +// previous state (both defaultHandler and slog.Default) on cleanup. +func setDefaultForTest(t *testing.T, h *Handler) { + t.Helper() + prevSlog := slog.Default() + prevHandler := defaultHandler.Load() + SetDefault(h) + t.Cleanup(func() { + slog.SetDefault(prevSlog) + defaultHandler.Store(prevHandler) + }) +} + +func TestNativeSlog_ContextFieldInjection(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + ctx := context.Background() + ctx = AddToContext(ctx, "trace_id", "abc-123") + ctx = AddToContext(ctx, "service", "test-svc") + + slog.InfoContext(ctx, "hello world") + + m := parseJSON(t, &buf) + if m["trace_id"] != "abc-123" { + t.Errorf("expected trace_id=abc-123, got %v", m["trace_id"]) + } + if m["service"] != "test-svc" { + t.Errorf("expected service=test-svc, got %v", m["service"]) + } + if m["msg"] != "hello world" { + t.Errorf("expected msg=hello world, got %v", m["msg"]) + } +} + +func TestNativeSlog_OverrideLogLevel(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithLevel(loggers.InfoLevel), loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + // Without override: debug should be filtered. + slog.DebugContext(context.Background(), "should be filtered") + if buf.Len() > 0 { + t.Errorf("expected debug to be filtered, got: %s", buf.String()) + } + + // With override: debug should pass through. + ctx := OverrideLogLevel(context.Background(), loggers.DebugLevel) + slog.DebugContext(ctx, "should appear") + if buf.Len() == 0 { + t.Error("expected debug message with override to appear") + } + m := parseJSON(t, &buf) + if m["msg"] != "should appear" { + t.Errorf("expected msg=should appear, got %v", m["msg"]) + } +} + +func TestHandler_WithAttrsPreservesContextInjection(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + + wrapped := h.WithAttrs([]slog.Attr{slog.String("static_key", "static_val")}) + + ctx := context.Background() + ctx = AddToContext(ctx, "trace_id", "xyz-789") + + r := slog.Record{} + r.Level = slog.LevelInfo + r.Message = "test with attrs" + + err := wrapped.Handle(ctx, r) + if err != nil { + t.Fatalf("Handle returned error: %v", err) + } + + m := parseJSON(t, &buf) + if m["static_key"] != "static_val" { + t.Errorf("expected static_key=static_val, got %v", m["static_key"]) + } + if m["trace_id"] != "xyz-789" { + t.Errorf("expected trace_id=xyz-789 from context, got %v", m["trace_id"]) + } +} + +func TestHandler_WithGroupPreservesContextInjection(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + + grouped := h.WithGroup("app") + + ctx := context.Background() + ctx = AddToContext(ctx, "trace_id", "grp-456") + + r := slog.Record{} + r.Level = slog.LevelInfo + r.Message = "grouped message" + r.AddAttrs(slog.String("action", "deploy")) + + err := grouped.Handle(ctx, r) + if err != nil { + t.Fatalf("Handle returned error: %v", err) + } + + m := parseJSON(t, &buf) + if m["msg"] != "grouped message" { + t.Errorf("expected msg=grouped message, got %v", m["msg"]) + } + appGroup, _ := m["app"].(map[string]any) + if appGroup == nil { + t.Fatal("expected app group in output") + } + if appGroup["trace_id"] != "grp-456" { + t.Errorf("expected app.trace_id=grp-456 from context, got %v", appGroup["trace_id"]) + } +} + +func TestHandler_EmptyWithAttrs(t *testing.T) { + inner := slog.NewJSONHandler(&bytes.Buffer{}, nil) + h := NewHandlerWithInner(inner) + h2 := h.WithAttrs(nil) + if h2 != h { + t.Error("expected same handler for empty WithAttrs") + } +} + +func TestHandler_EmptyWithGroup(t *testing.T) { + inner := slog.NewJSONHandler(&bytes.Buffer{}, nil) + h := NewHandlerWithInner(inner) + h2 := h.WithGroup("") + if h2 != h { + t.Error("expected same handler for empty WithGroup") + } +} + +func TestHandler_Inner(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, nil) + h := NewHandlerWithInner(inner) + if h.Inner() != inner { + t.Error("Inner() should return the inner handler") + } +} + +func TestHandler_SetGetLevel(t *testing.T) { + h := NewHandler() + h.SetLevel(loggers.DebugLevel) + if h.GetLevel() != loggers.DebugLevel { + t.Errorf("expected DebugLevel, got %v", h.GetLevel()) + } + h.SetLevel(loggers.ErrorLevel) + if h.GetLevel() != loggers.ErrorLevel { + t.Errorf("expected ErrorLevel, got %v", h.GetLevel()) + } +} + +func TestCBLogger_ArgParsing(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + l := &cbLogger{handler: h} + + // Test odd-leading form: first arg is message. + l.Info(context.Background(), "hello", "key1", "val1") + m := parseJSON(t, &buf) + if m["msg"] != "hello" { + t.Errorf("expected msg=hello, got %v", m["msg"]) + } + if m["key1"] != "val1" { + t.Errorf("expected key1=val1, got %v", m["key1"]) + } + + // Test even-length with explicit "msg" key. + l.Info(context.Background(), "msg", "explicit message", "key2", "val2") + m = parseJSON(t, &buf) + if m["msg"] != "explicit message" { + t.Errorf("expected msg=explicit message, got %v", m["msg"]) + } + if m["key2"] != "val2" { + t.Errorf("expected key2=val2, got %v", m["key2"]) + } + + // Test single arg. + l.Info(context.Background(), "just a message") + m = parseJSON(t, &buf) + if m["msg"] != "just a message" { + t.Errorf("expected msg=just a message, got %v", m["msg"]) + } +} + +func TestSetDefault_EnablesNativeSlog(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + slog.Info("native slog call", "key", "value") + m := parseJSON(t, &buf) + if m["msg"] != "native slog call" { + t.Errorf("expected msg=native slog call, got %v", m["msg"]) + } + if m["key"] != "value" { + t.Errorf("expected key=value, got %v", m["key"]) + } +} + +func TestToAttr_SlogAttr(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + ctx := AddAttrsToContext(context.Background(), slog.String("user_id", "42")) + slog.InfoContext(ctx, "test") + + m := parseJSON(t, &buf) + if m["user_id"] != "42" { + t.Errorf("expected user_id=42, got %v", m["user_id"]) + } +} + +func TestToAttr_SlogAttr_MapKeyIgnored(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + ctx := context.Background() + ctx = loggers.AddToLogContext(ctx, "ignored_key", slog.String("real_key", "value")) + slog.InfoContext(ctx, "test") + + m := parseJSON(t, &buf) + if m["real_key"] != "value" { + t.Errorf("expected real_key=value, got %v", m["real_key"]) + } + if _, exists := m["ignored_key"]; exists { + t.Errorf("did not expect ignored_key in output, got %v", m["ignored_key"]) + } +} + +func TestToAttr_SlogValue(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + ctx := context.Background() + ctx = loggers.AddToLogContext(ctx, "count", slog.IntValue(99)) + slog.InfoContext(ctx, "test") + + m := parseJSON(t, &buf) + if m["count"] != float64(99) { + t.Errorf("expected count=99, got %v", m["count"]) + } +} + +func TestAddAttrsToContext_Multiple(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + ctx := AddAttrsToContext(context.Background(), + slog.String("trace_id", "abc-123"), + slog.Int("user_id", 42), + ) + slog.InfoContext(ctx, "handled") + + m := parseJSON(t, &buf) + if m["trace_id"] != "abc-123" { + t.Errorf("expected trace_id=abc-123, got %v", m["trace_id"]) + } + if m["user_id"] != float64(42) { + t.Errorf("expected user_id=42, got %v", m["user_id"]) + } +} + +func TestAddAttrsToContext_Overwrites(t *testing.T) { + var buf bytes.Buffer + inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + setDefaultForTest(t, h) + + ctx := AddToContext(context.Background(), "trace_id", "old-value") + ctx = AddAttrsToContext(ctx, slog.String("trace_id", "new-value")) + slog.InfoContext(ctx, "test") + + m := parseJSON(t, &buf) + if m["trace_id"] != "new-value" { + t.Errorf("expected trace_id=new-value, got %v", m["trace_id"]) + } +} + +func TestAddAttrsToContext_EmptyKey(t *testing.T) { + ctx := AddAttrsToContext(context.Background(), slog.Attr{Key: "", Value: slog.StringValue("skip")}) + fields := loggers.FromContext(ctx) + if fields != nil { + found := false + fields.Range(func(k, _ any) bool { + if k == "" { + found = true + } + return true + }) + if found { + t.Error("expected empty-key attr to be skipped") + } + } +} + +func TestAddAttrsToContext_Nil(t *testing.T) { + ctx := AddAttrsToContext(nil, slog.String("key", "val")) //nolint:staticcheck // testing nil context + if ctx == nil { + t.Error("expected non-nil context") + } +} + +func TestLevelMapping(t *testing.T) { + tests := []struct { + cb loggers.Level + slog slog.Level + }{ + {loggers.DebugLevel, slog.LevelDebug}, + {loggers.InfoLevel, slog.LevelInfo}, + {loggers.WarnLevel, slog.LevelWarn}, + {loggers.ErrorLevel, slog.LevelError}, + } + + for _, tt := range tests { + if got := ToSlogLevel(tt.cb); got != tt.slog { + t.Errorf("ToSlogLevel(%v) = %v, want %v", tt.cb, got, tt.slog) + } + if got := FromSlogLevel(tt.slog); got != tt.cb { + t.Errorf("FromSlogLevel(%v) = %v, want %v", tt.slog, got, tt.cb) + } + } +} diff --git a/log.go b/log.go index 18e5ae1..3e37f27 100644 --- a/log.go +++ b/log.go @@ -2,97 +2,244 @@ package log import ( "context" + "log/slog" + "runtime" "sync/atomic" + "time" "github.com/go-coldbrew/log/loggers" - cbslog "github.com/go-coldbrew/log/loggers/slog" ) // SupportPackageIsVersion1 is a compile-time assertion constant. // Downstream packages reference this to enforce version compatibility. const SupportPackageIsVersion1 = true -var defaultLogger atomic.Pointer[Logger] +var defaultHandler atomic.Pointer[Handler] -type logger struct { - baseLog loggers.BaseLogger +// cbLogger wraps a Handler and provides ColdBrew's convenience methods. +type cbLogger struct { + handler *Handler } -func (l *logger) SetLevel(level loggers.Level) { - l.baseLog.SetLevel(level) +func (l *cbLogger) SetLevel(level loggers.Level) { + l.handler.SetLevel(level) } -func (l *logger) GetLevel() loggers.Level { - return l.baseLog.GetLevel() +func (l *cbLogger) GetLevel() loggers.Level { + return l.handler.GetLevel() } -func (l *logger) Debug(ctx context.Context, args ...any) { +func (l *cbLogger) Debug(ctx context.Context, args ...any) { l.Log(ctx, loggers.DebugLevel, 1, args...) } -func (l *logger) Info(ctx context.Context, args ...any) { +func (l *cbLogger) Info(ctx context.Context, args ...any) { l.Log(ctx, loggers.InfoLevel, 1, args...) } -func (l *logger) Warn(ctx context.Context, args ...any) { +func (l *cbLogger) Warn(ctx context.Context, args ...any) { l.Log(ctx, loggers.WarnLevel, 1, args...) } -func (l *logger) Error(ctx context.Context, args ...any) { +func (l *cbLogger) Error(ctx context.Context, args ...any) { l.Log(ctx, loggers.ErrorLevel, 1, args...) } -func (l *logger) Log(ctx context.Context, level loggers.Level, skip int, args ...any) { +func (l *cbLogger) Log(ctx context.Context, level loggers.Level, skip int, args ...any) { if ctx == nil { ctx = context.Background() } - logLevel := l.GetLevel() - if logLevel >= level { - l.baseLog.Log(ctx, level, skip+1, args...) + + slogLevel := ToSlogLevel(level) + + if !l.handler.Enabled(ctx, slogLevel) { return } - // Only check override if base level would filter this out. - // Most requests have no override, so this avoids a context lookup on the hot path. - if overridenLogLevel, found := GetOverridenLogLevel(ctx); found && overridenLogLevel >= level { - l.baseLog.Log(ctx, level, skip+1, args...) + + // Parse args using ColdBrew's convention: + // odd-length: first arg is message, rest are key-value pairs + // even-length: scan for explicit "msg" key + var msg string + msgIdx := -1 + if len(args) == 1 { + msg = stringKey(args[0]) + } else if len(args) > 1 { + if len(args)%2 != 0 { + msg = stringKey(args[0]) + args = args[1:] + } else { + for i := 0; i < len(args)-1; i += 2 { + if k, ok := args[i].(string); ok && k == loggers.MessageKey { + msg = stringKey(args[i+1]) + msgIdx = i + break + } + } + } + } + + var pcs [1]uintptr + runtime.Callers(skip+2, pcs[:]) + + var attrBuf [8]slog.Attr + attrs := attrBuf[:0] + + for i := 0; i < len(args)-1; i += 2 { + if i == msgIdx { + continue + } + attrs = append(attrs, toAttr(stringKey(args[i]), args[i+1])) + } + if len(args) > 1 && len(args)%2 != 0 { + attrs = append(attrs, slog.Any("!BADKEY", args[len(args)-1])) } + + record := slog.NewRecord(time.Now(), slogLevel, msg, pcs[0]) + record.AddAttrs(attrs...) + + _ = l.handler.Handle(ctx, record) } -// NewLogger creates a new logger with a provided BaseLogger -// The default logger is slog logger -func NewLogger(log loggers.BaseLogger) Logger { - l := new(logger) - l.baseLog = log - return l +// handler returns the underlying Handler (unexported to keep Logger interface clean). +func (l *cbLogger) handler_() *Handler { + return l.handler } -// GetLogger returns the global logger -// If the global logger is not set, it will create a new one with slog logger -func GetLogger() Logger { - l := defaultLogger.Load() - if l == nil { - // If the default logger is not set, create a new one with slog logger - slogLogger := cbslog.NewLogger() - newLogger := NewLogger(slogLogger) - defaultLogger.CompareAndSwap(nil, &newLogger) +// getOrInitHandler returns the global Handler, lazily initializing it if needed. +func getOrInitHandler() *Handler { + h := defaultHandler.Load() + if h == nil { + newHandler := NewHandler() + defaultHandler.CompareAndSwap(nil, newHandler) + h = defaultHandler.Load() } - return *defaultLogger.Load() + return h } -// SetLogger sets the global logger +// SetDefault sets the global ColdBrew handler and also calls slog.SetDefault +// so that native slog.InfoContext/slog.ErrorContext calls automatically get +// ColdBrew context fields injected. +func SetDefault(h *Handler) { + if h == nil { + return + } + defaultHandler.Store(h) + slog.SetDefault(slog.New(h)) +} + +// GetHandler returns the global ColdBrew Handler. +func GetHandler() *Handler { + return getOrInitHandler() +} + +// GetLogger returns the global logger. +// If the global logger is not set, it will create a new one with the default Handler. +func GetLogger() Logger { + return &cbLogger{handler: getOrInitHandler()} +} + +// SetLogger sets the global logger. +// +// Deprecated: Use SetDefault with a *Handler instead. SetLogger is kept for +// backward compatibility with code that implements the Logger interface. func SetLogger(l Logger) { - if l != nil { - defaultLogger.Store(&l) + if l == nil { + return + } + // If the logger wraps a Handler, use it directly. + type handlerProvider interface{ handler_() *Handler } + if hl, ok := l.(handlerProvider); ok { + SetDefault(hl.handler_()) + return } + // Legacy path: wrap the BaseLogger in a compatibility adapter. + h := newHandlerFromBaseLogger(l) + defaultHandler.Store(h) +} + +// NewLogger creates a new logger with a provided BaseLogger. +// +// Deprecated: Use NewHandler or NewHandlerWithInner instead. NewLogger is kept +// for backward compatibility. +func NewLogger(bl loggers.BaseLogger) Logger { //nolint:staticcheck // backward compatibility + h := newHandlerFromBaseLogger(bl) + return &cbLogger{handler: h} } // AddToContext adds log fields to the provided context. // Any info added here will be included in all logs that use the returned context. -// This is the preferred entry point for adding contextual logging fields and is implemented -// internally using loggers.AddToLogContext. +// This works with both ColdBrew's log functions and native slog.InfoContext. func AddToContext(ctx context.Context, key string, value any) context.Context { if ctx == nil { ctx = context.Background() } return loggers.AddToLogContext(ctx, key, value) } + +// AddAttrsToContext adds typed slog.Attr fields to the context. +// Each Attr is stored keyed by its own Key. At log time, the Handler recovers +// the typed Attr via a type switch, preserving the original slog type information. +// +// Note: context storage goes through an any-typed API internally, so the Attr +// is boxed once when stored. The benefit is at log emission time — the Handler +// emits the Attr directly instead of wrapping it in slog.Any. Combine with +// slog.LogAttrs for per-call attrs to avoid boxing on the hot path: +// +// ctx = log.AddAttrsToContext(ctx, +// slog.String("trace_id", id), +// slog.Int("user_id", uid), +// ) +// slog.LogAttrs(ctx, slog.LevelInfo, "handled", slog.Int("status", 200)) +func AddAttrsToContext(ctx context.Context, attrs ...slog.Attr) context.Context { + if ctx == nil { + ctx = context.Background() + } + for _, a := range attrs { + if a.Key != "" { + ctx = loggers.AddToLogContext(ctx, a.Key, a) + } + } + return ctx +} + +// baseLoggerAdapter wraps a BaseLogger as a slog.Handler for backward compatibility. +type baseLoggerAdapter struct { + bl loggers.BaseLogger //nolint:staticcheck // backward compatibility adapter +} + +func newHandlerFromBaseLogger(bl loggers.BaseLogger) *Handler { //nolint:staticcheck // backward compatibility + levelVar := &slog.LevelVar{} + levelVar.Set(ToSlogLevel(bl.GetLevel())) + return &Handler{ + inner: &baseLoggerAdapter{bl: bl}, + levelVar: levelVar, + opts: loggers.GetDefaultOptions(), + } +} + +func (a *baseLoggerAdapter) SetLevel(level loggers.Level) { + a.bl.SetLevel(level) +} + +func (a *baseLoggerAdapter) Enabled(_ context.Context, level slog.Level) bool { + return a.bl.GetLevel() >= FromSlogLevel(level) +} + +func (a *baseLoggerAdapter) Handle(ctx context.Context, record slog.Record) error { + args := make([]any, 0, 1+record.NumAttrs()*2) + args = append(args, record.Message) + record.Attrs(func(attr slog.Attr) bool { + args = append(args, attr.Key, attr.Value.Any()) + return true + }) + a.bl.Log(ctx, FromSlogLevel(record.Level), 0, args...) + return nil +} + +func (a *baseLoggerAdapter) WithAttrs(_ []slog.Attr) slog.Handler { + return a +} + +func (a *baseLoggerAdapter) WithGroup(_ string) slog.Handler { + return a +} diff --git a/loggers/gokit/README.md b/loggers/gokit/README.md deleted file mode 100755 index 3c361b8..0000000 --- a/loggers/gokit/README.md +++ /dev/null @@ -1,65 +0,0 @@ - - -[![CI](https://github.com/go-coldbrew/log/actions/workflows/go.yml/badge.svg)](https://github.com/go-coldbrew/log/actions/workflows/go.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/go-coldbrew/log)](https://goreportcard.com/report/github.com/go-coldbrew/log) -[![GoDoc](https://pkg.go.dev/badge/github.com/go-coldbrew/log.svg)](https://pkg.go.dev/github.com/go-coldbrew/log) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - - - -# gokit - -```go -import "github.com/go-coldbrew/log/loggers/gokit" -``` - -Deprecated: Package gokit provides BaseLogger implementation for go\-kit/log. The go\-kit/log library is in maintenance mode and no longer actively developed. Use the slog backend \(loggers/slog\) instead, which is the default and recommended backend. - -
Example -

- -This example shows how to use the gokit backend. - -Deprecated: Use the slog backend instead \(loggers/slog\). The go\-kit/log library is in maintenance mode and no longer actively developed. - -```go -package main - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - "github.com/go-coldbrew/log/loggers/gokit" -) - -func main() { - logger := gokit.NewLogger( - loggers.WithJSONLogs(true), - loggers.WithCallerInfo(true), - ) - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Info(ctx, "msg", "request handled", "method", "GET", "path", "/api/v1") -} -``` - -

-
- -## Index - -- [func NewLogger\(options ...loggers.Option\) loggers.BaseLogger](<#NewLogger>) - - - -## func [NewLogger]() - -```go -func NewLogger(options ...loggers.Option) loggers.BaseLogger -``` - -NewLogger returns a base logger impl for go\-kit log - -Generated by [gomarkdoc]() diff --git a/loggers/gokit/example_test.go b/loggers/gokit/example_test.go deleted file mode 100644 index c46460d..0000000 --- a/loggers/gokit/example_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package gokit_test - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - "github.com/go-coldbrew/log/loggers/gokit" -) - -// This example shows how to use the gokit backend. -// -// Deprecated: Use the slog backend instead (loggers/slog). The go-kit/log -// library is in maintenance mode and no longer actively developed. -func Example() { - logger := gokit.NewLogger( - loggers.WithJSONLogs(true), - loggers.WithCallerInfo(true), - ) - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Info(ctx, "msg", "request handled", "method", "GET", "path", "/api/v1") -} diff --git a/loggers/gokit/gokit.go b/loggers/gokit/gokit.go deleted file mode 100644 index 3454131..0000000 --- a/loggers/gokit/gokit.go +++ /dev/null @@ -1,83 +0,0 @@ -// Deprecated: Package gokit provides BaseLogger implementation for go-kit/log. -// The go-kit/log library is in maintenance mode and no longer actively developed. -// Use the slog backend (loggers/slog) instead, which is the default and recommended backend. -package gokit - -import ( - "context" - "fmt" - stdlog "log" - "os" - - "github.com/go-coldbrew/log/loggers" - "github.com/go-kit/log" -) - -type logger struct { - logger log.Logger - level loggers.Level - opt loggers.Options -} - -func (l *logger) Log(ctx context.Context, level loggers.Level, skip int, args ...any) { - // Batch all extra fields (level, caller, context) into a single slice - // and call log.With() once instead of N separate wrapper allocations. - extra := make([]any, 0, 8) - extra = append(extra, l.opt.LevelFieldName, level.String()) - - if l.opt.CallerInfo { - _, file, line := loggers.FetchCallerInfo(skip+1, l.opt.CallerFileDepth) - extra = append(extra, l.opt.CallerFieldName, fmt.Sprintf("%s:%d", file, line)) - } - - ctxFields := loggers.FromContext(ctx) - if ctxFields != nil { - ctxFields.Range(func(k, v any) bool { - extra = append(extra, k, v) - return true - }) - } - - lgr := log.With(l.logger, extra...) - if len(args) == 1 { - _ = lgr.Log(loggers.MessageKey, args[0]) - } else { - _ = lgr.Log(args...) - } -} - -func (l *logger) SetLevel(level loggers.Level) { - l.level = level -} - -func (l *logger) GetLevel() loggers.Level { - return l.level -} - -// NewLogger returns a base logger impl for go-kit log -func NewLogger(options ...loggers.Option) loggers.BaseLogger { - opt := loggers.GetDefaultOptions() - for _, f := range options { - f(&opt) - } - - l := logger{} - writer := log.NewSyncWriter(os.Stdout) - - if opt.JSONLogs { - l.logger = log.NewJSONLogger(writer) - } else { - l.logger = log.NewLogfmtLogger(writer) - } - - l.logger = log.With(l.logger, opt.TimestampFieldName, log.DefaultTimestamp) - - l.level = opt.Level - l.opt = opt - - if opt.ReplaceStdLogger { - stdlog.SetFlags(stdlog.LUTC) - stdlog.SetOutput(log.NewStdlibAdapter(l.logger, log.TimestampKey(opt.TimestampFieldName))) - } - return &l -} diff --git a/loggers/loggers.go b/loggers/loggers.go index fed6b4a..3b624ce 100644 --- a/loggers/loggers.go +++ b/loggers/loggers.go @@ -71,7 +71,11 @@ const ( DebugLevel ) -// BaseLogger is the interface that needs to be implemented by client loggers +// BaseLogger is the interface that needs to be implemented by client loggers. +// +// Deprecated: Implement slog.Handler instead and use log.NewHandlerWithInner +// to compose it with ColdBrew's context field injection. BaseLogger is kept +// for backward compatibility and will be removed in a future major version. type BaseLogger interface { // Log logs a message at the given level. The args are key-value pairs. // The key must be a string, while the value can be of any type. diff --git a/loggers/logrus/README.md b/loggers/logrus/README.md deleted file mode 100755 index 9af8801..0000000 --- a/loggers/logrus/README.md +++ /dev/null @@ -1,32 +0,0 @@ - - -[![CI](https://github.com/go-coldbrew/log/actions/workflows/go.yml/badge.svg)](https://github.com/go-coldbrew/log/actions/workflows/go.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/go-coldbrew/log)](https://goreportcard.com/report/github.com/go-coldbrew/log) -[![GoDoc](https://pkg.go.dev/badge/github.com/go-coldbrew/log.svg)](https://pkg.go.dev/github.com/go-coldbrew/log) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - - - -# logrus - -```go -import "github.com/go-coldbrew/log/loggers/logrus" -``` - -Deprecated: Package logrus provides a BaseLogger implementation for logrus. The logrus library has been in maintenance mode since 2020 and is no longer actively developed. Use the slog backend \(loggers/slog\) instead, which is the default and recommended backend. - -## Index - -- [func NewLogger\(options ...loggers.Option\) loggers.BaseLogger](<#NewLogger>) - - - -## func [NewLogger]() - -```go -func NewLogger(options ...loggers.Option) loggers.BaseLogger -``` - -NewLogger returns a BaseLogger impl for logrus - -Generated by [gomarkdoc]() diff --git a/loggers/logrus/logrus.go b/loggers/logrus/logrus.go deleted file mode 100644 index ae6afff..0000000 --- a/loggers/logrus/logrus.go +++ /dev/null @@ -1,125 +0,0 @@ -// Deprecated: Package logrus provides a BaseLogger implementation for logrus. -// The logrus library has been in maintenance mode since 2020 and is no longer actively developed. -// Use the slog backend (loggers/slog) instead, which is the default and recommended backend. -package logrus - -import ( - "context" - "fmt" - stdlog "log" - "os" - - "github.com/go-coldbrew/log/loggers" - log "github.com/sirupsen/logrus" -) - -type logger struct { - logger *log.Logger - opt loggers.Options -} - -func toLogrusLogLevel(level loggers.Level) log.Level { - switch level { - case loggers.DebugLevel: - return log.DebugLevel - case loggers.InfoLevel: - return log.InfoLevel - case loggers.WarnLevel: - return log.WarnLevel - case loggers.ErrorLevel: - return log.ErrorLevel - default: - return log.ErrorLevel - } -} - -func (l *logger) Log(ctx context.Context, level loggers.Level, skip int, args ...any) { - fields := make(log.Fields) - - // fetch fields from context and add them to logrus fields - ctxFields := loggers.FromContext(ctx) - if ctxFields != nil { - ctxFields.Range(func(k, v any) bool { - if str, ok := k.(string); ok { - fields[str] = v - } - return true - }) - } - - if l.opt.CallerInfo { - _, file, line := loggers.FetchCallerInfo(skip+1, l.opt.CallerFileDepth) - fields[l.opt.CallerFieldName] = fmt.Sprintf("%s:%d", file, line) - } - - logger := l.logger.WithFields(fields) - switch level { - case loggers.DebugLevel: - logger.Debug(args...) - case loggers.InfoLevel: - logger.Info(args...) - case loggers.WarnLevel: - logger.Warn(args...) - case loggers.ErrorLevel: - logger.Error(args...) - default: - l.logger.Error(args...) - } -} - -func (l *logger) SetLevel(level loggers.Level) { - l.logger.SetLevel(toLogrusLogLevel(level)) -} - -func (l *logger) GetLevel() loggers.Level { - switch l.logger.Level { - case log.DebugLevel: - return loggers.DebugLevel - case log.InfoLevel: - return loggers.InfoLevel - case log.WarnLevel: - return loggers.WarnLevel - case log.ErrorLevel: - return loggers.ErrorLevel - default: - return loggers.InfoLevel - } -} - -// NewLogger returns a BaseLogger impl for logrus -func NewLogger(options ...loggers.Option) loggers.BaseLogger { - // default options - opt := loggers.GetDefaultOptions() - // read options - for _, f := range options { - f(&opt) - } - - l := logger{} - l.logger = log.New() - l.logger.Out = os.Stdout - - l.logger.SetLevel(toLogrusLogLevel(opt.Level)) - - fieldMap := log.FieldMap{ - log.FieldKeyTime: opt.TimestampFieldName, - log.FieldKeyLevel: opt.LevelFieldName, - } - //check JSON logs - if opt.JSONLogs { - l.logger.Formatter = &log.JSONFormatter{ - FieldMap: fieldMap, - } - } else { - l.logger.Formatter = &log.TextFormatter{ - FullTimestamp: true, - } - } - - l.opt = opt - - if opt.ReplaceStdLogger { - stdlog.SetOutput(l.logger.Writer()) - } - return &l -} diff --git a/loggers/slog/README.md b/loggers/slog/README.md deleted file mode 100644 index c5f95f9..0000000 --- a/loggers/slog/README.md +++ /dev/null @@ -1,110 +0,0 @@ - - -[![CI](https://github.com/go-coldbrew/log/actions/workflows/go.yml/badge.svg)](https://github.com/go-coldbrew/log/actions/workflows/go.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/go-coldbrew/log)](https://goreportcard.com/report/github.com/go-coldbrew/log) -[![GoDoc](https://pkg.go.dev/badge/github.com/go-coldbrew/log.svg)](https://pkg.go.dev/github.com/go-coldbrew/log) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - - - -# slog - -```go -import "github.com/go-coldbrew/log/loggers/slog" -``` - -Package slog provides a BaseLogger implementation for log/slog. - -
Example -

- -This example shows how to use the slog backend with JSON output. The slog backend is the default and recommended backend. - -```go -package main - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - - cbslog "github.com/go-coldbrew/log/loggers/slog" -) - -func main() { - // Create a slog-backed logger with JSON output - logger := cbslog.NewLogger( - loggers.WithJSONLogs(true), - loggers.WithCallerInfo(true), - ) - - // Set as the global logger - log.SetLogger(log.NewLogger(logger)) - - // Log normally — output goes through slog's JSONHandler - ctx := context.Background() - log.Info(ctx, "msg", "service started", "port", 8080) -} -``` - -

-
- -
Example (Text Output) -

- -This example shows how to configure the slog backend with text output and a custom log level. - -```go -package main - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - - cbslog "github.com/go-coldbrew/log/loggers/slog" -) - -func main() { - logger := cbslog.NewLogger( - loggers.WithJSONLogs(false), - loggers.WithLevel(loggers.DebugLevel), - ) - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Debug(ctx, "msg", "debug message", "detail", "verbose") -} -``` - -

-
- -## Index - -- [func NewLogger\(options ...loggers.Option\) loggers.BaseLogger](<#NewLogger>) -- [func NewLoggerWithHandler\(handler slog.Handler, options ...loggers.Option\) loggers.BaseLogger](<#NewLoggerWithHandler>) - - - -## func [NewLogger]() - -```go -func NewLogger(options ...loggers.Option) loggers.BaseLogger -``` - -NewLogger returns a BaseLogger implementation backed by log/slog. - - -## func [NewLoggerWithHandler]() - -```go -func NewLoggerWithHandler(handler slog.Handler, options ...loggers.Option) loggers.BaseLogger -``` - -NewLoggerWithHandler returns a BaseLogger implementation backed by the provided slog.Handler. Use this when you need a custom handler \(e.g., for testing or custom output formats\). Note: SetLevel updates the internally tracked level. Both this level and the provided handler's own level filtering apply; the stricter one wins. - -Generated by [gomarkdoc]() diff --git a/loggers/slog/example_test.go b/loggers/slog/example_test.go deleted file mode 100644 index e7560b4..0000000 --- a/loggers/slog/example_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package slog_test - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - cbslog "github.com/go-coldbrew/log/loggers/slog" -) - -// This example shows how to use the slog backend with JSON output. -// The slog backend is the default and recommended backend. -func Example() { - // Create a slog-backed logger with JSON output - logger := cbslog.NewLogger( - loggers.WithJSONLogs(true), - loggers.WithCallerInfo(true), - ) - - // Set as the global logger - log.SetLogger(log.NewLogger(logger)) - - // Log normally — output goes through slog's JSONHandler - ctx := context.Background() - log.Info(ctx, "msg", "service started", "port", 8080) -} - -// This example shows how to configure the slog backend with text output -// and a custom log level. -func Example_textOutput() { - logger := cbslog.NewLogger( - loggers.WithJSONLogs(false), - loggers.WithLevel(loggers.DebugLevel), - ) - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Debug(ctx, "msg", "debug message", "detail", "verbose") -} diff --git a/loggers/slog/slog.go b/loggers/slog/slog.go deleted file mode 100644 index 54c062a..0000000 --- a/loggers/slog/slog.go +++ /dev/null @@ -1,266 +0,0 @@ -// Package slog provides a BaseLogger implementation for log/slog. -package slog - -import ( - "context" - "fmt" - "log/slog" - "os" - "runtime" - "strconv" - "sync" - "time" - - "github.com/go-coldbrew/log/loggers" -) - -// slogBackendKey is a context sentinel used to prevent infinite loops -// when both the slog backend and slog handler bridge are active. -type slogBackendKey struct{} - -type logger struct { - handler slog.Handler - levelVar *slog.LevelVar - opt loggers.Options - callerCache sync.Map // pc (uintptr) → "file:line" (string) -} - -func stringKey(v any) string { - if s, ok := v.(string); ok { - return s - } - return fmt.Sprint(v) -} - -// toAttr creates an slog.Attr, handling time.Duration specially since slog -// serializes it as nanoseconds (int64) while gokit uses fmt.Sprint ("1s"). -func toAttr(key string, val any) slog.Attr { - if d, ok := val.(time.Duration); ok { - return slog.String(key, d.String()) - } - return slog.Any(key, val) -} - -func (l *logger) Log(ctx context.Context, level loggers.Level, skip int, args ...any) { - if ctx == nil { - ctx = context.Background() - } - - slogLevel := toSlogLevel(level) - - // Gate on our own levelVar first (handles SetLevel on custom handlers - // whose internal level may not be wired to our levelVar). - if slogLevel < l.levelVar.Level() { - return - } - - if !l.handler.Enabled(ctx, slogLevel) { - return - } - - // Re-entry guard: prevent infinite loops with the slog handler bridge. - // Placed after level checks to avoid context allocation on filtered messages. - if ctx.Value(slogBackendKey{}) != nil { - return - } - ctx = context.WithValue(ctx, slogBackendKey{}, true) - - // Extract "msg" from args and build attrs directly — no intermediate slice. - var msg string - msgIdx := -1 - if len(args) == 1 { - msg = stringKey(args[0]) - } else if len(args) > 1 { - // Odd-leading form: first arg is message, rest are key-value pairs. - // e.g., log.Info(ctx, "request processed", "id", 123) - if len(args)%2 != 0 { - msg = stringKey(args[0]) - args = args[1:] - } else { - // Even-length: look for explicit "msg" key in pairs. - for i := 0; i < len(args)-1; i += 2 { - if k, ok := args[i].(string); ok && k == loggers.MessageKey { - msg = stringKey(args[i+1]) - msgIdx = i - break - } - } - } - } - - // Capture PC early — used for both caller info cache and slog.Record. - var pcs [1]uintptr - runtime.Callers(skip+2, pcs[:]) - - // Stack-allocated buffer avoids heap allocation for <=8 attrs (common case). - var attrBuf [8]slog.Attr - attrs := attrBuf[:0] - - if l.opt.CallerInfo { - callerStr := l.cachedCallerInfo(pcs[0]) - attrs = append(attrs, slog.String(l.opt.CallerFieldName, callerStr)) - } - - ctxFields := loggers.FromContext(ctx) - if ctxFields != nil { - ctxFields.Range(func(k, v any) bool { - attrs = append(attrs, toAttr(stringKey(k), v)) - return true - }) - } - - // Build attrs directly from args, skipping the "msg" pair. - for i := 0; i < len(args)-1; i += 2 { - if i == msgIdx { - continue - } - attrs = append(attrs, toAttr(stringKey(args[i]), args[i+1])) - } - if len(args) > 1 && len(args)%2 != 0 { - attrs = append(attrs, slog.Any("!BADKEY", args[len(args)-1])) - } - - record := slog.NewRecord(time.Now(), slogLevel, msg, pcs[0]) - record.AddAttrs(attrs...) - - _ = l.handler.Handle(ctx, record) -} - -// cachedCallerInfo returns a "file:line" string for the given program counter, -// using a per-logger cache to avoid repeated frame resolution and string -// formatting for the same call site. The cache is bounded by the number of -// unique log call sites in the binary (typically hundreds). -func (l *logger) cachedCallerInfo(pc uintptr) string { - if v, ok := l.callerCache.Load(pc); ok { - return v.(string) - } - // Derive file:line from the pc we already captured for slog.Record, - // avoiding a redundant runtime.Caller call. - frames := runtime.CallersFrames([]uintptr{pc}) - f, _ := frames.Next() - file := f.File - depth := l.opt.CallerFileDepth - if depth <= 0 { - depth = 2 - } - for i := len(file) - 1; i > 0; i-- { - if file[i] == '/' { - depth-- - if depth == 0 { - file = file[i+1:] - break - } - } - } - s := file + ":" + strconv.Itoa(f.Line) - l.callerCache.LoadOrStore(pc, s) - return s -} - -func (l *logger) SetLevel(level loggers.Level) { - l.levelVar.Set(toSlogLevel(level)) -} - -func (l *logger) GetLevel() loggers.Level { - return fromSlogLevel(l.levelVar.Level()) -} - -func toSlogLevel(level loggers.Level) slog.Level { - switch level { - case loggers.DebugLevel: - return slog.LevelDebug - case loggers.InfoLevel: - return slog.LevelInfo - case loggers.WarnLevel: - return slog.LevelWarn - case loggers.ErrorLevel: - return slog.LevelError - default: - return slog.LevelError - } -} - -func fromSlogLevel(level slog.Level) loggers.Level { - switch { - case level >= slog.LevelError: - return loggers.ErrorLevel - case level >= slog.LevelWarn: - return loggers.WarnLevel - case level >= slog.LevelInfo: - return loggers.InfoLevel - default: - return loggers.DebugLevel - } -} - -// NewLogger returns a BaseLogger implementation backed by log/slog. -func NewLogger(options ...loggers.Option) loggers.BaseLogger { - opt := loggers.GetDefaultOptions() - for _, f := range options { - f(&opt) - } - - levelVar := &slog.LevelVar{} - levelVar.Set(toSlogLevel(opt.Level)) - - handlerOpts := &slog.HandlerOptions{ - AddSource: false, - Level: levelVar, - // Wire-compatible output: remap "time" → opt.TimestampFieldName (default "@timestamp") - // and uppercase slog levels → lowercase ColdBrew levels (e.g., "INFO" → "info"). - ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { - if a.Key == slog.TimeKey { - a.Key = opt.TimestampFieldName - } - if a.Key == slog.LevelKey { - a.Key = opt.LevelFieldName - if lvl, ok := a.Value.Any().(slog.Level); ok { - a.Value = slog.StringValue(fromSlogLevel(lvl).String()) - } - } - return a - }, - } - - var handler slog.Handler - if opt.JSONLogs { - handler = slog.NewJSONHandler(os.Stdout, handlerOpts) - } else { - handler = slog.NewTextHandler(os.Stdout, handlerOpts) - } - - if opt.ReplaceStdLogger { - slog.SetDefault(slog.New(handler)) - } - - return &logger{ - handler: handler, - levelVar: levelVar, - opt: opt, - } -} - -// NewLoggerWithHandler returns a BaseLogger implementation backed by the -// provided slog.Handler. Use this when you need a custom handler -// (e.g., for testing or custom output formats). -// Note: SetLevel updates the internally tracked level. Both this level and -// the provided handler's own level filtering apply; the stricter one wins. -func NewLoggerWithHandler(handler slog.Handler, options ...loggers.Option) loggers.BaseLogger { - opt := loggers.GetDefaultOptions() - for _, f := range options { - f(&opt) - } - - levelVar := &slog.LevelVar{} - levelVar.Set(toSlogLevel(opt.Level)) - - if opt.ReplaceStdLogger { - slog.SetDefault(slog.New(handler)) - } - - return &logger{ - handler: handler, - levelVar: levelVar, - opt: opt, - } -} diff --git a/loggers/slog/slog_test.go b/loggers/slog/slog_test.go deleted file mode 100644 index d6b4632..0000000 --- a/loggers/slog/slog_test.go +++ /dev/null @@ -1,530 +0,0 @@ -package slog - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log/slog" - "strings" - "testing" - "time" - - "github.com/go-coldbrew/log/loggers" -) - -// newBufferedLogger creates a logger writing to buf. Pass handlerOpts to -// customize the slog.HandlerOptions (e.g., for wire-compat ReplaceAttr). -// If handlerOpts is nil, a plain handler with no ReplaceAttr is used. -func newBufferedLogger(buf *bytes.Buffer, handlerOpts *slog.HandlerOptions, opts ...loggers.Option) loggers.BaseLogger { - opt := loggers.GetDefaultOptions() - for _, f := range opts { - f(&opt) - } - - levelVar := &slog.LevelVar{} - levelVar.Set(toSlogLevel(opt.Level)) - - if handlerOpts == nil { - handlerOpts = &slog.HandlerOptions{AddSource: false, Level: levelVar} - } else { - handlerOpts.Level = levelVar - } - - var handler slog.Handler - if opt.JSONLogs { - handler = slog.NewJSONHandler(buf, handlerOpts) - } else { - handler = slog.NewTextHandler(buf, handlerOpts) - } - - return &logger{ - handler: handler, - levelVar: levelVar, - opt: opt, - } -} - -func wireCompatHandlerOpts(opt loggers.Options) *slog.HandlerOptions { - return &slog.HandlerOptions{ - AddSource: false, - ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { - if a.Key == slog.TimeKey { - a.Key = opt.TimestampFieldName - } - if a.Key == slog.LevelKey { - a.Key = opt.LevelFieldName - if lvl, ok := a.Value.Any().(slog.Level); ok { - a.Value = slog.StringValue(fromSlogLevel(lvl).String()) - } - } - return a - }, - } -} - -func newWireCompatLogger(buf *bytes.Buffer, opts ...loggers.Option) loggers.BaseLogger { - opt := loggers.GetDefaultOptions() - for _, f := range opts { - f(&opt) - } - return newBufferedLogger(buf, wireCompatHandlerOpts(opt), opts...) -} - -func TestNewLogger(t *testing.T) { - l := NewLogger() - if l == nil { - t.Fatal("expected non-nil logger") - } - var _ loggers.BaseLogger = l -} - -func TestLogLevels(t *testing.T) { - tests := []struct { - name string - level loggers.Level - logLevel loggers.Level - expect bool - }{ - {"error at info level", loggers.ErrorLevel, loggers.InfoLevel, true}, - {"info at info level", loggers.InfoLevel, loggers.InfoLevel, true}, - {"debug at info level", loggers.DebugLevel, loggers.InfoLevel, false}, - {"warn at error level", loggers.WarnLevel, loggers.ErrorLevel, false}, - {"debug at debug level", loggers.DebugLevel, loggers.DebugLevel, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithLevel(tt.logLevel)) - l.Log(context.Background(), tt.level, 1, "msg", "test message") - got := buf.String() - if tt.expect && got == "" { - t.Errorf("expected output but got none") - } - if !tt.expect && got != "" { - t.Errorf("expected no output but got: %s", got) - } - }) - } -} - -func TestContextFields(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - ctx := context.Background() - ctx = loggers.AddToLogContext(ctx, "request_id", "abc-123") - ctx = loggers.AddToLogContext(ctx, "service", "test-svc") - - l.Log(ctx, loggers.InfoLevel, 1, "msg", "hello") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON output: %v\nraw: %s", err, buf.String()) - } - - if m["request_id"] != "abc-123" { - t.Errorf("expected request_id=abc-123, got %v", m["request_id"]) - } - if m["service"] != "test-svc" { - t.Errorf("expected service=test-svc, got %v", m["service"]) - } -} - -func TestCallerInfo(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(true)) - - // skip=0 because we're calling the backend directly (no wrapper layer). - l.Log(context.Background(), loggers.InfoLevel, 0, "msg", "with caller") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - caller, ok := m["caller"].(string) - if !ok || caller == "" { - t.Errorf("expected caller field, got %v", m["caller"]) - } - if !strings.Contains(caller, "slog_test.go") { - t.Errorf("expected caller to contain slog_test.go, got %s", caller) - } -} - -func TestCallerInfoDisabled(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "no caller") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - if _, ok := m["caller"]; ok { - t.Errorf("expected no caller field, but got one: %v", m["caller"]) - } -} - -func TestSetLevel(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithLevel(loggers.ErrorLevel)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "should not appear") - if buf.Len() > 0 { - t.Errorf("expected no output at info level with error-only logger, got: %s", buf.String()) - } - - l.SetLevel(loggers.DebugLevel) - if l.GetLevel() != loggers.DebugLevel { - t.Errorf("expected DebugLevel after SetLevel, got %v", l.GetLevel()) - } - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "should appear now") - if buf.Len() == 0 { - t.Error("expected output after SetLevel to DebugLevel") - } -} - -func TestJSONOutput(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "json test", "key", "value") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("expected valid JSON, got: %s", buf.String()) - } - if m["msg"] != "json test" { - t.Errorf("expected msg=json test, got %v", m["msg"]) - } - if m["key"] != "value" { - t.Errorf("expected key=value, got %v", m["key"]) - } -} - -func TestTextOutput(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(false), loggers.WithCallerInfo(false)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "text test", "key", "value") - - out := buf.String() - if !strings.Contains(out, "text test") { - t.Errorf("expected output to contain 'text test', got: %s", out) - } - if !strings.Contains(out, "key=value") { - t.Errorf("expected output to contain 'key=value', got: %s", out) - } -} - -func TestSingleArgMessage(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "hello world") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("expected valid JSON, got: %s", buf.String()) - } - if m["msg"] != "hello world" { - t.Errorf("expected msg='hello world', got %v", m["msg"]) - } -} - -func TestNilContext(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - l.Log(nil, loggers.InfoLevel, 1, "msg", "nil ctx") //nolint:staticcheck // testing nil context handling - if buf.Len() == 0 { - t.Error("expected output with nil context") - } -} - -func TestReentryGuard(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - ctx := context.WithValue(context.Background(), slogBackendKey{}, true) - l.Log(ctx, loggers.InfoLevel, 1, "msg", "should be dropped") - - if buf.Len() > 0 { - t.Errorf("expected no output with re-entry guard, got: %s", buf.String()) - } -} - -func TestLevelMapping(t *testing.T) { - tests := []struct { - cb loggers.Level - slog slog.Level - }{ - {loggers.DebugLevel, slog.LevelDebug}, - {loggers.InfoLevel, slog.LevelInfo}, - {loggers.WarnLevel, slog.LevelWarn}, - {loggers.ErrorLevel, slog.LevelError}, - } - - for _, tt := range tests { - got := toSlogLevel(tt.cb) - if got != tt.slog { - t.Errorf("toSlogLevel(%v) = %v, want %v", tt.cb, got, tt.slog) - } - } -} - -func TestNewLoggerWithHandler(t *testing.T) { - var buf bytes.Buffer - handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) - l := NewLoggerWithHandler(handler, loggers.WithCallerInfo(false)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "custom handler") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("expected valid JSON, got: %s", buf.String()) - } - if m["msg"] != "custom handler" { - t.Errorf("expected msg='custom handler', got %v", m["msg"]) - } -} - -func TestWireCompatibility_TimestampKey(t *testing.T) { - var buf bytes.Buffer - l := newWireCompatLogger(&buf, loggers.WithCallerInfo(false)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "wire test") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - if _, ok := m["@timestamp"]; !ok { - t.Errorf("expected @timestamp key, got keys: %v", keys(m)) - } - if _, ok := m["time"]; ok { - t.Errorf("unexpected 'time' key — should be renamed to @timestamp") - } -} - -func TestWireCompatibility_LevelValues(t *testing.T) { - tests := []struct { - level loggers.Level - expected string - }{ - {loggers.DebugLevel, "debug"}, - {loggers.InfoLevel, "info"}, - {loggers.WarnLevel, "warning"}, - {loggers.ErrorLevel, "error"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - var buf bytes.Buffer - l := newWireCompatLogger(&buf, loggers.WithCallerInfo(false), loggers.WithLevel(loggers.DebugLevel)) - l.Log(context.Background(), tt.level, 1, "msg", "level test") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - if m["level"] != tt.expected { - t.Errorf("expected level=%q, got %q", tt.expected, m["level"]) - } - }) - } -} - -func TestWireCompatibility_LevelKey(t *testing.T) { - var buf bytes.Buffer - l := newWireCompatLogger(&buf, loggers.WithCallerInfo(false), - loggers.WithLevelFieldName("severity")) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "custom level key") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - if _, ok := m["severity"]; !ok { - t.Errorf("expected 'severity' key, got keys: %v", keys(m)) - } - if _, ok := m["level"]; ok { - t.Errorf("unexpected 'level' key — should be renamed to 'severity'") - } -} - -func TestWireCompatibility_CustomTimestampKey(t *testing.T) { - var buf bytes.Buffer - l := newWireCompatLogger(&buf, loggers.WithCallerInfo(false), - loggers.WithTimestampFieldName("ts")) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "custom ts key") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - if _, ok := m["ts"]; !ok { - t.Errorf("expected 'ts' key, got keys: %v", keys(m)) - } - if _, ok := m["time"]; ok { - t.Errorf("unexpected 'time' key — should be renamed to 'ts'") - } -} - -func TestDurationFormatting(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - l.Log(context.Background(), loggers.InfoLevel, 1, "msg", "test", "took", time.Second) - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - // time.Duration should be formatted as "1s" (matching gokit), not 1000000000. - took, ok := m["took"].(string) - if !ok { - t.Fatalf("expected 'took' to be a string, got %T: %v", m["took"], m["took"]) - } - if took != "1s" { - t.Errorf("expected took=1s, got %s", took) - } -} - -// TestManyAttrsSpillsFromStackBuffer verifies that logging >8 attrs -// (which exceeds the [8]slog.Attr stack buffer) produces correct output. -func TestManyAttrsSpillsFromStackBuffer(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - args := []any{"msg", "many attrs"} - for i := 0; i < 12; i++ { - args = append(args, fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i)) - } - - l.Log(context.Background(), loggers.InfoLevel, 1, args...) - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - for i := 0; i < 12; i++ { - key := fmt.Sprintf("key%d", i) - expected := fmt.Sprintf("val%d", i) - if m[key] != expected { - t.Errorf("expected %s=%s, got %v", key, expected, m[key]) - } - } -} - -// TestMsgNotFirstKVPair verifies that when "msg" is not the first key-value pair, -// the preceding pairs are still included in the output. -func TestMsgNotFirstKVPair(t *testing.T) { - var buf bytes.Buffer - l := newBufferedLogger(&buf, nil, loggers.WithJSONLogs(true), loggers.WithCallerInfo(false)) - - // "msg" is the second kv pair — "before" should still appear. - l.Log(context.Background(), loggers.InfoLevel, 1, "before", "yes", "msg", "hello", "after", "also") - - var m map[string]any - if err := json.Unmarshal(buf.Bytes(), &m); err != nil { - t.Fatalf("failed to parse JSON: %v\nraw: %s", err, buf.String()) - } - - if m["msg"] != "hello" { - t.Errorf("expected msg=hello, got %v", m["msg"]) - } - if m["before"] != "yes" { - t.Errorf("expected before=yes, got %v", m["before"]) - } - if m["after"] != "also" { - t.Errorf("expected after=also, got %v", m["after"]) - } -} - -func keys(m map[string]any) []string { - ks := make([]string, 0, len(m)) - for k := range m { - ks = append(ks, k) - } - return ks -} - -// reentrantHandler is a slog.Handler that logs again when handling a record, -// triggering the re-entry guard. -type reentrantHandler struct { - inner slog.Handler - logger loggers.BaseLogger -} - -func (h *reentrantHandler) Enabled(ctx context.Context, level slog.Level) bool { - return h.inner.Enabled(ctx, level) -} - -func (h *reentrantHandler) Handle(ctx context.Context, record slog.Record) error { - if h.logger != nil { - // Always trigger re-entry — the slog re-entry guard is what - // prevents infinite recursion. Without the guard, this would loop forever. - h.logger.Log(ctx, loggers.InfoLevel, 1, "msg", "re-entrant log") - } - return h.inner.Handle(ctx, record) -} - -func (h *reentrantHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &reentrantHandler{inner: h.inner.WithAttrs(attrs), logger: h.logger} -} - -func (h *reentrantHandler) WithGroup(name string) slog.Handler { - return &reentrantHandler{inner: h.inner.WithGroup(name), logger: h.logger} -} - -func TestSlogReentryGuard(t *testing.T) { - var buf bytes.Buffer - inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) - rh := &reentrantHandler{inner: inner} - - lgr := NewLoggerWithHandler(rh, loggers.WithLevel(loggers.DebugLevel)) - rh.logger = lgr // wire the re-entry trigger - - // This should NOT infinite-loop thanks to the re-entry guard. - // The guard uses context.WithValue(slogBackendKey{}) to detect re-entry. - done := make(chan struct{}) - go func() { - lgr.Log(context.Background(), loggers.InfoLevel, 0, "msg", "trigger") - close(done) - }() - - select { - case <-done: - // Success: no infinite loop - case <-time.After(2 * time.Second): - t.Fatal("slog re-entry guard failed: infinite loop detected") - } - - // Parse JSON output and verify exactly one log record was written. - // The re-entrant "re-entrant log" call should be dropped by the guard. - output := buf.String() - lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) != 1 { - t.Fatalf("expected exactly 1 log line, got %d: %s", len(lines), output) - } - var record map[string]any - if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { - t.Fatalf("failed to parse log JSON: %v", err) - } - if msg, ok := record["msg"].(string); !ok || msg != "trigger" { - t.Errorf("expected msg='trigger', got %v", record["msg"]) - } -} diff --git a/loggers/stdlog/README.md b/loggers/stdlog/README.md deleted file mode 100755 index 01f2e63..0000000 --- a/loggers/stdlog/README.md +++ /dev/null @@ -1,59 +0,0 @@ - - -[![CI](https://github.com/go-coldbrew/log/actions/workflows/go.yml/badge.svg)](https://github.com/go-coldbrew/log/actions/workflows/go.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/go-coldbrew/log)](https://goreportcard.com/report/github.com/go-coldbrew/log) -[![GoDoc](https://pkg.go.dev/badge/github.com/go-coldbrew/log.svg)](https://pkg.go.dev/github.com/go-coldbrew/log) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - - - -# stdlog - -```go -import "github.com/go-coldbrew/log/loggers/stdlog" -``` - -Package stdlog provides a BaseLogger implementation for golang "log" package - -
Example -

- -This example shows how to use the stdlog backend. It uses Go's standard "log" package for output — simple but no structured formatting. - -```go -package main - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers/stdlog" -) - -func main() { - logger := stdlog.NewLogger() - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Info(ctx, "msg", "server started", "port", 8080) -} -``` - -

-
- -## Index - -- [func NewLogger\(options ...loggers.Option\) loggers.BaseLogger](<#NewLogger>) - - - -## func [NewLogger]() - -```go -func NewLogger(options ...loggers.Option) loggers.BaseLogger -``` - -NewLogger returns a BaseLogger impl for golang "log" package - -Generated by [gomarkdoc]() diff --git a/loggers/stdlog/example_test.go b/loggers/stdlog/example_test.go deleted file mode 100644 index 1f02015..0000000 --- a/loggers/stdlog/example_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package stdlog_test - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers/stdlog" -) - -// This example shows how to use the stdlog backend. -// It uses Go's standard "log" package for output — simple but no structured formatting. -func Example() { - logger := stdlog.NewLogger() - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Info(ctx, "msg", "server started", "port", 8080) -} diff --git a/loggers/stdlog/log.go b/loggers/stdlog/log.go deleted file mode 100644 index 4a5a29b..0000000 --- a/loggers/stdlog/log.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package stdlog provides a BaseLogger implementation for golang "log" package -package stdlog - -import ( - "context" - "log" - - "github.com/go-coldbrew/log/loggers" -) - -type logger struct { - level loggers.Level -} - -func (l *logger) SetLevel(level loggers.Level) { - l.level = level -} - -func (l *logger) GetLevel() loggers.Level { - return l.level -} - -func (l *logger) Log(ctx context.Context, level loggers.Level, skip int, args ...any) { - if l.level >= level { - // fetch fields from context and add them to logrus fields - ctxFields := loggers.FromContext(ctx) - if ctxFields != nil { - ctxFields.Range(func(k, v any) bool { - args = append(args, k, v) - return true - }) - } - log.Println(args...) - } -} - -// NewLogger returns a BaseLogger impl for golang "log" package -func NewLogger(options ...loggers.Option) loggers.BaseLogger { - return &logger{ - level: loggers.InfoLevel, - } -} diff --git a/loggers/zap/README.md b/loggers/zap/README.md deleted file mode 100644 index 9424eb5..0000000 --- a/loggers/zap/README.md +++ /dev/null @@ -1,72 +0,0 @@ - - -[![CI](https://github.com/go-coldbrew/log/actions/workflows/go.yml/badge.svg)](https://github.com/go-coldbrew/log/actions/workflows/go.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/go-coldbrew/log)](https://goreportcard.com/report/github.com/go-coldbrew/log) -[![GoDoc](https://pkg.go.dev/badge/github.com/go-coldbrew/log.svg)](https://pkg.go.dev/github.com/go-coldbrew/log) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - - - -# zap - -```go -import "github.com/go-coldbrew/log/loggers/zap" -``` - -Package zap provides a BaseLogger implementation for uber/zap - -
Example -

- -This example shows how to use the zap backend with JSON output. - -```go -package main - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - "github.com/go-coldbrew/log/loggers/zap" -) - -func main() { - logger := zap.NewLogger( - loggers.WithJSONLogs(true), - loggers.WithCallerInfo(true), - ) - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Info(ctx, "msg", "request handled", "method", "POST", "latency_ms", 12) -} -``` - -

-
- -## Index - -- [Constants](<#constants>) -- [func NewLogger\(options ...loggers.Option\) loggers.BaseLogger](<#NewLogger>) - - -## Constants - -COLBREW\_CALL\_STACK\_SIZE number stack frame involved between the logger call from application to zap call. - -```go -const COLBREW_CALL_STACK_SIZE = 3 -``` - - -## func [NewLogger]() - -```go -func NewLogger(options ...loggers.Option) loggers.BaseLogger -``` - - - -Generated by [gomarkdoc]() diff --git a/loggers/zap/example_test.go b/loggers/zap/example_test.go deleted file mode 100644 index 3f92627..0000000 --- a/loggers/zap/example_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package zap_test - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - "github.com/go-coldbrew/log/loggers/zap" -) - -// This example shows how to use the zap backend with JSON output. -func Example() { - logger := zap.NewLogger( - loggers.WithJSONLogs(true), - loggers.WithCallerInfo(true), - ) - log.SetLogger(log.NewLogger(logger)) - - ctx := context.Background() - log.Info(ctx, "msg", "request handled", "method", "POST", "latency_ms", 12) -} diff --git a/loggers/zap/zap.go b/loggers/zap/zap.go deleted file mode 100644 index 8afbd00..0000000 --- a/loggers/zap/zap.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package zap provides a BaseLogger implementation for uber/zap -package zap - -import ( - "context" - "fmt" - - "github.com/go-coldbrew/log/loggers" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -type logger struct { - logger *zap.SugaredLogger - opt loggers.Options - cfg zap.Config -} - -// COLBREW_CALL_STACK_SIZE number stack frame involved between the logger call from application to zap call. -const COLBREW_CALL_STACK_SIZE = 3 - -func (l *logger) Log(ctx context.Context, level loggers.Level, skip int, args ...any) { - - var msg string - // If there are odd number of elements in args, first will be treated as a message and rest will - // be key value pair to log in json format. - if len(args)%2 != 0 { - msg = fmt.Sprint(args[0]) - args = args[1:] - } - ctxFields := loggers.FromContext(ctx) - if ctxFields != nil { - ctxFields.Range(func(k, v any) bool { args = append(args, k, v); return true }) - } - - // Use structured-logging variants (Infow, Debugw, etc.) to pass fields inline - // instead of logger.With() which creates a new SugaredLogger wrapper per call. - switch level { - case loggers.DebugLevel: - l.logger.Debugw(msg, args...) - case loggers.InfoLevel: - l.logger.Infow(msg, args...) - case loggers.WarnLevel: - l.logger.Warnw(msg, args...) - default: - l.logger.Errorw(msg, args...) - } -} - -func (l *logger) GetLevel() loggers.Level { - return l.opt.Level -} - -func (l *logger) SetLevel(level loggers.Level) { - l.opt.Level = level - l.cfg.Level.SetLevel(toZapLevel(level)) -} - -func toZapLevel(level loggers.Level) zapcore.Level { - - switch level { - case loggers.DebugLevel: - return zapcore.DebugLevel - case loggers.InfoLevel: - return zap.InfoLevel - case loggers.WarnLevel: - return zap.WarnLevel - case loggers.ErrorLevel: - return zap.ErrorLevel - default: - return zapcore.ErrorLevel - } -} - -func NewLogger(options ...loggers.Option) loggers.BaseLogger { - - opt := loggers.GetDefaultOptions() - for _, f := range options { - f(&opt) - } - - zapCfg := zap.Config{ - Level: zap.NewAtomicLevelAt(toZapLevel(opt.Level)), - OutputPaths: []string{"stdout"}, - ErrorOutputPaths: []string{"stderr"}, - - EncoderConfig: zapcore.EncoderConfig{ - MessageKey: "message", - - LevelKey: opt.LevelFieldName, - EncodeLevel: zapcore.CapitalLevelEncoder, - - TimeKey: opt.TimestampFieldName, - EncodeTime: zapcore.ISO8601TimeEncoder, - - CallerKey: opt.CallerFieldName, - EncodeCaller: zapcore.FullCallerEncoder, - }, - } - - if opt.JSONLogs { - zapCfg.Encoding = "json" - } else { - zapCfg.Encoding = "console" - } - l, err := zapCfg.Build() - if err != nil { - l, _ = zap.NewProduction() - } - l = l.WithOptions(zap.AddCallerSkip(COLBREW_CALL_STACK_SIZE)) - - return &logger{ - logger: l.Sugar(), - opt: opt, - cfg: zapCfg, - } - -} diff --git a/types.go b/types.go index 0195a6d..68e4b04 100644 --- a/types.go +++ b/types.go @@ -6,19 +6,20 @@ import ( "github.com/go-coldbrew/log/loggers" ) -// Logger interface is implemnted by the log implementation to provide the log methods to the application code. +// Logger is the interface for ColdBrew's structured logging. +// It extends loggers.BaseLogger with convenience methods for each log level. type Logger interface { - loggers.BaseLogger + loggers.BaseLogger //nolint:staticcheck // intentional use for backward compatibility // Debug logs a message at level Debug. - // ctx is used to extract the request id and other context information. + // ctx is used to extract per-request context fields. Debug(ctx context.Context, args ...any) // Info logs a message at level Info. - // ctx is used to extract the request id and other context information. + // ctx is used to extract per-request context fields. Info(ctx context.Context, args ...any) // Warn logs a message at level Warn. - // ctx is used to extract the request id and other context information. + // ctx is used to extract per-request context fields. Warn(ctx context.Context, args ...any) // Error logs a message at level Error. - // ctx is used to extract the request id and other context information. + // ctx is used to extract per-request context fields. Error(ctx context.Context, args ...any) } diff --git a/wrap/gokitwrap.go b/wrap/gokitwrap.go deleted file mode 100644 index 74a15b7..0000000 --- a/wrap/gokitwrap.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package wrap provides multiple wrap functions to wrap log implementation of other log packages -package wrap - -import ( - "context" - - "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" - - basegokit "github.com/go-kit/log" -) - -/* - * gokitwrap wraps the gokit.Logger impl with log.Logger - */ -type gokitwrap struct { - l log.Logger -} - -func (g *gokitwrap) Log(keyvals ...any) error { - vals := make([]any, 0) - ctx := context.Background() - for _, val := range keyvals { - if c, ok := val.(context.Context); ok { - ctx = c - } else { - vals = append(vals, val) - } - } - g.l.Log(ctx, loggers.InfoLevel, 1, vals...) - return nil -} - -// ToGoKitLogger wraps a log.Logger to gokit/log.Logger -func ToGoKitLogger(l log.Logger) basegokit.Logger { - return &gokitwrap{ - l: l, - } -} diff --git a/wrap/slogwrap.go b/wrap/slogwrap.go index 3049ebe..8d66fac 100644 --- a/wrap/slogwrap.go +++ b/wrap/slogwrap.go @@ -6,63 +6,66 @@ import ( "strings" "github.com/go-coldbrew/log" - "github.com/go-coldbrew/log/loggers" ) +// ToSlogHandler wraps a ColdBrew log.Logger as an slog.Handler. +// +// Deprecated: With the slog-native architecture, use log.GetHandler() directly +// as it already implements slog.Handler with context field injection. +// This function is kept for backward compatibility with custom Logger implementations. +func ToSlogHandler(l log.Logger) slog.Handler { + return &slogHandler{l: l} +} + +// ToSlogLogger wraps a ColdBrew log.Logger as an *slog.Logger. +// +// Deprecated: With the slog-native architecture, the global slog.Logger +// is already configured via log.SetDefault. Use slog.Default() or +// slog.New(log.GetHandler()) instead. +func ToSlogLogger(l log.Logger) *slog.Logger { + return slog.New(ToSlogHandler(l)) +} + // slogBridgeKey is a context sentinel used to prevent infinite loops // when both the slog handler bridge and slog backend are active. type slogBridgeKey struct{} // slogHandler implements slog.Handler, routing slog log calls into -// a ColdBrew log.Logger. This allows third-party code and new code -// using slog natively to flow through ColdBrew's logging pipeline. +// a ColdBrew log.Logger. This is the legacy bridge for custom Logger +// implementations that don't expose a Handler. type slogHandler struct { - l log.Logger - // preformatted holds key-value pairs from WithAttrs, already resolved - // with the groupPrefix that was active at the time WithAttrs was called. - // This ensures attrs are not retroactively re-prefixed by later WithGroup calls. + l log.Logger preformatted []any groups []string - groupPrefix string // cached strings.Join(groups, ".") + "." + groupPrefix string } // Enabled reports whether the handler handles records at the given level. -// Respects per-request level overrides set via log.OverrideLogLevel. -// ColdBrew levels are inverted (Error=0 < Warn=1 < Info=2 < Debug=3), -// so >= means "configured level is at least as verbose as the message level." func (h *slogHandler) Enabled(ctx context.Context, level slog.Level) bool { cbLevel := h.l.GetLevel() if override, found := log.GetOverridenLogLevel(ctx); found { cbLevel = override } - return cbLevel >= fromSlogLevel(level) + return cbLevel >= log.FromSlogLevel(level) } -// The skip value accounts for the call stack between Handle and the actual caller: -// baseLog.Log → wrapper.Log → Handle → slog.(*Logger).log → slog.Info → caller const slogHandlerSkip = 6 func (h *slogHandler) Handle(ctx context.Context, record slog.Record) error { if ctx == nil { ctx = context.Background() } - // Re-entry guard: prevent infinite loops with the slog backend. if ctx.Value(slogBridgeKey{}) != nil { return nil } ctx = context.WithValue(ctx, slogBridgeKey{}, true) - cbLevel := fromSlogLevel(record.Level) + cbLevel := log.FromSlogLevel(record.Level) - // Use odd-leading form: message as first arg, then key-value pairs. - // This is the universal convention across all backends (zap, gokit, slog). args := make([]any, 0, 1+len(h.preformatted)+record.NumAttrs()*2) args = append(args, record.Message) - - // Append pre-resolved attrs (keys already include their frozen group prefix). args = append(args, h.preformatted...) - // Append record attrs with the current group prefix. record.Attrs(func(a slog.Attr) bool { args = appendAttr(args, h.groupPrefix, a, 0) return true @@ -76,8 +79,6 @@ func (h *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { if len(attrs) == 0 { return h } - // Pre-resolve attrs with the current groupPrefix so they're frozen - // and won't be affected by future WithGroup calls. var resolved []any for _, a := range attrs { resolved = appendAttr(resolved, h.groupPrefix, a, 0) @@ -103,40 +104,10 @@ func (h *slogHandler) WithGroup(name string) slog.Handler { } } -// ToSlogHandler wraps a ColdBrew log.Logger as an slog.Handler. -func ToSlogHandler(l log.Logger) slog.Handler { - return &slogHandler{l: l} -} - -// ToSlogLogger wraps a ColdBrew log.Logger as an *slog.Logger. -func ToSlogLogger(l log.Logger) *slog.Logger { - return slog.New(ToSlogHandler(l)) -} - -func fromSlogLevel(level slog.Level) loggers.Level { - switch { - case level >= slog.LevelError: - return loggers.ErrorLevel - case level >= slog.LevelWarn: - return loggers.WarnLevel - case level >= slog.LevelInfo: - return loggers.InfoLevel - default: - return loggers.DebugLevel - } -} - -// maxGroupDepth is the maximum nesting depth for slog group attributes. -// Beyond this depth, groups are replaced with a placeholder to prevent -// memory exhaustion from pathological input. const maxGroupDepth = 10 -// groupDepthExceededPlaceholder is the value used when slog group nesting -// exceeds maxGroupDepth. const groupDepthExceededPlaceholder = "[nested group depth exceeded]" -// appendAttr flattens an slog.Attr into key-value pairs, applying a group prefix. -// depth tracks the current group nesting level to cap unbounded recursion. func appendAttr(args []any, groupPrefix string, a slog.Attr, depth int) []any { a.Value = a.Value.Resolve() if a.Equal(slog.Attr{}) { diff --git a/wrap/slogwrap_test.go b/wrap/slogwrap_test.go index 3f67777..19dab2f 100644 --- a/wrap/slogwrap_test.go +++ b/wrap/slogwrap_test.go @@ -11,7 +11,6 @@ import ( "github.com/go-coldbrew/log" "github.com/go-coldbrew/log/loggers" - cbslog "github.com/go-coldbrew/log/loggers/slog" ) // captureLogger is a mock BaseLogger that records log calls for inspection. @@ -237,19 +236,19 @@ func TestLevelMapping(t *testing.T) { } for _, tt := range tests { - got := fromSlogLevel(tt.slogLevel) + got := log.FromSlogLevel(tt.slogLevel) if got != tt.cbLevel { - t.Errorf("fromSlogLevel(%v) = %v, want %v", tt.slogLevel, got, tt.cbLevel) + t.Errorf("log.FromSlogLevel(%v) = %v, want %v", tt.slogLevel, got, tt.cbLevel) } } } func TestLevelMappingNonStandard(t *testing.T) { // Levels between standard values should map to the lower bucket. - if fromSlogLevel(slog.LevelInfo+2) != loggers.InfoLevel { + if log.FromSlogLevel(slog.LevelInfo+2) != loggers.InfoLevel { t.Error("expected Info+2 to map to InfoLevel") } - if fromSlogLevel(slog.LevelDebug-4) != loggers.DebugLevel { + if log.FromSlogLevel(slog.LevelDebug-4) != loggers.DebugLevel { t.Error("expected Debug-4 to map to DebugLevel") } } @@ -456,16 +455,24 @@ func TestAppendAttr_DeepNesting(t *testing.T) { // TestReentryGuardIntegration verifies that having both the slog backend AND // the slog bridge active simultaneously does not cause an infinite loop. func TestReentryGuardIntegration(t *testing.T) { - handler := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) - log.SetLogger(log.NewLogger(cbslog.NewLoggerWithHandler(handler, loggers.WithCallerInfo(false)))) + prevSlog := slog.Default() + prevHandler := log.GetHandler() + t.Cleanup(func() { + slog.SetDefault(prevSlog) + log.SetDefault(prevHandler) + }) + + inner := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}) + h := log.NewHandlerWithInner(inner, loggers.WithCallerInfo(false)) + log.SetDefault(h) sl := ToSlogLogger(log.GetLogger()) // This would infinite-loop without re-entry guards: - // sl.Info → bridge.Handle → log.Logger.Log → slog backend.Log → (guard breaks) + // sl.Info → bridge.Handle → log.Logger.Log → Handler.Handle → (guard breaks) sl.InfoContext(context.Background(), "should not loop", "key", "value") - // Reverse: ColdBrew log through slog backend while bridge is slog default. + // Reverse: ColdBrew log through handler while bridge is slog default. slog.SetDefault(sl) log.Info(context.Background(), "msg", "reverse direction", "key", "value")