Wrapped loggers don't inherit level changes
Summary
Wrapped loggers created with .With() don't inherit log level changes made after their creation. This causes debug messages from wrapped loggers to be silently dropped even when the global log level is set to debug.
Expected Behavior
When the global log level is changed, all loggers (including wrapped ones created with .With()) should respect the new level setting.
Actual Behavior
Wrapped loggers created with .With() retain the log level that was active at the time of their creation and don't inherit subsequent level changes.
Proof of Concept
package main
import (
"os"
"github.com/charmbracelet/log"
)
func main() {
logger := log.New(os.Stdout)
logger.SetLevel(log.InfoLevel) // Set to Info initially
logger.SetReportTimestamp(false)
logger.SetReportCaller(false)
// Create wrapped logger BEFORE changing to Debug level
wrappedLogger := logger.With("component", "auth")
// Now set to Debug level
logger.SetLevel(log.DebugLevel)
// Test if both loggers show debug messages
println("=== Testing debug messages ===")
logger.Debug("This shows - original logger")
wrappedLogger.Debug("This doesn't show - wrapped logger")
println("\n=== Testing info messages ===")
logger.Info("This shows - original logger")
wrappedLogger.Info("This shows - wrapped logger")
}
Expected Output:
=== Testing debug messages ===
DEBU This shows - original logger
DEBU This doesn't show - wrapped logger component=auth
=== Testing info messages ===
INFO This shows - original logger
INFO This shows - wrapped logger component=auth
Actual Output:
=== Testing debug messages ===
DEBU This shows - original logger
=== Testing info messages ===
INFO This shows - original logger
INFO This shows - wrapped logger component=auth
Notice the wrapped logger's debug message is missing.
Root Cause
The issue is in the With() method in logger.go:
func (l *Logger) With(keyvals ...any) *Logger {
var st Styles
l.mu.Lock()
sl := *l // This copies the current level value by value
st = *l.styles
l.mu.Unlock()
// ... rest of method
return &sl
}
When sl := *l is executed, it copies the current value of the level field into a new memory location. The level checking in Log() uses atomic.LoadInt64(&l.level), which reads from each logger's individual level field rather than a shared global level.
Impact
This affects any application that:
- Creates wrapped loggers early in initialization
- Changes log levels after wrapped loggers are created (common pattern for CLI tools with verbose flags)
- Expects debug messages from wrapped loggers to appear when debug mode is enabled
Possible Solutions
- Shared level reference: Store a pointer to the parent logger's level field instead of copying the value
- Level inheritance: Make wrapped loggers check their parent's level in addition to their own
- Documentation: Clearly document that level changes don't affect existing wrapped loggers
Environment
- Go version: 1.21+
- charmbracelet/log version: v0.4.2 (likely affects other versions too)