This is a working example as used by Domainr
https://go.dev/play/p/TH_HdwDaUiN
package main
import (
"context"
"fmt"
"log/slog"
"os"
"play.ground/logging"
)
func main() {
ctx := context.Background()
logger := logging.NewLogger()
logger.LogAttrs(ctx, slog.LevelDebug, "some_event", slog.String("foo", "bar"))
logger.LogAttrs(ctx, slog.LevelInfo, "some_event", slog.String("foo", "bar"))
logger.LogAttrs(ctx, slog.LevelWarn, "some_event", slog.String("foo", "bar"))
logger.LogAttrs(ctx, slog.LevelError, "some_event", slog.String("foo", "bar"))
fmt.Println("NOTICE NO DEBUG LOG ABOVE, BUT THERE IS BELOW (AFTER CHANGING LOG LEVEL)")
logging.Level.Set(slog.LevelDebug)
logger.LogAttrs(ctx, slog.LevelDebug, "some_event", slog.String("foo", "bar"))
logger = logging.NewLoggerWithOutputLevel(os.Stdout, logging.Level)
logger.LogAttrs(ctx, slog.LevelDebug, "some_event", slog.String("foo", "bar"))
logger.LogAttrs(ctx, slog.LevelInfo, "some_event", slog.String("foo", "bar"))
logger.LogAttrs(ctx, slog.LevelWarn, "some_event", slog.String("foo", "bar"))
logger.LogAttrs(ctx, slog.LevelError, "some_event", slog.String("foo", "bar"))
}
-- go.mod --
module play.ground
-- logging/logging.go --
package logging
import (
"context"
"io"
"log"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
)
// Level allows dynamically changing the output level via .Set() method.
// Defaults to [slog.LevelInfo].
var Level = new(slog.LevelVar)
// Logger describes the set of features we want to expose from log/slog.
//
// NOTE: Don't confuse our custom With() signature with (*slog.Logger).With
// We return a Logger type where the standard library returns a *slog.Logger
type Logger interface {
Enabled(ctx context.Context, level slog.Level) bool
LogAttrs(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr)
With(args ...any) Logger
_private()
}
// NewLogger returns a logging.Logger configured for stderr.
func NewLogger() Logger {
return NewLoggerWithOutputLevel(os.Stdout, Level)
}
// NewLoggerWithOutput returns a [Logger] configured with an output writer.
func NewLoggerWithOutput(w io.Writer) Logger {
return (*logger)(slog.New(slog.NewJSONHandler(w, defaultOptions()).WithAttrs(defaultAttrs())))
}
// NewLoggerWithOutputLevel returns a [Logger] configured with an output writer and Level.
func NewLoggerWithOutputLevel(w io.Writer, l slog.Leveler) Logger {
opts := defaultOptions()
opts.Level = l
return (*logger)(slog.New(slog.NewJSONHandler(w, opts).WithAttrs(defaultAttrs())))
}
// NewBareLoggerWithOutputLevel returns a [Logger] configured with an output location and [slog.Leveler].
// It does not include any additional attributes.
func NewBareLoggerWithOutputLevel(w io.Writer, l slog.Leveler) Logger {
opts := defaultOptions()
opts.Level = l
return (*logger)(slog.New(slog.NewJSONHandler(w, opts)))
}
// nolint:revive
//
//lint:ignore U1000 Prevents any other package from implementing this interface
type private struct{} //nolint:unused
// IMPORTANT: logger is an alias to slog.Logger to avoid a double-pointer deference.
// All methods off the type will need to type-cast a *logger to *slog.Logger.
// With() must additionally type-cast back to a Logger compatible type.
type logger slog.Logger
func (*logger) _private() {}
func (l *logger) Enabled(ctx context.Context, level slog.Level) bool {
return (*slog.Logger)(l).Enabled(ctx, level)
}
func (l *logger) LogAttrs(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
(*slog.Logger)(l).LogAttrs(ctx, level, msg, attrs...)
}
func (l *logger) With(args ...any) Logger {
return (*logger)((*slog.Logger)(l).With(args...))
}
// Adapt returns a [log.Logger] for use with packages that are not yet compatible with
// [log/slog].
func Adapt(l Logger, level slog.Level) *log.Logger {
// _private() ensures this type assertion cannot panic.
slogger := (*slog.Logger)(l.(*logger)) //nolint:revive,forcetypeassert
return slog.NewLogLogger(slogger.Handler(), level)
}
func defaultOptions() *slog.HandlerOptions {
return &slog.HandlerOptions{
AddSource: true,
ReplaceAttr: slogReplaceAttr,
Level: Level,
}
}
func defaultAttrs() []slog.Attr {
return []slog.Attr{slog.Group("app",
slog.String("name", "my app name"),
slog.String("version", "my app version"),
)}
}
// NullLogger discards logs.
func NullLogger() Logger {
// NOTE: We pass a level not currently defined to reduce operational overhead.
// The intent, unlike passing nil for the opts argument, is for the logger to
// not even bother generating a message that will just be discarded.
// An additional gap of 4 was used as it aligns with Go's original design.
// https://github.com/golang/go/blob/1e95fc7/src/log/slog/level.go#L34-L42
return (*logger)(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 4}))) //nolint:gomnd
}
// slogReplaceAttr adjusts the log output.
//
// - Restricts these changes to top-level keys (not keys within groups)
// - Changes default time field value to UTC time zone
// - Replaces msg key with event
// - Omits event field if empty
// - Omits error field if when nil
// - Truncates source's filename to domainr-api directory
//
// - Formats duration and delay values in microseconds as xxxxµs
//
// See https://pkg.go.dev/log/slog#HandlerOptions.ReplaceAttr
// N.B: TextHandler manages quoting attribute values as necessary.
func slogReplaceAttr(groups []string, a slog.Attr) slog.Attr {
// Limit application of these rules only to top-level keys
if len(groups) == 0 {
// Set time zone to UTC
if a.Key == slog.TimeKey {
a.Value = slog.TimeValue(a.Value.Time().UTC())
return a
}
// Use event as the default MessageKey, remove if empty
if a.Key == slog.MessageKey {
a.Key = "event"
if a.Value.String() == "" {
return slog.Attr{}
}
return a
}
// Display a 'partial' path.
// Avoids ambiguity when multiple files have the same name across packages.
// e.g. billing.go appears under 'global', 'billing' and 'server' packages.
if a.Key == slog.SourceKey {
if source, ok := a.Value.Any().(*slog.Source); ok {
a.Key = "caller"
if _, after, ok := strings.Cut(source.File, "domainr-api"+string(filepath.Separator)); ok {
source.File = after
}
}
return a
}
}
// Remove error key=value when error is nil
if a.Equal(slog.Any("error", error(nil))) {
return slog.Attr{}
}
// Present durations and delays as xxxxµs
switch a.Key {
case "dur", "delay", "p95", "previous_p95", "remaining", "max_wait":
a.Value = slog.StringValue(strconv.FormatInt(a.Value.Duration().Microseconds(), 10) + "µs")
}
return a
}