logx is a pluggable logging library for Go that integrates zerolog for high-performance structured logging and lumberjack for log file rotation.
This library is designed for easy integration into any Go application that wants the power of zerolog with seamless log rotation support from lumberjack. It helps with the usecase where a global logger is needed across different packages or modules, while still allowing for flexible centralized configuration and output formats.
By default, it includes process ID (e.g. pid) in the logs, which can be useful for debugging applications.
- Simple, pluggable package for structured logging
- Log levels: All levels that zerolog supports (i.e. Debug, Info, Warn, Error, Fatal, Panic, Trace)
- Log file rotation via lumberjack
- Includes process ID in logs for easier debugging
- Optional UTC timestamps, truncated source caller (
IncludeCaller, length viaCallerFieldLength), and full package path (IncludePackage) - Console (human-readable) or structured JSON output (
ConsoleLogging) - Persistent global fields via
SetGlobalContext - Bridges the standard library
log/slog(andgo-logr) into the same output
go get github.com/automa-saga/logxpackage main
import (
"fmt"
"github.com/automa-saga/logx"
)
func main() {
err := logx.Initialize(logx.LoggingConfig{
Level: "info",
ConsoleLogging: true,
FileLogging: true,
Directory: "/tmp/logs/myapp",
Filename: "myApp.log",
MaxSize: 10, // MB
MaxBackups: 10,
MaxAge: 30,
Compress: true,
TimeFormat: "2006-01-02T15:04:05.000Z07:00", // millisecond precision; defaults to time.RFC3339
UTC: true, // pin timestamps to UTC (default: local time)
IncludeCaller: true, // "caller" field, e.g. pkg/sub/file.go:42
CallerFieldLength: 0, // trailing segments in "caller" (0 = default 3; 1 = file name only)
IncludePackage: true, // "package" field: full import path, e.g. github.com/org/repo/pkg
})
if err != nil {
panic(err)
}
logx.As().Info().Msg("Application started")
logx.As().Debug().Str("userID", "123").Msg("Debugging details")
logx.As().Warn().Msg("This is a warning")
logx.As().Error().Err(fmt.Errorf("test error")).Msg("An error occurred")
}
# Output (with UTC, IncludeCaller, and IncludePackage)
2025-06-27T03:08:40.123Z INF acme/myapp/main.go:52 > Application started package=github.com/acme/myapp pid=35333
2025-06-27T03:08:40.124Z DBG acme/myapp/main.go:53 > Debugging details package=github.com/acme/myapp pid=35333 userID=123
2025-06-27T03:08:40.124Z WRN acme/myapp/main.go:54 > This is a warning package=github.com/acme/myapp pid=35333
2025-06-27T03:08:40.125Z ERR acme/myapp/main.go:55 > An error occurred package=github.com/acme/myapp error="test error" pid=35333To stamp the same fields (e.g. build metadata) onto every log line, register
them with SetGlobalContext. logx re-applies them whenever it (re)builds its
loggers, and they reach both As() and the slog bridge:
logx.SetGlobalContext(func(c zerolog.Context) zerolog.Context {
return c.Str("build_version", version).Str("build_commit", commit)
})Prefer this over logx.SetLogger(logx.As().With()....Logger()): that snapshots
one logger instance, is dropped by a later Initialize, and is not shared with
the slog bridge.
A logger you build yourself and install with SetLogger (for example, one that
writes to stderr instead of stdout) does not get logx's caller and package
fields. Attach CallerHook to add them, following the settings from the last
Initialize:
l := zerolog.New(os.Stderr).With().Timestamp().Logger().Hook(logx.CallerHook())
logx.SetLogger(l)NewSlogHandler routes log/slog (and, via logr.FromSlogHandler, go-logr
consumers such as controller-runtime) through logx's sinks, level, and global
fields:
slog.SetDefault(slog.New(logx.NewSlogHandler()))
// go-logr (e.g. controller-runtime):
ctrl.SetLogger(logr.FromSlogHandler(logx.NewSlogHandler()))With IncludeCaller enabled, the handler resolves the caller from the slog
record's PC (the real call site), not from the logging wrapper.
logx.As() returns a pointer to a shallow copy of the global logger (~112 bytes,
one heap allocation). For most code this is negligible. In tight loops or
high-frequency hot paths (> 1000 calls/sec), store the logger once before the
loop to avoid the per-iteration copy.
logger := logx.As() // allocate once
for _, r := range records {
logger.Debug().Str("id", r.ID).Msg("processing") // 0 allocs per iteration
}Benchmarks (Apple M1 Max, go test -bench BenchmarkAs -benchmem):
| Benchmark | ns/op | B/op | allocs/op | Notes |
|---|---|---|---|---|
BenchmarkAs_PerIteration |
34.6 | 112 | 1 | As() per loop, event disabled |
BenchmarkAs_StoredOnce |
3.2 | 0 | 0 | As() hoisted, event disabled |
BenchmarkAs_Enabled_PerIteration |
101.6 | 112 | 1 | As() per loop, event emitted |
BenchmarkAs_Enabled_StoredOnce |
69.0 | 0 | 0 | As() hoisted, event emitted |
"Enabled" vs "disabled" refers to whether the log level lets the event
actually be written. A call like logger.Info() only encodes fields and writes
output when the logger's configured level permits that severity; otherwise
zerolog short-circuits and does almost nothing.
- The disabled rows (
As_PerIteration,As_StoredOnce) call a level below the threshold, so the event is a no-op. These isolate the cost ofAs()itself — the shallow copy and its heap allocation. - The enabled rows (
As_Enabled_*) emit the event (written toio.Discardto exclude disk I/O), so they include the full real-world cost: theAs()copy plus field encoding and writing.
Either way, hoisting As() out of the loop removes the 112 B / 1 alloc per
iteration.
Hoisting As() out of the loop eliminates the 112 B / 1 alloc per iteration in
both cases. Reproduce with go test -bench BenchmarkAs -benchmem ..
MIT License. See the LICENSE file for details.