Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,102 @@ pairs. For example, the following add `{"app": "example_app", "dc": "sjc-1"}` to
export GOLOG_LOG_LABELS="app=example_app,dc=sjc-1"
```

#### `GOLOG_CAPTURE_DEFAULT_SLOG`

When `SetupLogging()` is called, go-log automatically routes slog logs through its zap core for consistent formatting and dynamic level control (unless explicitly disabled). This means libraries using `slog` (like go-libp2p) will automatically use go-log's formatting and respect dynamic level changes (e.g., via `ipfs log level` commands).

To disable this behavior and keep `slog.Default()` unchanged, set:

```bash
export GOLOG_CAPTURE_DEFAULT_SLOG="false"
```

### Slog Integration

go-log automatically integrates with Go's `log/slog` package when `SetupLogging()` is called. This provides:

1. **Unified formatting**: slog logs use the same format as go-log (color/nocolor/json)
2. **Dynamic level control**: slog loggers respect `SetLogLevel()` and environment variables
3. **Subsystem-aware filtering**: slog loggers with subsystem attributes get per-subsystem level control

**Note**: This slog bridge exists as an intermediate solution while go-log uses zap internally. In the future, go-log may migrate from zap to native slog, which would simplify this integration.

#### How it works

Libraries like go-libp2p use gologshim to create slog loggers. When these loggers detect go-log's slog bridge, they automatically integrate with go-log's level control.

**Attributes added by gologshim:**
- `logger`: Subsystem name (e.g., "ping", "swarm2", "basichost")
- Any additional labels from `GOLOG_LOG_LABELS`

Example from go-libp2p's ping protocol:
```go
var log = logging.Logger("ping") // gologshim
log.Debug("ping error", "err", err)
```

Output when formatted by go-log (JSON format shown here, also supports color/nocolor):
```json
{
"level": "debug",
"ts": "2025-10-27T12:34:56.789+0100",
"logger": "ping",
"caller": "ping/ping.go:72",
"msg": "ping error",
"err": "connection refused"
}
```

#### Controlling slog logger levels

These loggers respect go-log's level configuration:

```bash
# Via environment variable (before daemon starts)
export GOLOG_LOG_LEVEL="error,ping=debug"

# Via API (while daemon is running)
logging.SetLogLevel("ping", "debug")
```

This works even if the logger is created lazily or hasn't been created yet. Level settings are preserved and applied when the logger is first used.

#### For library authors

If you're writing a library that uses `log/slog` and want to integrate with go-log's level control system, you can detect go-log's slog bridge using duck typing to avoid including go-log in your library's go.mod:

```go
// Check if slog.Default() is go-log's bridge
type goLogBridge interface {
GoLogBridge()
}

if _, ok := slog.Default().Handler().(goLogBridge); ok {
// go-log's bridge is active - use it for consistent formatting
// and dynamic level control via WithAttrs to add subsystem name
h := slog.Default().Handler().WithAttrs([]slog.Attr{
slog.String("logger", "mysubsystem"),
})
return slog.New(h)
}

