log icon indicating copy to clipboard operation
log copied to clipboard

Wrapped loggers don't inherit level changes

Open zx8 opened this issue 4 months ago • 0 comments

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:

  1. Creates wrapped loggers early in initialization
  2. Changes log levels after wrapped loggers are created (common pattern for CLI tools with verbose flags)
  3. Expects debug messages from wrapped loggers to appear when debug mode is enabled

Possible Solutions

  1. Shared level reference: Store a pointer to the parent logger's level field instead of copying the value
  2. Level inheritance: Make wrapped loggers check their parent's level in addition to their own
  3. 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)

zx8 avatar Sep 01 '25 15:09 zx8