Configuration
loglayer.New takes a loglayer.Config. Every field has a sensible default; only Transport (or Transports) is required.
loglayer.New(Config) *LogLayer is the typical entry point: it panics on misconfiguration (matches Go convention for setup-time errors). For applications that prefer explicit error handling on missing or invalid config, use loglayer.Build(Config) (*LogLayer, error), which returns loglayer.ErrNoTransport, ErrTransportAndTransports, or ErrUngroupedTransportsWithoutMode instead of panicking.
// Panics on misconfiguration (typical setup).
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
})
// Or explicit error handling.
log, err := loglayer.Build(loglayer.Config{
Transport: structured.New(structured.Config{}),
})Most application setup should stick with New: misconfiguration of the logger is a programmer error, and panicking at construction time fails loudly rather than letting a misconfigured logger drift into production.
type Config struct {
Transport Transport // single transport (mutually exclusive with Transports)
Transports []Transport // multiple transports (mutually exclusive with Transport)
Plugins []Plugin // plugins to register at construction time
Prefix string // surfaced to transports as TransportParams.Prefix
Disabled bool // suppress all output (default: false)
Level LogLevel // initial level threshold (default: every level enabled)
ErrorSerializer ErrorSerializer // customize error rendering
ErrorFieldName string // key for serialized error (default: "err")
CopyMsgOnOnlyError bool // copy err.Error() into the message in ErrorOnly
FieldsKey string // nest fields under this key (default: merged at root)
MetadataFieldName string // nest metadata under this key (default: "metadata")
FlattenMetadata bool // v2 shape opt-out: flatten map metadata at root when MetadataFieldName is unset
MuteFields bool // disable fields in output
MuteMetadata bool // disable metadata in output
DisableFatalExit bool // skip os.Exit(1) after a Fatal log
TransportCloseTimeout time.Duration // bound for transport drain on Fatal/RemoveTransport (default 5s)
OnTransportPanic func(*RecoveredPanicError) // opt-in callback recovering transport SendToLogger panics
Source SourceConfig // call-site capture (file/line/function) per emission
Routing RoutingConfig // group-based dispatch (named routing rules + active filter + ungrouped mode)
}
type SourceConfig struct {
Enabled bool // capture file/line/function on every emission (default: false)
FieldName string // output key (default: "source")
}
type RoutingConfig struct {
Groups map[string]LogGroup // named routing rules (see Groups)
ActiveGroups []string // restrict routing to these groups (nil/empty = no filter)
Ungrouped UngroupedRouting // how to route entries with no group tag
}New vs Build
For config loaded at runtime, use Build: Build returns an error instead of panicking, with the same validation as New (no transport, both Transport and Transports set). Keep New for programmatic setup where a bad config is a programmer error and panicking at construction time fails loudly:
log, err := loglayer.Build(loglayer.Config{
Transport: structured.New(structured.Config{}),
})
if err != nil {
return fmt.Errorf("configure logger: %w", err)
}Both report loglayer.ErrNoTransport when no transport is configured (via errors.Is on the Build error).
Libraries that need a logger should accept one from their caller instead of calling New or Build themselves; the consumer knows their own config source.
Transports
Set exactly one of Transport or Transports:
// Single transport
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
})
// Multiple: every entry fans out to all of them
loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{
console.New(console.Config{}),
structured.New(structured.Config{Writer: jsonFile}),
},
})loglayer.New panics if neither is set. Setting both panics with ErrTransportAndTransports (or Build returns it). See Multiple Transports.
Plugins
Plugins to register at construction time. Equivalent to calling log.AddPlugin for each entry after New; either form is fine.
import "go.loglayer.dev/plugins/redact/v3"
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
Plugins: []loglayer.Plugin{
redact.New(redact.Config{Keys: []string{"password", "apiKey"}}),
},
})Plugin order matters: hooks run in the order plugins were added, and each plugin sees the previous plugin's output. See Plugins for the full lifecycle.
Plugin ID is optional; LogLayer auto-generates one when you omit it. Supply your own ID when you intend to call RemovePlugin / GetPlugin later.
Routing
Config.Routing groups the named-routing knobs. When Routing.Groups is nil/empty there is no group routing: every transport receives every entry. Once configured, tag entries via WithGroup to opt them into a group's transports.
log := loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{...},
Routing: loglayer.RoutingConfig{
Groups: map[string]loglayer.LogGroup{
"database": {Transports: []string{"datadog"}, Level: loglayer.LogLevelError},
},
ActiveGroups: loglayer.ActiveGroupsFromEnv("LOGLAYER_GROUPS"), // optional env-driven filter
},
})See Groups for the full reference: per-group level filters, multi-group routing, Ungrouped behavior modes, runtime mutators.
Prefix
A string prepended (with one space) to the first string message of every log call. Useful for tagging a logger as belonging to a subsystem:
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
Prefix: "[auth]",
})
log.Info("started") // → "msg":"[auth] started"WithPrefix(prefix) returns a child logger with the prefix overridden, leaving the parent untouched.
Disabled
Set to true to suppress all log output from construction. Equivalent to calling log.DisableLogging() immediately after New:
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
Disabled: true, // every level dropped
})You can flip it at runtime with log.EnableLogging() / log.DisableLogging(). See Adjusting Log Levels.
Level
The initial level threshold, applied at construction exactly like SetLevel. Any of LogLevelTrace (5), LogLevelDebug (10), LogLevelInfo (20), LogLevelWarn (30), LogLevelError (40), LogLevelFatal (50), or LogLevelPanic (60). See Log Levels for the full list. Any level below the threshold is dropped.
The zero value means "no override": every level is enabled (the default). Levels start at LogLevelTrace, so zero is unambiguous.
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
Level: loglayer.LogLevelInfo, // trace + debug dropped from construction
})Composes with Disabled: Disabled: true suppresses everything even when Level is set.
Set it before New for config-driven loggers (env var, config file); use SetLevel at runtime to toggle live.
ErrorSerializer
A function that converts error to a map[string]any. The default returns {"message": err.Error()}. Override to capture stack traces, error chains, or library-specific fields. We recommend github.com/rotisserie/eris, its ToJSON function plugs in directly:
import "github.com/rotisserie/eris"
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
ErrorSerializer: func(err error) map[string]any {
return eris.ToJSON(err, true) // true = include stack trace
},
})See Error Handling for more options including writing your own serializer.
ErrorFieldName
The key used for the serialized error inside the log output. Defaults to "err":
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
ErrorFieldName: "error",
})CopyMsgOnOnlyError
When true, log.ErrorOnly(err) also uses err.Error() as the log message. Defaults to false, ErrorOnly produces an entry with no message and just the error in data.err. Per-call override is available via ErrorOnlyOpts.CopyMsg.
FieldsKey
By default, fields are merged at the root of the log output. Set this to nest them under a single key:
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
FieldsKey: "fields",
})
log.WithFields(loglayer.Fields{"requestId": "abc"}).Info("ok")
// {"msg":"ok","fields":{"requestId":"abc"}}See Fields.
MetadataFieldName
By default (v3), the entry's metadata nests under the "metadata" key uniformly, for both map and non-map values, across every transport. Set this to nest under a different key:
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
MetadataFieldName: "payload",
})
log.WithMetadata(loglayer.Metadata{"userId": 1234}).Info("served")
// {"msg":"served","payload":{"userId":1234}}FlattenMetadata
Set FlattenMetadata: true to restore the v2 shape: map metadata merges at the root, and non-map metadata follows each transport's historical placement. Ignored when MetadataFieldName is explicitly set.
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
FlattenMetadata: true,
})
log.WithMetadata(loglayer.Metadata{"userId": 1234}).Info("served")
// {"msg":"served","userId":1234}The resolved key ("metadata", your override, or unset with FlattenMetadata) is published to every transport (and dispatch-time plugin hooks) via loglayer.Schema; transports honor it uniformly. See Metadata.
DisableFatalExit
When true, the core skips the os.Exit(1) call that normally follows a fatal-level dispatch. Defaults to false (fatal exits, matching Go convention).
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
DisableFatalExit: true,
})
log.Fatal("logged, but process keeps running")loglayer.NewMock() enables this automatically. See Mocking and Fatal Exits the Process.
A future contributor calling log.Fatal in a worker kills the process
In service code with deferred cleanup, or from worker goroutines, a bare log.Fatal(...) kills the process immediately without running defers. Set DisableFatalExit: true on the root config for long-running services and use Error in workers (or call log.Fatal only from a coordinator that drains first). See Adjusting Log Levels for the runtime level toggles you can wire up instead.
MuteFields / MuteMetadata
Boolean flags that suppress fields or metadata from output. The data is still tracked on the logger, only the emit step skips it. Useful in development to cut log noise without removing the calls.
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
MuteMetadata: true, // metadata still attached, just not emitted
})You can flip these at runtime with log.MuteFields(), log.UnmuteFields(), log.MuteMetadata(), log.UnmuteMetadata().
OnTransportPanic
By default, a panic inside a transport's SendToLogger propagates up through your log.Info(...) call, matching the convention used by zerolog / zap / log/slog. Set OnTransportPanic to recover panicking transports and report them out-of-band so a buggy sink can't crash the host application:
log := loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{...},
OnTransportPanic: func(err *loglayer.RecoveredPanicError) {
// err.Kind is loglayer.PanicKindTransport.
// err.ID is the panicking transport's ID.
// err.Plugin is nil (transports have no hook-method dimension).
// err.Value is what was passed to panic().
metrics.Inc("loglayer.transport_panic", "id", err.ID)
},
})When set, the dispatch loop:
- Recovers the panic so the user's emission call returns normally.
- Calls your handler with a
*loglayer.RecoveredPanicErrordescribing the failure. - Continues dispatch to the remaining transports so one bad sink doesn't suppress the others.
A panic from inside the handler itself is recovered (and dropped) so a buggy reporter can't take down the dispatch loop.
The shape matches the *RecoveredPanicError that plugin hooks surface via ErrorReporter.OnError, so a single observability function can absorb both:
func report(err *loglayer.RecoveredPanicError) {
tags := []string{"kind", err.Kind, "id", err.ID}
if err.Plugin != nil {
tags = append(tags, "hook", err.Plugin.Hook)
}
metrics.Inc("loglayer.panic", tags...)
}
// Wire to plugins via WithErrorReporter, a helper that attaches an
// error observer to any plugin (the plugin sees recovered panics).
log.AddPlugin(loglayer.WithErrorReporter(plugin, func(e error) {
if rpe, ok := e.(*loglayer.RecoveredPanicError); ok {
report(rpe)
}
}))
// And to transports:
log = loglayer.New(loglayer.Config{
Transports: transports,
OnTransportPanic: report,
})Field semantics:
Kind:loglayer.PanicKindPluginorloglayer.PanicKindTransport.ID: the panicking component's identifier (the plugin ID for plugin panics, the transport ID for transport panics). Always populated.Plugin: a*PluginPanicDetails, non-nil iffKind == PanicKindPlugin. Carries the hook method name (Plugin.Hook = "OnBeforeDataOut", etc.). Nil for transport panics so the absence of the hook dimension is a typed condition rather than an empty-string convention.Value: the value originally passed topanic().
Off by default for hot-path reasons
Wrapping every SendToLogger call in a deferred recover costs ~8 ns per emission per transport even when no panic occurs (the open-coded defer still runs). On a sub-50 ns dispatch path that's measurable. The default (nil handler) keeps the hot path a direct call. Opt in when transport stability matters more than the few-nanosecond cost.
Source (caller info)
Config.Source groups the call-site capture knobs. Source.Enabled: true captures the call site (file, line, function) of every log emission and includes it in the log output under Source.FieldName (default "source"). Off by default; opt in for production-debuggable output.
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
Source: loglayer.SourceConfig{Enabled: true},
})
log.Info("served")
// {"level":"info","time":"...","msg":"served","source":{"function":"main.handler","file":"/app/main.go","line":42}}The captured Source value is a *loglayer.Source with Function, File, Line. JSON tags match the log/slog source convention so structured output is interchangeable with standard slog setups. The struct also implements fmt.Stringer (compact func file:line rendering for console / pretty transports) and slog.LogValuer (nested group when forwarded to a slog handler).
Override the output key when matching an existing log schema:
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
Source: loglayer.SourceConfig{
Enabled: true,
FieldName: "caller",
},
})Cost: about 620 ns and 5 extra allocations per emission on amd64 (BenchmarkLoglayer_SimpleMessage goes from ~40 ns / 1 alloc to ~660 ns / 6 allocs). The dominant terms are runtime.Caller's frame walk, runtime.FuncForPC().Name() materializing the function-name string, and the heap-allocated *Source. Paid only when Source.Enabled is true; the dispatch path is untouched otherwise. If per-emission cost matters more than caller info, leave it off and rely on transport-level rendering plus inline metadata.
Adapters can supply Source explicitly
If you're calling log.Raw(...) from an adapter that already has a program counter (the slog handler extracts it from slog.Record.PC), pass Source: loglayer.SourceFromPC(pc) on the RawLogEntry and skip runtime capture. The slog handler does this automatically.
Transport BaseConfig
Each transport accepts a transport.BaseConfig for transport-level concerns:
type BaseConfig struct {
ID string // unique identifier (required for AddTransport / RemoveTransport)
Disabled bool // suppress this transport (default: false)
Level loglayer.LogLevel // minimum level this transport will process
}Transport-level Level filtering happens in addition to the logger's level filtering. See Transport Configuration and individual transport pages.