// Fallback: create your own slog handler
```

This pattern allows libraries to integrate without adding go-log as a dependency. The `GoLogBridge()` marker method is implemented by both `zapToSlogBridge` and `subsystemAwareHandler` types in go-log's slog bridge.

For a complete example, see [go-libp2p's gologshim](https://github.com/libp2p/go-libp2p/blob/master/gologshim/gologshim.go).

#### Disabling slog integration

To disable automatic slog integration and keep `slog.Default()` unchanged:

```bash
export GOLOG_CAPTURE_DEFAULT_SLOG="false"
```

When disabled, go-libp2p's gologshim will create its own slog handlers that write to stderr.

## Contribute

Feel free to join in. All welcome. Open an [issue](https://github.com/ipfs/go-log/issues)!
Expand Down
1 change: 1 addition & 0 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (l *lockedMultiCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *za
func (l *lockedMultiCore) Write(ent zapcore.Entry, fields []zapcore.Field) error {
l.mu.RLock()
defer l.mu.RUnlock()

var errs []error
for i := range l.cores {
err := l.cores[i].Write(ent, fields)
Expand Down
57 changes: 46 additions & 11 deletions setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package log
import (
"errors"
"fmt"
"log/slog"
"os"
"regexp"
"strings"
Expand Down Expand Up @@ -35,8 +36,9 @@ const (
envLoggingFile = "GOLOG_FILE" // /path/to/file
envLoggingURL = "GOLOG_URL" // url that will be processed by sink in the zap

envLoggingOutput = "GOLOG_OUTPUT" // possible values: stdout|stderr|file combine multiple values with '+'
envLoggingLabels = "GOLOG_LOG_LABELS" // comma-separated key-value pairs, i.e. "app=example_app,dc=sjc-1"
envLoggingOutput = "GOLOG_OUTPUT" // possible values: stdout|stderr|file combine multiple values with '+'
envLoggingLabels = "GOLOG_LOG_LABELS" // comma-separated key-value pairs, i.e. "app=example_app,dc=sjc-1"
envCaptureSlog = "GOLOG_CAPTURE_DEFAULT_SLOG" // set to "false" to disable routing slog logs through go-log's zap core
)

type LogFormat int
Expand Down Expand Up @@ -156,6 +158,33 @@ func SetupLogging(cfg Config) {
levels[name] = zap.NewAtomicLevelAt(zapcore.Level(level))
}
}

// Enable slog integration by default (unless explicitly disabled via GOLOG_CAPTURE_DEFAULT_SLOG=false).
// This allows libraries using slog (like go-libp2p) to automatically use go-log's formatting
// and dynamic level control.
if os.Getenv(envCaptureSlog) != "false" {
captureSlog(loggerCore)
}
}

// captureSlog is the internal implementation that routes slog logs through go-log's zap core
func captureSlog(core zapcore.Core) {
// Check if slog.Default() is already customized (not stdlib default)
// and warn the user that we're replacing it
defaultHandler := slog.Default().Handler()
if _, isGoLogBridge := defaultHandler.(interface{ GoLogBridge() }); !isGoLogBridge {
// Not a go-log bridge, check if it's a custom handler
// We detect custom handlers by checking if it's not a standard text/json handler
// This is imperfect but reasonably safe - custom handlers are likely wrapped or different types
handlerType := fmt.Sprintf("%T", defaultHandler)
if !strings.Contains(handlerType, "slog.defaultHandler") &&
!strings.Contains(handlerType, "slog.commonHandler") {
fmt.Fprintf(os.Stderr, "WARN: go-log is replacing custom slog.Default() handler (%s). Set GOLOG_CAPTURE_DEFAULT_SLOG=false to prevent this.\n", handlerType)
}
}

bridge := newZapToSlogBridge(core)
slog.SetDefault(slog.New(bridge))
}

// SetPrimaryCore changes the primary logging core. If the SetupLogging was
Expand Down Expand Up @@ -195,8 +224,12 @@ func setAllLoggers(lvl LogLevel) {
}
}

// SetLogLevel changes the log level of a specific subsystem
// name=="*" changes all subsystems
// SetLogLevel changes the log level of a specific subsystem.
// name=="*" changes all subsystems.
//
// This function works for both native go-log loggers and slog-based loggers
// (e.g., from go-libp2p via gologshim). If the subsystem doesn't exist yet,
// a level entry is created and will be applied when the logger is created.
func SetLogLevel(name, level string) error {
lvl, err := Parse(level)
if err != nil {
Expand All @@ -210,16 +243,18 @@ func SetLogLevel(name, level string) error {
return nil
}

loggerMutex.RLock()
defer loggerMutex.RUnlock()
loggerMutex.Lock()
defer loggerMutex.Unlock()

// Check if we have a logger by that name
if _, ok := levels[name]; !ok {
return ErrNoSuchLogger
// Get or create atomic level for this subsystem
atomicLevel, ok := levels[name]
if !ok {
atomicLevel = zap.NewAtomicLevelAt(zapcore.Level(lvl))
levels[name] = atomicLevel
} else {
atomicLevel.SetLevel(zapcore.Level(lvl))
}

levels[name].SetLevel(zapcore.Level(lvl))

return nil
}

Expand Down
34 changes: 34 additions & 0 deletions setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,37 @@ func TestLogToStdoutOnly(t *testing.T) {
}
require.Contains(t, buf.String(), want)
}

func TestSetLogLevelAutoCreate(t *testing.T) {
// Save and restore original state to avoid test pollution
loggerMutex.Lock()
originalLevels := levels
levels = make(map[string]zap.AtomicLevel)
loggerMutex.Unlock()
defer func() {
loggerMutex.Lock()
levels = originalLevels
loggerMutex.Unlock()
}()

// Set level for non-existent subsystem (should succeed)
err := SetLogLevel("nonexistent", "debug")
require.NoError(t, err)

// Verify level entry was created
loggerMutex.RLock()
atomicLevel, exists := levels["nonexistent"]
loggerMutex.RUnlock()

require.True(t, exists, "level entry should be auto-created")
require.Equal(t, zapcore.DebugLevel, atomicLevel.Level())

// Change level (should update existing entry)
err = SetLogLevel("nonexistent", "error")
require.NoError(t, err)
require.Equal(t, zapcore.ErrorLevel, atomicLevel.Level())

// Invalid level should still fail
err = SetLogLevel("nonexistent", "invalid")
require.Error(t, err)
}
Loading