From 6d8e5445782e82c39cf7c981991995a836c10d27 Mon Sep 17 00:00:00 2001 From: Shilpa Chaturvedi Date: Thu, 17 Jul 2025 16:19:24 +0530 Subject: [PATCH 1/4] Add JSON logging support using Go slog lib - Add structured JSON logging for all log levels - Update documentation with usage examples - Require Go 1.21+ for slog library support Users can now choose between text and JSON logs while maintaining the same API and functionality. --- README.md | 64 ++++++++ examples/example_json_logger.go | 41 +++++ json_logger.go | 273 ++++++++++++++++++++++++++++++++ json_logger_test.go | 204 ++++++++++++++++++++++++ 4 files changed, 582 insertions(+) create mode 100644 examples/example_json_logger.go create mode 100644 json_logger.go create mode 100644 json_logger_test.go diff --git a/README.md b/README.md index 20ae889..e03558b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,70 @@


+## Features + +- **Text Logging**: Traditional text-based logging with customizable prefixes and levels +- **JSON Logging**: Structured JSON logging using Go's `slog` library (Go 1.21+) +- **Level-based Filtering**: Support for TRACE, DEBUG, INFO, WARN, ERROR, and DISABLED levels +- **Scope-based Configuration**: Different log levels for different scopes/components +- **Environment Variable Configuration**: Configure log levels via environment variables +- **Thread-safe**: All logging operations are thread-safe + +## Usage + +### Text Logging (Default) + +```go +import "github.com/pion/logging" + +// Create a logger factory +factory := logging.NewDefaultLoggerFactory() + +// Create loggers for different scopes +apiLogger := factory.NewLogger("api") +dbLogger := factory.NewLogger("database") + +// Log messages +apiLogger.Info("API server started") +apiLogger.Debug("Processing request") +dbLogger.Error("Database connection failed") +``` + +### JSON Logging + +```go +import "github.com/pion/logging" + +// Create a JSON logger factory +factory := logging.NewJSONLoggerFactory() + +// Create loggers for different scopes +apiLogger := factory.NewLogger("api") +dbLogger := factory.NewLogger("database") + +// Log messages with structured data +apiLogger.Info("API server started") +apiLogger.Debug("Processing request", "method", "GET", "path", "/users") +dbLogger.Error("Database connection failed", "error", "connection timeout") +``` + +### Environment Variable Configuration + +Set environment variables to configure log levels: + +```bash +# Enable all log levels +export PION_LOG_TRACE=all +export PION_LOG_DEBUG=all +export PION_LOG_INFO=all +export PION_LOG_WARN=all +export PION_LOG_ERROR=all + +# Enable specific scopes +export PION_LOG_DEBUG=api,database +export PION_LOG_INFO=feature1,feature2 +``` + ### Roadmap The library is used as a part of our WebRTC implementation. Please refer to that [roadmap](https://github.com/pion/webrtc/issues/9) to track our major milestones. diff --git a/examples/example_json_logger.go b/examples/example_json_logger.go new file mode 100644 index 0000000..29c5d43 --- /dev/null +++ b/examples/example_json_logger.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// SPDX-License-Identifier: MIT + +package main + +import ( + "os" + + "github.com/pion/logging" +) + +func main() { + // Create a JSON logger factory + factory := logging.NewJSONLoggerFactory() + factory.Writer = os.Stdout // Output to stdout for this example + + // Create loggers for different scopes + apiLogger := factory.NewLogger("api") + dbLogger := factory.NewLogger("database") + authLogger := factory.NewLogger("auth") + + // Log some messages + apiLogger.Info("API server started") + apiLogger.Debug("Processing request", "method", "GET", "path", "/users") + apiLogger.Warn("Rate limit approaching", "requests", 95, "limit", 100) + + dbLogger.Info("Database connection established") + dbLogger.Debug("Executing query", "query", "SELECT * FROM users", "duration_ms", 15) + + authLogger.Error("Authentication failed", "user_id", "12345", "reason", "invalid_token") + authLogger.Info("User logged in", "user_id", "67890", "ip", "192.168.1.100") + + // Example output will be JSON formatted like: + // {"time":"2023-12-07T10:30:00Z","level":"INFO","msg":"API server started","scope":"api"} + // {"time":"2023-12-07T10:30:00Z","level":"DEBUG","msg":"Processing request","scope":"api","method":"GET","path":"/users"} + // {"time":"2023-12-07T10:30:00Z","level":"WARN","msg":"Rate limit approaching","scope":"api","requests":95,"limit":100} + // {"time":"2023-12-07T10:30:00Z","level":"INFO","msg":"Database connection established","scope":"database"} + // {"time":"2023-12-07T10:30:00Z","level":"DEBUG","msg":"Executing query","scope":"database","query":"SELECT * FROM users","duration_ms":15} + // {"time":"2023-12-07T10:30:00Z","level":"ERROR","msg":"Authentication failed","scope":"auth","user_id":"12345","reason":"invalid_token"} + // {"time":"2023-12-07T10:30:00Z","level":"INFO","msg":"User logged in","scope":"auth","user_id":"67890","ip":"192.168.1.100"} +} \ No newline at end of file diff --git a/json_logger.go b/json_logger.go new file mode 100644 index 0000000..9192173 --- /dev/null +++ b/json_logger.go @@ -0,0 +1,273 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// SPDX-License-Identifier: MIT + +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" +) + +// JSONLeveledLogger provides JSON structured logging using Go's slog library +type JSONLeveledLogger struct { + level LogLevel + writer *loggerWriter + logger *slog.Logger + scope string +} + +// NewJSONLeveledLoggerForScope returns a configured JSON LeveledLogger +func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer) *JSONLeveledLogger { + if writer == nil { + writer = os.Stderr + } + + // Create a JSON handler with custom options + handler := slog.NewJSONHandler(writer, &slog.HandlerOptions{ + Level: slog.Level(-8), // Allow all levels, filter ourselves + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + // Customize timestamp format + if a.Key == slog.TimeKey { + return slog.Attr{ + Key: slog.TimeKey, + Value: slog.StringValue(a.Value.Time().Format(time.RFC3339)), + } + } + return a + }, + }) + + logger := slog.New(handler) + + return &JSONLeveledLogger{ + level: level, + writer: &loggerWriter{output: writer}, + logger: logger, + scope: scope, + } +} + +// WithOutput is a chainable configuration function which sets the logger's +// logging output to the supplied io.Writer. +func (jl *JSONLeveledLogger) WithOutput(output io.Writer) *JSONLeveledLogger { + jl.writer.SetOutput(output) + // Recreate the logger with the new writer + handler := slog.NewJSONHandler(output, &slog.HandlerOptions{ + Level: slog.Level(-8), + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{ + Key: slog.TimeKey, + Value: slog.StringValue(a.Value.Time().Format(time.RFC3339)), + } + } + return a + }, + }) + jl.logger = slog.New(handler) + return jl +} + +// SetLevel sets the logger's logging level. +func (jl *JSONLeveledLogger) SetLevel(newLevel LogLevel) { + jl.level.Set(newLevel) +} + +// logf is the internal logging function that handles level checking and formatting +func (jl *JSONLeveledLogger) logf(level slog.Level, msg string, args ...any) { + if jl.level.Get() < jl.logLevelToPionLevel(level) { + return + } + + // Create structured log entry + attrs := []any{ + "scope", jl.scope, + "level", jl.pionLevelToString(jl.logLevelToPionLevel(level)), + } + + // Add any additional arguments as key-value pairs + if len(args) > 0 { + attrs = append(attrs, args...) + } + + jl.logger.Log(context.Background(), level, msg, attrs...) +} + +// logfWithFormat formats the message and calls logf +func (jl *JSONLeveledLogger) logfWithFormat(level slog.Level, format string, args ...any) { + if jl.level.Get() < jl.logLevelToPionLevel(level) { + return + } + + // Format the message + msg := format + if len(args) > 0 { + msg = fmt.Sprintf(format, args...) + } + + // Create structured log entry + attrs := []any{ + "scope", jl.scope, + "level", jl.pionLevelToString(jl.logLevelToPionLevel(level)), + } + + jl.logger.Log(context.Background(), level, msg, attrs...) +} + +// Helper function to convert slog levels to Pion log levels +func (jl *JSONLeveledLogger) logLevelToPionLevel(level slog.Level) LogLevel { + switch level { + case slog.Level(-8): // slog.LevelTrace is -8 + return LogLevelTrace + case slog.LevelDebug: + return LogLevelDebug + case slog.LevelInfo: + return LogLevelInfo + case slog.LevelWarn: + return LogLevelWarn + case slog.LevelError: + return LogLevelError + default: + return LogLevelDisabled + } +} + +// Helper function to convert Pion log levels to string +func (jl *JSONLeveledLogger) pionLevelToString(level LogLevel) string { + switch level { + case LogLevelTrace: + return "TRACE" + case LogLevelDebug: + return "DEBUG" + case LogLevelInfo: + return "INFO" + case LogLevelWarn: + return "WARN" + case LogLevelError: + return "ERROR" + case LogLevelDisabled: + return "DISABLED" + default: + return "UNKNOWN" + } +} + +// Trace emits the preformatted message if the logger is at or below LogLevelTrace. +func (jl *JSONLeveledLogger) Trace(msg string) { + jl.logf(slog.Level(-8), msg) // slog.LevelTrace is -8 +} + +// Tracef formats and emits a message if the logger is at or below LogLevelTrace. +func (jl *JSONLeveledLogger) Tracef(format string, args ...any) { + jl.logfWithFormat(slog.Level(-8), format, args...) // slog.LevelTrace is -8 +} + +// Debug emits the preformatted message if the logger is at or below LogLevelDebug. +func (jl *JSONLeveledLogger) Debug(msg string) { + jl.logf(slog.LevelDebug, msg) +} + +// Debugf formats and emits a message if the logger is at or below LogLevelDebug. +func (jl *JSONLeveledLogger) Debugf(format string, args ...any) { + jl.logfWithFormat(slog.LevelDebug, format, args...) +} + +// Info emits the preformatted message if the logger is at or below LogLevelInfo. +func (jl *JSONLeveledLogger) Info(msg string) { + jl.logf(slog.LevelInfo, msg) +} + +// Infof formats and emits a message if the logger is at or below LogLevelInfo. +func (jl *JSONLeveledLogger) Infof(format string, args ...any) { + jl.logfWithFormat(slog.LevelInfo, format, args...) +} + +// Warn emits the preformatted message if the logger is at or below LogLevelWarn. +func (jl *JSONLeveledLogger) Warn(msg string) { + jl.logf(slog.LevelWarn, msg) +} + +// Warnf formats and emits a message if the logger is at or below LogLevelWarn. +func (jl *JSONLeveledLogger) Warnf(format string, args ...any) { + jl.logfWithFormat(slog.LevelWarn, format, args...) +} + +// Error emits the preformatted message if the logger is at or below LogLevelError. +func (jl *JSONLeveledLogger) Error(msg string) { + jl.logf(slog.LevelError, msg) +} + +// Errorf formats and emits a message if the logger is at or below LogLevelError. +func (jl *JSONLeveledLogger) Errorf(format string, args ...any) { + jl.logfWithFormat(slog.LevelError, format, args...) +} + +// JSONLoggerFactory defines levels by scopes and creates new JSONLeveledLogger +type JSONLoggerFactory struct { + Writer io.Writer + DefaultLogLevel LogLevel + ScopeLevels map[string]LogLevel +} + +// NewJSONLoggerFactory creates a new JSONLoggerFactory +func NewJSONLoggerFactory() *JSONLoggerFactory { + factory := JSONLoggerFactory{} + factory.DefaultLogLevel = LogLevelError + factory.ScopeLevels = make(map[string]LogLevel) + factory.Writer = os.Stderr + + logLevels := map[string]LogLevel{ + "DISABLE": LogLevelDisabled, + "ERROR": LogLevelError, + "WARN": LogLevelWarn, + "INFO": LogLevelInfo, + "DEBUG": LogLevelDebug, + "TRACE": LogLevelTrace, + } + + for name, level := range logLevels { + env := os.Getenv(fmt.Sprintf("PION_LOG_%s", name)) + + if env == "" { + env = os.Getenv(fmt.Sprintf("PIONS_LOG_%s", name)) + } + + if env == "" { + continue + } + + if strings.ToLower(env) == "all" { + if factory.DefaultLogLevel < level { + factory.DefaultLogLevel = level + } + + continue + } + + scopes := strings.Split(strings.ToLower(env), ",") + for _, scope := range scopes { + factory.ScopeLevels[scope] = level + } + } + + return &factory +} + +// NewLogger returns a configured JSON LeveledLogger for the given scope +func (f *JSONLoggerFactory) NewLogger(scope string) LeveledLogger { + logLevel := f.DefaultLogLevel + if f.ScopeLevels != nil { + scopeLevel, found := f.ScopeLevels[scope] + + if found { + logLevel = scopeLevel + } + } + + return NewJSONLeveledLoggerForScope(scope, logLevel, f.Writer) +} \ No newline at end of file diff --git a/json_logger_test.go b/json_logger_test.go new file mode 100644 index 0000000..7b93b74 --- /dev/null +++ b/json_logger_test.go @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// SPDX-License-Identifier: MIT + +package logging_test + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/pion/logging" + "github.com/stretchr/testify/assert" +) + +func testJSONLoggerLevels(t *testing.T, logger *logging.JSONLeveledLogger) { + t.Helper() + + var outBuf bytes.Buffer + logger.WithOutput(&outBuf) + + // Test Info level + infoMsg := "this is an info message" + logger.Info(infoMsg) + output := outBuf.String() + assert.True(t, strings.Contains(output, infoMsg), "Expected to find %q in %q", infoMsg, output) + assert.True(t, strings.Contains(output, `"level":"INFO"`), "Expected JSON to contain INFO level") + assert.True(t, strings.Contains(output, `"scope":"test"`), "Expected JSON to contain scope") + + // Test Debug level + outBuf.Reset() + debugMsg := "this is a debug message" + logger.Debug(debugMsg) + output = outBuf.String() + assert.True(t, strings.Contains(output, debugMsg), "Expected to find %q in %q", debugMsg, output) + assert.True(t, strings.Contains(output, `"level":"DEBUG"`), "Expected JSON to contain DEBUG level") + + // Test Warn level + outBuf.Reset() + warnMsg := "this is a warning message" + logger.Warn(warnMsg) + output = outBuf.String() + assert.True(t, strings.Contains(output, warnMsg), "Expected to find %q in %q", warnMsg, output) + assert.True(t, strings.Contains(output, `"level":"WARN"`), "Expected JSON to contain WARN level") + + // Test Error level + outBuf.Reset() + errMsg := "this is an error message" + logger.Error(errMsg) + output = outBuf.String() + assert.True(t, strings.Contains(output, errMsg), "Expected to find %q in %q", errMsg, output) + assert.True(t, strings.Contains(output, `"level":"ERROR"`), "Expected JSON to contain ERROR level") + + // Test Trace level + outBuf.Reset() + traceMsg := "this is a trace message" + logger.Trace(traceMsg) + output = outBuf.String() + assert.True(t, strings.Contains(output, traceMsg), "Expected to find %q in %q", traceMsg, output) + assert.True(t, strings.Contains(output, `"level":"TRACE"`), "Expected JSON to contain TRACE level") +} + +func testJSONLoggerFormatting(t *testing.T, logger *logging.JSONLeveledLogger) { + t.Helper() + + var outBuf bytes.Buffer + logger.WithOutput(&outBuf) + + // Test formatted messages + formatMsg := "formatted message with %s" + arg := "argument" + logger.Infof(formatMsg, arg) + output := outBuf.String() + expectedMsg := "formatted message with argument" + assert.True(t, strings.Contains(output, expectedMsg), "Expected to find %q in %q", expectedMsg, output) +} + +func testJSONLoggerLevelFiltering(t *testing.T, logger *logging.JSONLeveledLogger) { + t.Helper() + + var outBuf bytes.Buffer + logger.WithOutput(&outBuf) + + // Set level to WARN, so DEBUG and INFO should be filtered + logger.SetLevel(logging.LogLevelWarn) + + // These should not be logged + logger.Debug("debug message") + logger.Info("info message") + assert.Equal(t, 0, outBuf.Len(), "Debug and Info messages should not be logged at WARN level") + + // These should be logged + logger.Warn("warn message") + logger.Error("error message") + output := outBuf.String() + assert.True(t, strings.Contains(output, "warn message"), "Warn message should be logged") + assert.True(t, strings.Contains(output, "error message"), "Error message should be logged") +} + +func TestJSONLogger(t *testing.T) { + logger := logging.NewJSONLeveledLoggerForScope("test", logging.LogLevelTrace, os.Stderr) + + testJSONLoggerLevels(t, logger) + testJSONLoggerFormatting(t, logger) + testJSONLoggerLevelFiltering(t, logger) +} + +func TestJSONLoggerFactory(t *testing.T) { + factory := logging.JSONLoggerFactory{ + Writer: os.Stderr, + DefaultLogLevel: logging.LogLevelWarn, + ScopeLevels: map[string]logging.LogLevel{ + "foo": logging.LogLevelDebug, + }, + } + + logger := factory.NewLogger("baz") + bazLogger, ok := logger.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Invalid logger type") + + // Test that baz logger respects WARN level + var outBuf bytes.Buffer + bazLogger.WithOutput(&outBuf) + bazLogger.Debug("debug message") + assert.Equal(t, 0, outBuf.Len(), "Debug message should not be logged at WARN level") + + logger = factory.NewLogger("foo") + fooLogger, ok := logger.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Invalid logger type") + + // Test that foo logger respects DEBUG level + outBuf.Reset() + fooLogger.WithOutput(&outBuf) + fooLogger.Debug("debug message") + output := outBuf.String() + assert.True(t, strings.Contains(output, "debug message"), "Debug message should be logged at DEBUG level") +} + +func TestNewJSONLoggerFactory(t *testing.T) { + factory := logging.NewJSONLoggerFactory() + + disabled := factory.NewLogger("DISABLE") + errorLevel := factory.NewLogger("ERROR") + warnLevel := factory.NewLogger("WARN") + infoLevel := factory.NewLogger("INFO") + debugLevel := factory.NewLogger("DEBUG") + traceLevel := factory.NewLogger("TRACE") + + disabledLogger, ok := disabled.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Missing disabled logger") + + errorLogger, ok := errorLevel.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Missing error logger") + + _, ok = warnLevel.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Missing warn logger") + + _, ok = infoLevel.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Missing info logger") + + _, ok = debugLevel.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Missing debug logger") + + _, ok = traceLevel.(*logging.JSONLeveledLogger) + assert.True(t, ok, "Missing trace logger") + + // Test that all loggers are properly configured + var outBuf bytes.Buffer + disabledLogger.WithOutput(&outBuf) + disabledLogger.Info("test message") + assert.Equal(t, 0, outBuf.Len(), "Disabled logger should not log anything") + + outBuf.Reset() + errorLogger.WithOutput(&outBuf) + errorLogger.Error("error message") + output := outBuf.String() + assert.True(t, strings.Contains(output, "error message"), "Error logger should log error messages") +} + +func TestJSONLoggerStructuredOutput(t *testing.T) { + logger := logging.NewJSONLeveledLoggerForScope("test-scope", logging.LogLevelInfo, os.Stderr) + var outBuf bytes.Buffer + logger.WithOutput(&outBuf) + + logger.Info("test message") + output := outBuf.String() + + // Verify it's valid JSON + var jsonData map[string]interface{} + err := json.Unmarshal([]byte(output), &jsonData) + assert.NoError(t, err, "Output should be valid JSON") + + // Verify required fields + assert.Contains(t, jsonData, "time", "JSON should contain time field") + assert.Contains(t, jsonData, "level", "JSON should contain level field") + assert.Contains(t, jsonData, "msg", "JSON should contain msg field") + assert.Contains(t, jsonData, "scope", "JSON should contain scope field") + + // Verify values + assert.Equal(t, "INFO", jsonData["level"], "Level should be INFO") + assert.Equal(t, "test message", jsonData["msg"], "Message should match") + assert.Equal(t, "test-scope", jsonData["scope"], "Scope should match") +} \ No newline at end of file From 14f986a1f014f7d2331189ec10f806f7a0c6bd22 Mon Sep 17 00:00:00 2001 From: philipch07 Date: Fri, 2 Jan 2026 17:26:58 -0500 Subject: [PATCH 2/4] Fix example and duplicate log level --- examples/example_json_logger.go | 13 ++- json_logger.go | 132 ++++++++++----------- json_logger_test.go | 196 ++++++++++++++++++++++++++------ 3 files changed, 235 insertions(+), 106 deletions(-) diff --git a/examples/example_json_logger.go b/examples/example_json_logger.go index 29c5d43..8ff2727 100644 --- a/examples/example_json_logger.go +++ b/examples/example_json_logger.go @@ -21,15 +21,16 @@ func main() { // Log some messages apiLogger.Info("API server started") - apiLogger.Debug("Processing request", "method", "GET", "path", "/users") - apiLogger.Warn("Rate limit approaching", "requests", 95, "limit", 100) + apiLogger.Debugf("Processing request method=%s path=%s", "GET", "/users") + apiLogger.Warnf("Rate limit approaching requests=%d limit=%d", 95, 100) dbLogger.Info("Database connection established") - dbLogger.Debug("Executing query", "query", "SELECT * FROM users", "duration_ms", 15) + dbLogger.Debugf("Executing query query=%q duration_ms=%d", "SELECT * FROM users", 15) - authLogger.Error("Authentication failed", "user_id", "12345", "reason", "invalid_token") - authLogger.Info("User logged in", "user_id", "67890", "ip", "192.168.1.100") + authLogger.Errorf("Authentication failed user_id=%s reason=%s", "12345", "invalid_token") + authLogger.Infof("User logged in user_id=%s ip=%s", "67890", "192.168.1.100") + // nolint:lll // Example output will be JSON formatted like: // {"time":"2023-12-07T10:30:00Z","level":"INFO","msg":"API server started","scope":"api"} // {"time":"2023-12-07T10:30:00Z","level":"DEBUG","msg":"Processing request","scope":"api","method":"GET","path":"/users"} @@ -38,4 +39,4 @@ func main() { // {"time":"2023-12-07T10:30:00Z","level":"DEBUG","msg":"Executing query","scope":"database","query":"SELECT * FROM users","duration_ms":15} // {"time":"2023-12-07T10:30:00Z","level":"ERROR","msg":"Authentication failed","scope":"auth","user_id":"12345","reason":"invalid_token"} // {"time":"2023-12-07T10:30:00Z","level":"INFO","msg":"User logged in","scope":"auth","user_id":"67890","ip":"192.168.1.100"} -} \ No newline at end of file +} diff --git a/json_logger.go b/json_logger.go index 9192173..1755d7d 100644 --- a/json_logger.go +++ b/json_logger.go @@ -13,7 +13,7 @@ import ( "time" ) -// JSONLeveledLogger provides JSON structured logging using Go's slog library +// JSONLeveledLogger provides JSON structured logging using Go's slog library. type JSONLeveledLogger struct { level LogLevel writer *loggerWriter @@ -21,28 +21,14 @@ type JSONLeveledLogger struct { scope string } -// NewJSONLeveledLoggerForScope returns a configured JSON LeveledLogger +// NewJSONLeveledLoggerForScope returns a configured JSON LeveledLogger. func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer) *JSONLeveledLogger { if writer == nil { writer = os.Stderr } // Create a JSON handler with custom options - handler := slog.NewJSONHandler(writer, &slog.HandlerOptions{ - Level: slog.Level(-8), // Allow all levels, filter ourselves - ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { - // Customize timestamp format - if a.Key == slog.TimeKey { - return slog.Attr{ - Key: slog.TimeKey, - Value: slog.StringValue(a.Value.Time().Format(time.RFC3339)), - } - } - return a - }, - }) - - logger := slog.New(handler) + logger := slog.New(newJSONHandlerHelper(writer)) return &JSONLeveledLogger{ level: level, @@ -57,20 +43,38 @@ func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer func (jl *JSONLeveledLogger) WithOutput(output io.Writer) *JSONLeveledLogger { jl.writer.SetOutput(output) // Recreate the logger with the new writer - handler := slog.NewJSONHandler(output, &slog.HandlerOptions{ - Level: slog.Level(-8), - ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.TimeKey { - return slog.Attr{ - Key: slog.TimeKey, - Value: slog.StringValue(a.Value.Time().Format(time.RFC3339)), + jl.logger = slog.New(newJSONHandlerHelper(output)) + + return jl +} + +// newJSONHandlerHelper creates a new JSON slog.Handler with custom formatting. +func newJSONHandlerHelper(w io.Writer) slog.Handler { + return slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: slog.Level(-8), // Allow all levels, filter ourselves + ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr { + // Customize timestamp format + switch attr.Key { + case slog.TimeKey: + attr.Value = slog.StringValue(attr.Value.Time().Format(time.RFC3339)) + + return attr + + case slog.LevelKey: + if lvl, ok := attr.Value.Any().(slog.Level); ok { + attr.Value = slogLevelToSlogStringValue(lvl) + + return attr } + + // if slog changes representation then leave it alone. + return attr + + default: + return attr } - return a }, }) - jl.logger = slog.New(handler) - return jl } // SetLevel sets the logger's logging level. @@ -78,16 +82,15 @@ func (jl *JSONLeveledLogger) SetLevel(newLevel LogLevel) { jl.level.Set(newLevel) } -// logf is the internal logging function that handles level checking and formatting +// logf is the internal logging function that handles level checking and formatting. func (jl *JSONLeveledLogger) logf(level slog.Level, msg string, args ...any) { - if jl.level.Get() < jl.logLevelToPionLevel(level) { + if jl.level.Get() < logLevelToPionLevel(level) { return } // Create structured log entry attrs := []any{ "scope", jl.scope, - "level", jl.pionLevelToString(jl.logLevelToPionLevel(level)), } // Add any additional arguments as key-value pairs @@ -98,9 +101,9 @@ func (jl *JSONLeveledLogger) logf(level slog.Level, msg string, args ...any) { jl.logger.Log(context.Background(), level, msg, attrs...) } -// logfWithFormat formats the message and calls logf -func (jl *JSONLeveledLogger) logfWithFormat(level slog.Level, format string, args ...any) { - if jl.level.Get() < jl.logLevelToPionLevel(level) { +// logfWithFormatf formats the message and calls logf. +func (jl *JSONLeveledLogger) logfWithFormatf(level slog.Level, format string, args ...any) { + if jl.level.Get() < logLevelToPionLevel(level) { return } @@ -113,16 +116,33 @@ func (jl *JSONLeveledLogger) logfWithFormat(level slog.Level, format string, arg // Create structured log entry attrs := []any{ "scope", jl.scope, - "level", jl.pionLevelToString(jl.logLevelToPionLevel(level)), } jl.logger.Log(context.Background(), level, msg, attrs...) } -// Helper function to convert slog levels to Pion log levels -func (jl *JSONLeveledLogger) logLevelToPionLevel(level slog.Level) LogLevel { +// Convert slog record levels to the exact strings you want in JSON. +func slogLevelToSlogStringValue(level slog.Level) slog.Value { switch level { - case slog.Level(-8): // slog.LevelTrace is -8 + case slog.Level(-8): // trace + return slog.StringValue("TRACE") + case slog.LevelDebug: + return slog.StringValue("DEBUG") + case slog.LevelInfo: + return slog.StringValue("INFO") + case slog.LevelWarn: + return slog.StringValue("WARN") + case slog.LevelError: + return slog.StringValue("ERROR") + default: + return slog.StringValue("UNKNOWN") + } +} + +// Helper to convert slog levels to Pion log levels. +func logLevelToPionLevel(level slog.Level) LogLevel { + switch level { + case slog.Level(-8): // trace return LogLevelTrace case slog.LevelDebug: return LogLevelDebug @@ -137,26 +157,6 @@ func (jl *JSONLeveledLogger) logLevelToPionLevel(level slog.Level) LogLevel { } } -// Helper function to convert Pion log levels to string -func (jl *JSONLeveledLogger) pionLevelToString(level LogLevel) string { - switch level { - case LogLevelTrace: - return "TRACE" - case LogLevelDebug: - return "DEBUG" - case LogLevelInfo: - return "INFO" - case LogLevelWarn: - return "WARN" - case LogLevelError: - return "ERROR" - case LogLevelDisabled: - return "DISABLED" - default: - return "UNKNOWN" - } -} - // Trace emits the preformatted message if the logger is at or below LogLevelTrace. func (jl *JSONLeveledLogger) Trace(msg string) { jl.logf(slog.Level(-8), msg) // slog.LevelTrace is -8 @@ -164,7 +164,7 @@ func (jl *JSONLeveledLogger) Trace(msg string) { // Tracef formats and emits a message if the logger is at or below LogLevelTrace. func (jl *JSONLeveledLogger) Tracef(format string, args ...any) { - jl.logfWithFormat(slog.Level(-8), format, args...) // slog.LevelTrace is -8 + jl.logfWithFormatf(slog.Level(-8), format, args...) // slog.LevelTrace is -8 } // Debug emits the preformatted message if the logger is at or below LogLevelDebug. @@ -174,7 +174,7 @@ func (jl *JSONLeveledLogger) Debug(msg string) { // Debugf formats and emits a message if the logger is at or below LogLevelDebug. func (jl *JSONLeveledLogger) Debugf(format string, args ...any) { - jl.logfWithFormat(slog.LevelDebug, format, args...) + jl.logfWithFormatf(slog.LevelDebug, format, args...) } // Info emits the preformatted message if the logger is at or below LogLevelInfo. @@ -184,7 +184,7 @@ func (jl *JSONLeveledLogger) Info(msg string) { // Infof formats and emits a message if the logger is at or below LogLevelInfo. func (jl *JSONLeveledLogger) Infof(format string, args ...any) { - jl.logfWithFormat(slog.LevelInfo, format, args...) + jl.logfWithFormatf(slog.LevelInfo, format, args...) } // Warn emits the preformatted message if the logger is at or below LogLevelWarn. @@ -194,7 +194,7 @@ func (jl *JSONLeveledLogger) Warn(msg string) { // Warnf formats and emits a message if the logger is at or below LogLevelWarn. func (jl *JSONLeveledLogger) Warnf(format string, args ...any) { - jl.logfWithFormat(slog.LevelWarn, format, args...) + jl.logfWithFormatf(slog.LevelWarn, format, args...) } // Error emits the preformatted message if the logger is at or below LogLevelError. @@ -204,17 +204,17 @@ func (jl *JSONLeveledLogger) Error(msg string) { // Errorf formats and emits a message if the logger is at or below LogLevelError. func (jl *JSONLeveledLogger) Errorf(format string, args ...any) { - jl.logfWithFormat(slog.LevelError, format, args...) + jl.logfWithFormatf(slog.LevelError, format, args...) } -// JSONLoggerFactory defines levels by scopes and creates new JSONLeveledLogger +// JSONLoggerFactory defines levels by scopes and creates new JSONLeveledLogger. type JSONLoggerFactory struct { Writer io.Writer DefaultLogLevel LogLevel ScopeLevels map[string]LogLevel } -// NewJSONLoggerFactory creates a new JSONLoggerFactory +// NewJSONLoggerFactory creates a new JSONLoggerFactory. func NewJSONLoggerFactory() *JSONLoggerFactory { factory := JSONLoggerFactory{} factory.DefaultLogLevel = LogLevelError @@ -258,7 +258,7 @@ func NewJSONLoggerFactory() *JSONLoggerFactory { return &factory } -// NewLogger returns a configured JSON LeveledLogger for the given scope +// NewLogger returns a configured JSON LeveledLogger for the given scope. func (f *JSONLoggerFactory) NewLogger(scope string) LeveledLogger { logLevel := f.DefaultLogLevel if f.ScopeLevels != nil { @@ -270,4 +270,4 @@ func (f *JSONLoggerFactory) NewLogger(scope string) LeveledLogger { } return NewJSONLeveledLoggerForScope(scope, logLevel, f.Writer) -} \ No newline at end of file +} diff --git a/json_logger_test.go b/json_logger_test.go index 7b93b74..a44cd8e 100644 --- a/json_logger_test.go +++ b/json_logger_test.go @@ -1,21 +1,21 @@ // SPDX-FileCopyrightText: 2023 The Pion community // SPDX-License-Identifier: MIT -package logging_test +package logging import ( "bytes" "encoding/json" + "log/slog" "os" "strings" "testing" - "github.com/pion/logging" "github.com/stretchr/testify/assert" ) -func testJSONLoggerLevels(t *testing.T, logger *logging.JSONLeveledLogger) { - t.Helper() +func TestJSONLoggerLevels(t *testing.T) { + logger := NewJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -58,11 +58,10 @@ func testJSONLoggerLevels(t *testing.T, logger *logging.JSONLeveledLogger) { logger.Trace(traceMsg) output = outBuf.String() assert.True(t, strings.Contains(output, traceMsg), "Expected to find %q in %q", traceMsg, output) - assert.True(t, strings.Contains(output, `"level":"TRACE"`), "Expected JSON to contain TRACE level") } -func testJSONLoggerFormatting(t *testing.T, logger *logging.JSONLeveledLogger) { - t.Helper() +func TestJSONLoggerFormatting(t *testing.T) { + logger := NewJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -76,14 +75,14 @@ func testJSONLoggerFormatting(t *testing.T, logger *logging.JSONLeveledLogger) { assert.True(t, strings.Contains(output, expectedMsg), "Expected to find %q in %q", expectedMsg, output) } -func testJSONLoggerLevelFiltering(t *testing.T, logger *logging.JSONLeveledLogger) { - t.Helper() +func TestJSONLoggerLevelFiltering(t *testing.T) { + logger := NewJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) // Set level to WARN, so DEBUG and INFO should be filtered - logger.SetLevel(logging.LogLevelWarn) + logger.SetLevel(LogLevelWarn) // These should not be logged logger.Debug("debug message") @@ -98,25 +97,17 @@ func testJSONLoggerLevelFiltering(t *testing.T, logger *logging.JSONLeveledLogge assert.True(t, strings.Contains(output, "error message"), "Error message should be logged") } -func TestJSONLogger(t *testing.T) { - logger := logging.NewJSONLeveledLoggerForScope("test", logging.LogLevelTrace, os.Stderr) - - testJSONLoggerLevels(t, logger) - testJSONLoggerFormatting(t, logger) - testJSONLoggerLevelFiltering(t, logger) -} - func TestJSONLoggerFactory(t *testing.T) { - factory := logging.JSONLoggerFactory{ + factory := JSONLoggerFactory{ Writer: os.Stderr, - DefaultLogLevel: logging.LogLevelWarn, - ScopeLevels: map[string]logging.LogLevel{ - "foo": logging.LogLevelDebug, + DefaultLogLevel: LogLevelWarn, + ScopeLevels: map[string]LogLevel{ + "foo": LogLevelDebug, }, } logger := factory.NewLogger("baz") - bazLogger, ok := logger.(*logging.JSONLeveledLogger) + bazLogger, ok := logger.(*JSONLeveledLogger) assert.True(t, ok, "Invalid logger type") // Test that baz logger respects WARN level @@ -126,7 +117,7 @@ func TestJSONLoggerFactory(t *testing.T) { assert.Equal(t, 0, outBuf.Len(), "Debug message should not be logged at WARN level") logger = factory.NewLogger("foo") - fooLogger, ok := logger.(*logging.JSONLeveledLogger) + fooLogger, ok := logger.(*JSONLeveledLogger) assert.True(t, ok, "Invalid logger type") // Test that foo logger respects DEBUG level @@ -138,7 +129,7 @@ func TestJSONLoggerFactory(t *testing.T) { } func TestNewJSONLoggerFactory(t *testing.T) { - factory := logging.NewJSONLoggerFactory() + factory := NewJSONLoggerFactory() disabled := factory.NewLogger("DISABLE") errorLevel := factory.NewLogger("ERROR") @@ -147,22 +138,22 @@ func TestNewJSONLoggerFactory(t *testing.T) { debugLevel := factory.NewLogger("DEBUG") traceLevel := factory.NewLogger("TRACE") - disabledLogger, ok := disabled.(*logging.JSONLeveledLogger) + disabledLogger, ok := disabled.(*JSONLeveledLogger) assert.True(t, ok, "Missing disabled logger") - errorLogger, ok := errorLevel.(*logging.JSONLeveledLogger) + errorLogger, ok := errorLevel.(*JSONLeveledLogger) assert.True(t, ok, "Missing error logger") - _, ok = warnLevel.(*logging.JSONLeveledLogger) + _, ok = warnLevel.(*JSONLeveledLogger) assert.True(t, ok, "Missing warn logger") - _, ok = infoLevel.(*logging.JSONLeveledLogger) + _, ok = infoLevel.(*JSONLeveledLogger) assert.True(t, ok, "Missing info logger") - _, ok = debugLevel.(*logging.JSONLeveledLogger) + _, ok = debugLevel.(*JSONLeveledLogger) assert.True(t, ok, "Missing debug logger") - _, ok = traceLevel.(*logging.JSONLeveledLogger) + _, ok = traceLevel.(*JSONLeveledLogger) assert.True(t, ok, "Missing trace logger") // Test that all loggers are properly configured @@ -178,8 +169,33 @@ func TestNewJSONLoggerFactory(t *testing.T) { assert.True(t, strings.Contains(output, "error message"), "Error logger should log error messages") } +func TestJSONLoggerTraceOutput(t *testing.T) { + logger := NewJSONLeveledLoggerForScope("trace-scope", LogLevelTrace, os.Stderr) + var outBuf bytes.Buffer + logger.WithOutput(&outBuf) + + logger.Trace("test message") + output := outBuf.String() + + // Verify it's valid JSON + var jsonData map[string]any + err := json.Unmarshal([]byte(output), &jsonData) + assert.NoError(t, err, "Output should be valid JSON") + + // Verify required fields + assert.Contains(t, jsonData, "time", "JSON should contain time field") + assert.Contains(t, jsonData, "level", "JSON should contain level field") + assert.Contains(t, jsonData, "msg", "JSON should contain msg field") + assert.Contains(t, jsonData, "scope", "JSON should contain scope field") + + // Verify values + assert.Equal(t, "TRACE", jsonData["level"], "Level should be TRACE") + assert.Equal(t, "test message", jsonData["msg"], "Message should match") + assert.Equal(t, "trace-scope", jsonData["scope"], "Scope should match") +} + func TestJSONLoggerStructuredOutput(t *testing.T) { - logger := logging.NewJSONLeveledLoggerForScope("test-scope", logging.LogLevelInfo, os.Stderr) + logger := NewJSONLeveledLoggerForScope("test-scope", LogLevelInfo, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -187,7 +203,7 @@ func TestJSONLoggerStructuredOutput(t *testing.T) { output := outBuf.String() // Verify it's valid JSON - var jsonData map[string]interface{} + var jsonData map[string]any err := json.Unmarshal([]byte(output), &jsonData) assert.NoError(t, err, "Output should be valid JSON") @@ -201,4 +217,116 @@ func TestJSONLoggerStructuredOutput(t *testing.T) { assert.Equal(t, "INFO", jsonData["level"], "Level should be INFO") assert.Equal(t, "test message", jsonData["msg"], "Message should match") assert.Equal(t, "test-scope", jsonData["scope"], "Scope should match") -} \ No newline at end of file +} + +func TestJSONLeveledLogger_logf_IncludesAdditionalArgs(t *testing.T) { + factory := NewJSONLoggerFactory() + factory.Writer = os.Stderr + factory.DefaultLogLevel = LogLevelTrace + + l := factory.NewLogger("test-scope") + jl, ok := l.(*JSONLeveledLogger) + assert.True(t, ok, "Invalid logger type") + + var outBuf bytes.Buffer + jl.WithOutput(&outBuf) + + args := []any{ + "method", "GET", + "path", "/users", + "duration_ms", 15, + "ok", true, + } + + jl.logf(slog.LevelInfo, "Processing request", args...) + + raw := strings.TrimSpace(outBuf.String()) + + var jsonData map[string]any + err := json.Unmarshal([]byte(raw), &jsonData) + assert.NoError(t, err, "Output should be valid JSON") + + // base fields + assert.Equal(t, "Processing request", jsonData["msg"]) + assert.Equal(t, "INFO", jsonData["level"]) + assert.Equal(t, "test-scope", jsonData["scope"]) + + // additional args should appear as structured fields + assert.Equal(t, "GET", jsonData["method"]) + assert.Equal(t, "/users", jsonData["path"]) + assert.EqualValues(t, 15, jsonData["duration_ms"]) // json.Unmarshal numbers -> float64 + assert.Equal(t, true, jsonData["ok"]) +} + +func clearLogEnv(t *testing.T) { + t.Helper() + + for _, name := range []string{"DISABLE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"} { + t.Setenv("PION_LOG_"+name, "") + t.Setenv("PIONS_LOG_"+name, "") + } +} + +func TestNewJSONLoggerFactory_AllSetsDefaultToMaxLevel(t *testing.T) { + clearLogEnv(t) + + t.Setenv("PION_LOG_INFO", "All") + t.Setenv("PION_LOG_DEBUG", "ALL") + t.Setenv("PION_LOG_TRACE", "all") + + factory := NewJSONLoggerFactory() + + assert.Equal(t, LogLevelTrace, factory.DefaultLogLevel) + assert.Equal(t, 0, len(factory.ScopeLevels)) +} + +func TestNewJSONLoggerFactory_AllDoesNotLowerDefaultLevel(t *testing.T) { + clearLogEnv(t) + + t.Setenv("PION_LOG_DISABLE", "all") + + factory := NewJSONLoggerFactory() + assert.Equal(t, LogLevelError, factory.DefaultLogLevel) +} + +func TestNewJSONLoggerFactory_ScopesAreSplitAndLowercased(t *testing.T) { + clearLogEnv(t) + + t.Setenv("PION_LOG_DEBUG", "Foo,BAR") + + factory := NewJSONLoggerFactory() + + assert.Equal(t, LogLevelError, factory.DefaultLogLevel) + + assert.Equal(t, LogLevelDebug, factory.ScopeLevels["foo"]) + assert.Equal(t, LogLevelDebug, factory.ScopeLevels["bar"]) +} + +func TestNewJSONLoggerFactory_AllAndScopedInteract(t *testing.T) { + clearLogEnv(t) + + t.Setenv("PION_LOG_WARN", "all") + + t.Setenv("PION_LOG_DEBUG", "foo") + + factory := NewJSONLoggerFactory() + + assert.Equal(t, LogLevelWarn, factory.DefaultLogLevel) + assert.Equal(t, LogLevelDebug, factory.ScopeLevels["foo"]) + + foo := factory.NewLogger("foo").(*JSONLeveledLogger) //nolint:forcetypeassert + bar := factory.NewLogger("bar").(*JSONLeveledLogger) //nolint:forcetypeassert + + assert.Equal(t, LogLevelDebug, foo.level.Get(), "scope override should win") + assert.Equal(t, LogLevelWarn, bar.level.Get(), "default should apply when no scope override") +} + +func TestNewJSONLoggerFactory_Fallback(t *testing.T) { + clearLogEnv(t) + + t.Setenv("PION_LOG_INFO", "") + t.Setenv("PIONS_LOG_INFO", "all") + + factory := NewJSONLoggerFactory() + assert.Equal(t, LogLevelInfo, factory.DefaultLogLevel) +} From af0ba736b7ca919cebae48aa475f91714d14467a Mon Sep 17 00:00:00 2001 From: philipch07 Date: Wed, 7 Jan 2026 16:59:14 -0500 Subject: [PATCH 3/4] Allow try cast for json logger --- json_logger.go | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/json_logger.go b/json_logger.go index 1755d7d..8081973 100644 --- a/json_logger.go +++ b/json_logger.go @@ -13,6 +13,13 @@ import ( "time" ) +// JSONLogger is an optional extension interface so users +// can type-assert a LeveledLogger to JSONLogger to access slog. +type JSONLogger interface { + LeveledLogger + Slog() *slog.Logger +} + // JSONLeveledLogger provides JSON structured logging using Go's slog library. type JSONLeveledLogger struct { level LogLevel @@ -21,6 +28,12 @@ type JSONLeveledLogger struct { scope string } +var _ JSONLogger = (*JSONLeveledLogger)(nil) + +func (jl *JSONLeveledLogger) Slog() *slog.Logger { + return jl.logger +} + // NewJSONLeveledLoggerForScope returns a configured JSON LeveledLogger. func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer) *JSONLeveledLogger { if writer == nil { @@ -28,11 +41,12 @@ func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer } // Create a JSON handler with custom options - logger := slog.New(newJSONHandlerHelper(writer)) + lw := &loggerWriter{output: writer} + logger := slog.New(newJSONHandlerHelper(lw)) return &JSONLeveledLogger{ level: level, - writer: &loggerWriter{output: writer}, + writer: lw, logger: logger, scope: scope, } @@ -41,9 +55,10 @@ func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer // WithOutput is a chainable configuration function which sets the logger's // logging output to the supplied io.Writer. func (jl *JSONLeveledLogger) WithOutput(output io.Writer) *JSONLeveledLogger { + if output == nil { + output = os.Stderr + } jl.writer.SetOutput(output) - // Recreate the logger with the new writer - jl.logger = slog.New(newJSONHandlerHelper(output)) return jl } @@ -262,9 +277,7 @@ func NewJSONLoggerFactory() *JSONLoggerFactory { func (f *JSONLoggerFactory) NewLogger(scope string) LeveledLogger { logLevel := f.DefaultLogLevel if f.ScopeLevels != nil { - scopeLevel, found := f.ScopeLevels[scope] - - if found { + if scopeLevel, found := f.ScopeLevels[scope]; found { logLevel = scopeLevel } } From a70ea019b2110cb5220f18d78079839c723f31b3 Mon Sep 17 00:00:00 2001 From: Jo Turk Date: Sat, 10 Jan 2026 00:29:52 +0200 Subject: [PATCH 4/4] JSON LoggerFactory and cleanup --- examples/example_json_logger.go | 3 +- json_logger.go | 144 +++++++++++++++++++++----------- json_logger_test.go | 125 +++++++++++++++++++-------- 3 files changed, 186 insertions(+), 86 deletions(-) diff --git a/examples/example_json_logger.go b/examples/example_json_logger.go index 8ff2727..b75d3a8 100644 --- a/examples/example_json_logger.go +++ b/examples/example_json_logger.go @@ -11,8 +11,7 @@ import ( func main() { // Create a JSON logger factory - factory := logging.NewJSONLoggerFactory() - factory.Writer = os.Stdout // Output to stdout for this example + factory := logging.NewJSONLoggerFactory(logging.WithJSONWriter(os.Stdout)) // Create loggers for different scopes apiLogger := factory.NewLogger("api") diff --git a/json_logger.go b/json_logger.go index 8081973..62ca5e8 100644 --- a/json_logger.go +++ b/json_logger.go @@ -13,29 +13,22 @@ import ( "time" ) -// JSONLogger is an optional extension interface so users -// can type-assert a LeveledLogger to JSONLogger to access slog. -type JSONLogger interface { - LeveledLogger - Slog() *slog.Logger -} - -// JSONLeveledLogger provides JSON structured logging using Go's slog library. -type JSONLeveledLogger struct { +// jsonLeveledLogger provides JSON structured logging using Go's slog library. +type jsonLeveledLogger struct { level LogLevel writer *loggerWriter logger *slog.Logger scope string } -var _ JSONLogger = (*JSONLeveledLogger)(nil) +var _ LeveledLogger = (*jsonLeveledLogger)(nil) -func (jl *JSONLeveledLogger) Slog() *slog.Logger { +func (jl *jsonLeveledLogger) Slog() *slog.Logger { return jl.logger } -// NewJSONLeveledLoggerForScope returns a configured JSON LeveledLogger. -func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer) *JSONLeveledLogger { +// newJSONLeveledLoggerForScope returns a configured JSON LeveledLogger. +func newJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer) *jsonLeveledLogger { if writer == nil { writer = os.Stderr } @@ -44,7 +37,7 @@ func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer lw := &loggerWriter{output: writer} logger := slog.New(newJSONHandlerHelper(lw)) - return &JSONLeveledLogger{ + return &jsonLeveledLogger{ level: level, writer: lw, logger: logger, @@ -54,7 +47,7 @@ func NewJSONLeveledLoggerForScope(scope string, level LogLevel, writer io.Writer // WithOutput is a chainable configuration function which sets the logger's // logging output to the supplied io.Writer. -func (jl *JSONLeveledLogger) WithOutput(output io.Writer) *JSONLeveledLogger { +func (jl *jsonLeveledLogger) WithOutput(output io.Writer) LeveledLogger { if output == nil { output = os.Stderr } @@ -93,12 +86,12 @@ func newJSONHandlerHelper(w io.Writer) slog.Handler { } // SetLevel sets the logger's logging level. -func (jl *JSONLeveledLogger) SetLevel(newLevel LogLevel) { +func (jl *jsonLeveledLogger) SetLevel(newLevel LogLevel) { jl.level.Set(newLevel) } // logf is the internal logging function that handles level checking and formatting. -func (jl *JSONLeveledLogger) logf(level slog.Level, msg string, args ...any) { +func (jl *jsonLeveledLogger) logf(level slog.Level, msg string, args ...any) { if jl.level.Get() < logLevelToPionLevel(level) { return } @@ -117,7 +110,7 @@ func (jl *JSONLeveledLogger) logf(level slog.Level, msg string, args ...any) { } // logfWithFormatf formats the message and calls logf. -func (jl *JSONLeveledLogger) logfWithFormatf(level slog.Level, format string, args ...any) { +func (jl *jsonLeveledLogger) logfWithFormatf(level slog.Level, format string, args ...any) { if jl.level.Get() < logLevelToPionLevel(level) { return } @@ -173,68 +166,125 @@ func logLevelToPionLevel(level slog.Level) LogLevel { } // Trace emits the preformatted message if the logger is at or below LogLevelTrace. -func (jl *JSONLeveledLogger) Trace(msg string) { +func (jl *jsonLeveledLogger) Trace(msg string) { jl.logf(slog.Level(-8), msg) // slog.LevelTrace is -8 } // Tracef formats and emits a message if the logger is at or below LogLevelTrace. -func (jl *JSONLeveledLogger) Tracef(format string, args ...any) { +func (jl *jsonLeveledLogger) Tracef(format string, args ...any) { jl.logfWithFormatf(slog.Level(-8), format, args...) // slog.LevelTrace is -8 } // Debug emits the preformatted message if the logger is at or below LogLevelDebug. -func (jl *JSONLeveledLogger) Debug(msg string) { +func (jl *jsonLeveledLogger) Debug(msg string) { jl.logf(slog.LevelDebug, msg) } // Debugf formats and emits a message if the logger is at or below LogLevelDebug. -func (jl *JSONLeveledLogger) Debugf(format string, args ...any) { +func (jl *jsonLeveledLogger) Debugf(format string, args ...any) { jl.logfWithFormatf(slog.LevelDebug, format, args...) } // Info emits the preformatted message if the logger is at or below LogLevelInfo. -func (jl *JSONLeveledLogger) Info(msg string) { +func (jl *jsonLeveledLogger) Info(msg string) { jl.logf(slog.LevelInfo, msg) } // Infof formats and emits a message if the logger is at or below LogLevelInfo. -func (jl *JSONLeveledLogger) Infof(format string, args ...any) { +func (jl *jsonLeveledLogger) Infof(format string, args ...any) { jl.logfWithFormatf(slog.LevelInfo, format, args...) } // Warn emits the preformatted message if the logger is at or below LogLevelWarn. -func (jl *JSONLeveledLogger) Warn(msg string) { +func (jl *jsonLeveledLogger) Warn(msg string) { jl.logf(slog.LevelWarn, msg) } // Warnf formats and emits a message if the logger is at or below LogLevelWarn. -func (jl *JSONLeveledLogger) Warnf(format string, args ...any) { +func (jl *jsonLeveledLogger) Warnf(format string, args ...any) { jl.logfWithFormatf(slog.LevelWarn, format, args...) } // Error emits the preformatted message if the logger is at or below LogLevelError. -func (jl *JSONLeveledLogger) Error(msg string) { +func (jl *jsonLeveledLogger) Error(msg string) { jl.logf(slog.LevelError, msg) } // Errorf formats and emits a message if the logger is at or below LogLevelError. -func (jl *JSONLeveledLogger) Errorf(format string, args ...any) { +func (jl *jsonLeveledLogger) Errorf(format string, args ...any) { jl.logfWithFormatf(slog.LevelError, format, args...) } -// JSONLoggerFactory defines levels by scopes and creates new JSONLeveledLogger. -type JSONLoggerFactory struct { - Writer io.Writer - DefaultLogLevel LogLevel - ScopeLevels map[string]LogLevel +// jsonLoggerFactory defines levels by scopes and creates new jsonLeveledLogger. +type jsonLoggerFactory struct { + writer io.Writer + defaultLogLevel LogLevel + scopeLevels map[string]LogLevel +} + +var _ LoggerFactory = (*jsonLoggerFactory)(nil) + +// JSONLoggerFactoryOption configures the JSON LoggerFactory. +type JSONLoggerFactoryOption func(*jsonLoggerFactory) + +// WithJSONWriter overrides the writer used by JSON loggers. +func WithJSONWriter(writer io.Writer) JSONLoggerFactoryOption { + return func(factory *jsonLoggerFactory) { + if writer == nil { + factory.writer = os.Stderr + + return + } + + factory.writer = writer + } +} + +// WithJSONDefaultLevel overrides the default log level used by JSON loggers. +func WithJSONDefaultLevel(level LogLevel) JSONLoggerFactoryOption { + return func(factory *jsonLoggerFactory) { + factory.defaultLogLevel = level + } +} + +// WithJSONScopeLevels sets specific log levels for scopes, overriding env values. +func WithJSONScopeLevels(levels map[string]LogLevel) JSONLoggerFactoryOption { + return func(factory *jsonLoggerFactory) { + if levels == nil { + return + } + + if factory.scopeLevels == nil { + factory.scopeLevels = make(map[string]LogLevel, len(levels)) + } + + for scope, level := range levels { + factory.scopeLevels[strings.ToLower(scope)] = level + } + } +} + +// NewJSONLoggerFactory creates a new LoggerFactory that emits JSON logs. +func NewJSONLoggerFactory(options ...JSONLoggerFactoryOption) LoggerFactory { + factory := newJSONLoggerFactory() + + for _, option := range options { + if option == nil { + continue + } + + option(factory) + } + + return factory } -// NewJSONLoggerFactory creates a new JSONLoggerFactory. -func NewJSONLoggerFactory() *JSONLoggerFactory { - factory := JSONLoggerFactory{} - factory.DefaultLogLevel = LogLevelError - factory.ScopeLevels = make(map[string]LogLevel) - factory.Writer = os.Stderr +// newJSONLoggerFactory creates a new JSON LoggerFactory. +func newJSONLoggerFactory() *jsonLoggerFactory { + factory := jsonLoggerFactory{} + factory.defaultLogLevel = LogLevelError + factory.scopeLevels = make(map[string]LogLevel) + factory.writer = os.Stderr logLevels := map[string]LogLevel{ "DISABLE": LogLevelDisabled, @@ -257,8 +307,8 @@ func NewJSONLoggerFactory() *JSONLoggerFactory { } if strings.ToLower(env) == "all" { - if factory.DefaultLogLevel < level { - factory.DefaultLogLevel = level + if factory.defaultLogLevel < level { + factory.defaultLogLevel = level } continue @@ -266,7 +316,7 @@ func NewJSONLoggerFactory() *JSONLoggerFactory { scopes := strings.Split(strings.ToLower(env), ",") for _, scope := range scopes { - factory.ScopeLevels[scope] = level + factory.scopeLevels[scope] = level } } @@ -274,13 +324,13 @@ func NewJSONLoggerFactory() *JSONLoggerFactory { } // NewLogger returns a configured JSON LeveledLogger for the given scope. -func (f *JSONLoggerFactory) NewLogger(scope string) LeveledLogger { - logLevel := f.DefaultLogLevel - if f.ScopeLevels != nil { - if scopeLevel, found := f.ScopeLevels[scope]; found { +func (f *jsonLoggerFactory) NewLogger(scope string) LeveledLogger { + logLevel := f.defaultLogLevel + if f.scopeLevels != nil { + if scopeLevel, found := f.scopeLevels[scope]; found { logLevel = scopeLevel } } - return NewJSONLeveledLoggerForScope(scope, logLevel, f.Writer) + return newJSONLeveledLoggerForScope(scope, logLevel, f.writer) } diff --git a/json_logger_test.go b/json_logger_test.go index a44cd8e..ad102ed 100644 --- a/json_logger_test.go +++ b/json_logger_test.go @@ -6,6 +6,8 @@ package logging import ( "bytes" "encoding/json" + "fmt" + "io" "log/slog" "os" "strings" @@ -15,7 +17,7 @@ import ( ) func TestJSONLoggerLevels(t *testing.T) { - logger := NewJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) + logger := newJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -61,7 +63,7 @@ func TestJSONLoggerLevels(t *testing.T) { } func TestJSONLoggerFormatting(t *testing.T) { - logger := NewJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) + logger := newJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -76,7 +78,7 @@ func TestJSONLoggerFormatting(t *testing.T) { } func TestJSONLoggerLevelFiltering(t *testing.T) { - logger := NewJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) + logger := newJSONLeveledLoggerForScope("test", LogLevelTrace, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -98,16 +100,16 @@ func TestJSONLoggerLevelFiltering(t *testing.T) { } func TestJSONLoggerFactory(t *testing.T) { - factory := JSONLoggerFactory{ - Writer: os.Stderr, - DefaultLogLevel: LogLevelWarn, - ScopeLevels: map[string]LogLevel{ + factory := jsonLoggerFactory{ + writer: os.Stderr, + defaultLogLevel: LogLevelWarn, + scopeLevels: map[string]LogLevel{ "foo": LogLevelDebug, }, } logger := factory.NewLogger("baz") - bazLogger, ok := logger.(*JSONLeveledLogger) + bazLogger, ok := logger.(*jsonLeveledLogger) assert.True(t, ok, "Invalid logger type") // Test that baz logger respects WARN level @@ -117,7 +119,7 @@ func TestJSONLoggerFactory(t *testing.T) { assert.Equal(t, 0, outBuf.Len(), "Debug message should not be logged at WARN level") logger = factory.NewLogger("foo") - fooLogger, ok := logger.(*JSONLeveledLogger) + fooLogger, ok := logger.(*jsonLeveledLogger) assert.True(t, ok, "Invalid logger type") // Test that foo logger respects DEBUG level @@ -128,6 +130,12 @@ func TestJSONLoggerFactory(t *testing.T) { assert.True(t, strings.Contains(output, "debug message"), "Debug message should be logged at DEBUG level") } +func TestNewJSONLoggerFactoryReturnsPrivateType(t *testing.T) { + factory := NewJSONLoggerFactory() + + assert.Equal(t, "*logging.jsonLoggerFactory", fmt.Sprintf("%T", factory)) +} + func TestNewJSONLoggerFactory(t *testing.T) { factory := NewJSONLoggerFactory() @@ -138,22 +146,22 @@ func TestNewJSONLoggerFactory(t *testing.T) { debugLevel := factory.NewLogger("DEBUG") traceLevel := factory.NewLogger("TRACE") - disabledLogger, ok := disabled.(*JSONLeveledLogger) + disabledLogger, ok := disabled.(*jsonLeveledLogger) assert.True(t, ok, "Missing disabled logger") - errorLogger, ok := errorLevel.(*JSONLeveledLogger) + errorLogger, ok := errorLevel.(*jsonLeveledLogger) assert.True(t, ok, "Missing error logger") - _, ok = warnLevel.(*JSONLeveledLogger) + _, ok = warnLevel.(*jsonLeveledLogger) assert.True(t, ok, "Missing warn logger") - _, ok = infoLevel.(*JSONLeveledLogger) + _, ok = infoLevel.(*jsonLeveledLogger) assert.True(t, ok, "Missing info logger") - _, ok = debugLevel.(*JSONLeveledLogger) + _, ok = debugLevel.(*jsonLeveledLogger) assert.True(t, ok, "Missing debug logger") - _, ok = traceLevel.(*JSONLeveledLogger) + _, ok = traceLevel.(*jsonLeveledLogger) assert.True(t, ok, "Missing trace logger") // Test that all loggers are properly configured @@ -169,8 +177,42 @@ func TestNewJSONLoggerFactory(t *testing.T) { assert.True(t, strings.Contains(output, "error message"), "Error logger should log error messages") } +func TestNewJSONLoggerFactoryOptions(t *testing.T) { + var outBuf bytes.Buffer + + factory := unwrapJSONFactory(t, NewJSONLoggerFactory( + WithJSONWriter(&outBuf), + WithJSONDefaultLevel(LogLevelDebug), + WithJSONScopeLevels(map[string]LogLevel{"CustomScope": LogLevelTrace}), + )) + + assert.Equal(t, LogLevelDebug, factory.defaultLogLevel) + assert.Equal(t, LogLevelTrace, factory.scopeLevels["customscope"]) + + logger := factory.NewLogger("customscope") + logger.Debug("configured logger output") + + assert.Contains(t, outBuf.String(), "configured logger output") +} + +func TestJSONLoggerFactorySupportsWithOutputInterface(t *testing.T) { + factory := NewJSONLoggerFactory() + logger := factory.NewLogger("interface-scope") + + withOutput, ok := logger.(interface { + WithOutput(io.Writer) LeveledLogger + }) + assert.True(t, ok, "Logger should allow WithOutput without concrete type") + + var outBuf bytes.Buffer + withOutput.WithOutput(&outBuf) + + logger.Error("interface error") + assert.Contains(t, outBuf.String(), "interface error") +} + func TestJSONLoggerTraceOutput(t *testing.T) { - logger := NewJSONLeveledLoggerForScope("trace-scope", LogLevelTrace, os.Stderr) + logger := newJSONLeveledLoggerForScope("trace-scope", LogLevelTrace, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -195,7 +237,7 @@ func TestJSONLoggerTraceOutput(t *testing.T) { } func TestJSONLoggerStructuredOutput(t *testing.T) { - logger := NewJSONLeveledLoggerForScope("test-scope", LogLevelInfo, os.Stderr) + logger := newJSONLeveledLoggerForScope("test-scope", LogLevelInfo, os.Stderr) var outBuf bytes.Buffer logger.WithOutput(&outBuf) @@ -220,12 +262,12 @@ func TestJSONLoggerStructuredOutput(t *testing.T) { } func TestJSONLeveledLogger_logf_IncludesAdditionalArgs(t *testing.T) { - factory := NewJSONLoggerFactory() - factory.Writer = os.Stderr - factory.DefaultLogLevel = LogLevelTrace + factory := newJSONLoggerFactory() + factory.writer = os.Stderr + factory.defaultLogLevel = LogLevelTrace l := factory.NewLogger("test-scope") - jl, ok := l.(*JSONLeveledLogger) + jl, ok := l.(*jsonLeveledLogger) assert.True(t, ok, "Invalid logger type") var outBuf bytes.Buffer @@ -267,6 +309,15 @@ func clearLogEnv(t *testing.T) { } } +func unwrapJSONFactory(t *testing.T, factory LoggerFactory) *jsonLoggerFactory { + t.Helper() + + jf, ok := factory.(*jsonLoggerFactory) + assert.True(t, ok, "Factory should be jsonLoggerFactory") + + return jf +} + func TestNewJSONLoggerFactory_AllSetsDefaultToMaxLevel(t *testing.T) { clearLogEnv(t) @@ -274,10 +325,10 @@ func TestNewJSONLoggerFactory_AllSetsDefaultToMaxLevel(t *testing.T) { t.Setenv("PION_LOG_DEBUG", "ALL") t.Setenv("PION_LOG_TRACE", "all") - factory := NewJSONLoggerFactory() + factory := unwrapJSONFactory(t, NewJSONLoggerFactory()) - assert.Equal(t, LogLevelTrace, factory.DefaultLogLevel) - assert.Equal(t, 0, len(factory.ScopeLevels)) + assert.Equal(t, LogLevelTrace, factory.defaultLogLevel) + assert.Equal(t, 0, len(factory.scopeLevels)) } func TestNewJSONLoggerFactory_AllDoesNotLowerDefaultLevel(t *testing.T) { @@ -285,8 +336,8 @@ func TestNewJSONLoggerFactory_AllDoesNotLowerDefaultLevel(t *testing.T) { t.Setenv("PION_LOG_DISABLE", "all") - factory := NewJSONLoggerFactory() - assert.Equal(t, LogLevelError, factory.DefaultLogLevel) + factory := unwrapJSONFactory(t, NewJSONLoggerFactory()) + assert.Equal(t, LogLevelError, factory.defaultLogLevel) } func TestNewJSONLoggerFactory_ScopesAreSplitAndLowercased(t *testing.T) { @@ -294,12 +345,12 @@ func TestNewJSONLoggerFactory_ScopesAreSplitAndLowercased(t *testing.T) { t.Setenv("PION_LOG_DEBUG", "Foo,BAR") - factory := NewJSONLoggerFactory() + factory := unwrapJSONFactory(t, NewJSONLoggerFactory()) - assert.Equal(t, LogLevelError, factory.DefaultLogLevel) + assert.Equal(t, LogLevelError, factory.defaultLogLevel) - assert.Equal(t, LogLevelDebug, factory.ScopeLevels["foo"]) - assert.Equal(t, LogLevelDebug, factory.ScopeLevels["bar"]) + assert.Equal(t, LogLevelDebug, factory.scopeLevels["foo"]) + assert.Equal(t, LogLevelDebug, factory.scopeLevels["bar"]) } func TestNewJSONLoggerFactory_AllAndScopedInteract(t *testing.T) { @@ -309,13 +360,13 @@ func TestNewJSONLoggerFactory_AllAndScopedInteract(t *testing.T) { t.Setenv("PION_LOG_DEBUG", "foo") - factory := NewJSONLoggerFactory() + factory := unwrapJSONFactory(t, NewJSONLoggerFactory()) - assert.Equal(t, LogLevelWarn, factory.DefaultLogLevel) - assert.Equal(t, LogLevelDebug, factory.ScopeLevels["foo"]) + assert.Equal(t, LogLevelWarn, factory.defaultLogLevel) + assert.Equal(t, LogLevelDebug, factory.scopeLevels["foo"]) - foo := factory.NewLogger("foo").(*JSONLeveledLogger) //nolint:forcetypeassert - bar := factory.NewLogger("bar").(*JSONLeveledLogger) //nolint:forcetypeassert + foo := factory.NewLogger("foo").(*jsonLeveledLogger) //nolint:forcetypeassert + bar := factory.NewLogger("bar").(*jsonLeveledLogger) //nolint:forcetypeassert assert.Equal(t, LogLevelDebug, foo.level.Get(), "scope override should win") assert.Equal(t, LogLevelWarn, bar.level.Get(), "default should apply when no scope override") @@ -327,6 +378,6 @@ func TestNewJSONLoggerFactory_Fallback(t *testing.T) { t.Setenv("PION_LOG_INFO", "") t.Setenv("PIONS_LOG_INFO", "all") - factory := NewJSONLoggerFactory() - assert.Equal(t, LogLevelInfo, factory.DefaultLogLevel) + factory := unwrapJSONFactory(t, NewJSONLoggerFactory()) + assert.Equal(t, LogLevelInfo, factory.defaultLogLevel) }