Logging with Fields
Fields are data that should appear on every log entry from a logger: request IDs, user info, session data, anything that identifies the unit of work in progress. They are the opposite of metadata, which is per-call.
loglayer.Fields is a named type over map[string]any so the compiler keeps it distinct from loglayer.Metadata (per-call) and loglayer.Data (the assembled output handed to transports and plugins).
Adding Fields
WithFields returns a new logger with the given key/value pairs merged in. The receiver is unchanged, matching zerolog, zap, slog, and logrus.
Assign the result
The compiler doesn't catch a discarded result. Always assign:
log.WithFields(loglayer.Fields{"k": "v"}) // ❌ result discarded, log unchanged
log = log.WithFields(loglayer.Fields{"k": "v"}) // ✅The same trap applies when handing the result to a function: go runHandler(log) drops the wrapper; pass log.WithFields(...) instead.
log = log.WithFields(loglayer.Fields{
"requestId": "abc-123",
"userId": "user_456",
})
log.Info("Processing request")
log.Warn("User quota exceeded")Both subsequent calls include requestId and userId. By default the keys are merged at the root of the assembled Data map:
{
"msg": "Processing request",
"requestId": "abc-123",
"userId": "user_456"
}loglayer.Fields is a type alias for map[string]any. loglayer.F is a shorter alias for the same type, for dense call sites:
log = log.WithFields(loglayer.F{"requestId": "abc"}) // same as loglayer.Fields{...}Use whichever you prefer; both compile to the same map[string]any.
The map is not deep-copied
LogLayer doesn't clone the Fields map you pass in. If you mutate it after WithFields returns, transports that retain the map (the testing transport, some async transports) will see the mutation. Treat the map as read-only after handing it off, or build a fresh one per call. See Thread Safety for the full per-method contract.
Per-Request Loggers
The shape above is the pattern for HTTP handlers, workers, and anything else where you want request-scoped fields without leaking across concurrent operations:
var serverLog = loglayer.New(...) // shared across all handlers
func handler(w http.ResponseWriter, r *http.Request) {
reqLog := serverLog.WithFields(loglayer.Fields{
"requestId": r.Header.Get("X-Request-ID"),
})
reqLog.Info("handling request") // includes requestId
}reqLog is goroutine-local; serverLog is unchanged. Concurrent handlers each get their own derived logger. If you do this in every handler, look at integrations/loghttp which wraps it as one-line middleware.
Calling WithFields Multiple Times
Each call returns a logger that inherits the previous logger's fields:
log = log.WithFields(loglayer.Fields{"a": 1})
log = log.WithFields(loglayer.Fields{"b": 2})
// Logger now carries {a: 1, b: 2}Passing nil or an empty map returns a clone with no additions.
Nesting Fields Under a Single Key
By default field keys merge at the root. To nest them under one key, set FieldsKey:
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
FieldsKey: "fields",
})
log = log.WithFields(loglayer.Fields{"requestId": "abc"})
log.Info("ok"){
"msg": "ok",
"fields": { "requestId": "abc" }
}Reading Current Fields
fields := log.GetFields()
// returns a shallow copy; mutating it does not affect the loggerClearing Fields
WithoutFields returns a new logger with the given keys removed. With no arguments, all fields are cleared.
// Remove all
log = log.WithoutFields()
// Remove specific keys
log = log.WithoutFields("requestId")
log = log.WithoutFields("requestId", "userId")Chains compose because each method returns the new logger:
log = log.WithFields(loglayer.Fields{"a": 1, "b": 2, "c": 3}).
WithoutFields("a")
log.Info("only b and c remain")Muting Fields
MuteFields and UnmuteFields mutate the logger in place. The state is atomic.Bool so concurrent reads from the dispatch path are safe, but flipping the toggle mid-emission can interleave: some entries see the pre-toggle state, others the post. Treat them as setup-time admin toggles. For a clean cutover, route through a feature flag or level toggle.
log.MuteFields() // skip fields in emit
log.UnmuteFields() // re-enableOr set it on the config:
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
MuteFields: true,
})Combining with Metadata and Errors
log = log.WithFields(loglayer.Fields{"requestId": "abc"})
log.WithMetadata(loglayer.Metadata{"duration_ms": 120}).
WithError(err).
Error("request failed"){
"msg": "request failed",
"requestId": "abc",
"duration_ms": 120,
"err": { "message": "..." }
}Mutating fields with a plugin
If you want to redact, rename, or otherwise rewrite fields globally before they're stored, register a plugin with an OnFieldsCalled hook. See Plugins and the built-in redact plugin.
