github.com/toaweme/log is a thin layer over the standard library's log/slog.
Everything is an ordinary slog.Handler, so it composes with the stdlib and any
other handler you already use. It has zero dependencies. It adds:
log.New(...)assembles outputs, a level, and filters without hand-wiring handlers.- Filtering - drop noisy records or shorten fat attribute values, by level,
message, or attribute match (with
*prefix wildcards). - Fan-out - send one record to several outputs at once (console + file + ...).
- Custom levels -
TRACEbelowDEBUGandFATALaboveERROR, rendered with their names instead of slog's numeric fallback. log.Discard()- a silentlog.Loggerfor tests and libraries that should produce no output.
go get github.com/toaweme/logFor app code that just wants to log, use the package-level helpers. They write
text to stdout at DEBUG out of the box, no setup:
log.Info("server", "port", 8080)
log.Error("request", "err", err)
log.Trace("entered", "i", i)
log.SetLevel(slog.LevelInfo) // raise the thresholdWhen you're ready to inject a logger instead of reaching for the global, build
one with log.New.
log.New takes a handful of options and assembles the handlers for you. With no
options it writes text to stdout at DEBUG.
logger := log.New(
log.WithText(os.Stdout), // text output
log.WithLevel(slog.LevelInfo),
)
logger.Info("ready")
logger = logger.With("svc", "api") // every record now carries svc=apilog.Logger is the interface you pass around. It is itself a slog.Handler, so
it drops into anything that expects one.
| Option | What it adds |
|---|---|
log.WithText(w) |
a text handler writing to w |
log.WithJSON(w) |
a JSON handler writing to w |
log.WithOutput(h) |
any slog.Handler you already have (memory sink, exporter, ...) |
log.WithLevel(l) |
minimum level for the Text/JSON outputs (default DEBUG) |
log.WithFilters(f...) |
wraps every output in a FilterHandler |
Pass as many outputs as you like; they fan out automatically.
This package never imports a rotation library, so it stays dependency-free.
log.WithJSON takes an io.Writer, so pass your own rotating writer (here
lumberjack) to it:
logger := log.New(
log.WithText(os.Stdout),
log.WithJSON(&lumberjack.Logger{
Filename: "/var/log/app.log",
MaxSize: 20, // MB
MaxBackups: 5,
Compress: true,
}),
)Human-readable text on the console, structured JSON in a rotated file, from one logger.
Build the logger you want once at startup and install it, so log.Info and
friends route through it:
func setupLogging(path string) {
logger := log.New(
log.WithText(os.Stdout),
log.WithJSON(&lumberjack.Logger{Filename: path, MaxSize: 20, MaxBackups: 5, Compress: true}),
log.WithFilters(
log.Deny().Attr("component", "cache*"), // hush a chatty subsystem
),
)
log.SetDefault(logger)
}After SetDefault, log.Info(...) writes to both outputs and obeys the filters.
Use log.WithOutput to add any handler you have, like one that pushes records to
subscribers for a UI:
mem := NewMemoryHandler(subscribers...) // your own slog.Handler
logger := log.New(
log.WithText(os.Stdout),
log.WithOutput(mem),
).With("pid", os.Getpid())Building a raw handler yourself? Pass
log.HandlerOptions(level)as its*slog.HandlerOptionsso it renders the customTRACE/FATALlevel names the same wayText/JSONdo.
Depend on the log.Logger interface, not a global. It keeps types testable and
mockable:
type Server struct {
log log.Logger
}
func NewServer(l log.Logger) *Server {
return &Server{log: l.With("component", "server")}
}
func (s *Server) handle() {
s.log.Debug("handling request")
}Pass log.New(...) in production and log.Default() (or a buffer-backed
log.New(log.WithText(&buf))) in tests.
When a test or a library just needs a log.Logger that produces no output, use
log.Discard(). It drops every record.
srv := NewServer(log.Discard()) // logs nothing
func TestThing(t *testing.T) {
thing := New(log.Discard()) // keep test output clean
// ...
}It is the idiomatic null logger for this package, the equivalent of wiring up
slog.New(slog.DiscardHandler) yourself.
log.WithFilters wraps your outputs in a FilterHandler that runs an ordered
list of filters over each record. Filters are built fluently:
log.New(
log.WithText(os.Stdout),
log.WithFilters(
// drop everything below Info (a level floor)
log.Deny().Below(slog.LevelInfo),
// drop a chatty subsystem; * is a prefix match
log.Deny().Attr("component", "cache-*"),
// truncate the fat "body" attr to 200 chars wherever it appears
log.Shorten("body").Limit(200),
),
)Builders:
log.Deny()drops matching records.log.Allow()passes matching records through unchanged.log.Shorten(keys...)truncates the given attribute values (default limit 100, change with.Limit(n)).
Match criteria (chain as many as you need; all must match):
.Message("...")- exact message match..Attr(key, val)- attribute equalsval; avalending in*is a prefix match. The record's message is available under the synthetic"msg"key..Below(level)- matches records strictly belowlevel. Paired withDenyit acts as a floor.
Filters can be changed at runtime on a *FilterHandler via AddFilter and
SetFilters; both are safe to call while logging.
The primitives log.New builds on are exported for hand-assembly:
// fan one record out to several handlers
multi := log.NewMultiHandler(
slog.NewTextHandler(os.Stdout, log.HandlerOptions(slog.LevelDebug)),
slog.NewJSONHandler(file, log.HandlerOptions(slog.LevelDebug)),
)
// wrap any handler in filters
filtered := log.NewFilterHandler(multi, log.Deny().Below(slog.LevelInfo))
logger := log.Wrap(slog.New(filtered)) // adopt an existing *slog.LoggerA MultiHandler drops a record only when every child would discard it, and one
failing output does not stop the others (errors are joined). log.Wrap adopts
any *slog.Logger as a log.Logger; logger.Slog() gets the *slog.Logger
back.
The custom levels are log.LevelTrace (below DEBUG) and log.LevelFatal
(above ERROR). Every log.Logger has Trace/Fatal helpers, and
WithLevel returns a logger at a new threshold while keeping the same outputs:
quiet := logger.WithLevel(slog.LevelError) // same outputs, higher thresholdFataldoes not exit. It logs aFATALrecord and returns.slogitself ships noFatal, andos.Exitinside a logging call skips deferred cleanup and unflushed writers, including the FATAL record itself. If you want to exit, callos.Exit(1)yourself, after the record is flushed or shipped.Filter.Belowis a floor, not a ceiling. It matches records below the given level. See Filtering.- There is a global logger. Created in
init, writing text to stdout. It is there for convenience; prefer injectinglog.Loggerin code you care about and treat the global as a quick-start.log.SetLevelonly moves the built-in default; once youSetDefaultyour own logger, set its level when you build it.