Introduction
Most Go projects already have structured logging working. What they don't have, out of the box, is a layer above it: redaction that travels with the logger, where logs go, what they get rewritten to first, and how to split traffic by subsystem.
LogLayer is that layer. It sits on top of whichever logging library you already use (or one of its built-in transports) and gives you:
- Plugin pipeline. Rewrite, filter, or enrich entries before any transport sees them. Built-in:
redact(deep, reflective, type-preserving),oteltraceanddatadogtracefor trace-ID injection. Six hook interfaces if you need your own. - Multi-transport fan-out. Send the same entry to several transports, each with its own minimum level. Pretty in dev, structured to a file, batched HTTP to Datadog, from one logger.
- Group routing. Tag entries by subsystem (
db,auth, ...) and route each group to its own transports. Toggle active groups at runtime via env var. - Two-way slog interop. Wrap a
*slog.Loggeras a backend, or install aslog.Handlerso everyslog.Info(...)call flows through the loglayer pipeline. - First-class test capture. Capture every log entry as a typed value in tests. Assert on level, message, fields, metadata, and context directly; no JSON parsing.
- Opt-in caller info. Capture file/line/function on every emission and render it under a configurable key. The slog handler forwards it for free.
- Distinct types for fields, metadata, and errors. The compiler catches misuse.
- Bring your own logger. Wrap zerolog, zap, slog, logrus, charmbracelet/log, or phuslu without rewriting call sites.
- Runtime control. Hot-swap transports, add or remove plugins, toggle levels. Live and concurrency-safe.
If your service is small and you only need "log to stdout in JSON," the stdlib is fine. The friction LogLayer fixes shows up later: when you add a second destination, redact a field across every log site, or want to wire in OpenTelemetry without rewriting how you log everywhere.
log.
WithMetadata(loglayer.Metadata{"userId": "1234"}).
WithError(errors.New("something went wrong")).
Error("user action failed"){
"msg": "user action failed",
"userId": "1234",
"err": { "message": "something went wrong" }
}Coming from TypeScript?
This is the Go port of loglayer for TypeScript. The mental model and API shape map directly. See For TypeScript Developers for the full convention map and the deliberate Go-specific differences (Fields instead of context, threading guarantees, error handling, module layout).
Bring Your Own Logger
LogLayer is designed to sit on top of your logging library of choice (zerolog, zap, slog, logrus, charmbracelet/log, phuslu/log) or to run standalone with one of the built-in transports (pretty terminal, structured JSON, HTTP, console).
Start with the built-in pretty transport during development, then switch to the zerolog or zap transport later when you have a real production setup, without changing a single log call.
Learn more about logging transports.
Consistent API
No need to remember different parameter orders or method names between logging libraries:
// With LogLayer, same call shape regardless of backend
log.WithMetadata(loglayer.Metadata{"some": "data"}).Info("my message")
// Without LogLayer, every library wants something different
zerologLogger.Info().Interface("some", "data").Msg("my message")
zapLogger.Info("my message", zap.Any("some", "data"))
slog.Info("my message", "some", "data")Start with basic logging.
Separation of Errors, Fields, and Metadata
LogLayer distinguishes three kinds of structured data, each with a clear scope:
| Type | Method | Scope | Purpose |
|---|---|---|---|
| Fields | WithFields() | Persistent across all logs from the derived logger | Request IDs, user info, session data |
| Metadata | WithMetadata() | Single log entry only | Event-specific details, durations, counts |
| Errors | WithError() | Single log entry only | An error value, serialized for output |
Per-log metadata can never accidentally leak into future logs, errors are serialized consistently, and each type can be nested under a dedicated field via configuration. WithFields is keyed (map[string]any) because fields support keyed operations like merge, clear-by-key, and copy on Child(). WithMetadata accepts any because each entry is a one-shot payload: pass a struct, a map, or a scalar.
This separation provides several benefits:
- Clarity: each piece of data has a clear purpose and appropriate scope.
- No pollution: per-log metadata can never accidentally persist to future logs.
- Flexible output: configure where each type appears in the final log (root level or dedicated fields) via configuration.
- Better debugging: errors are serialized consistently via a configurable
ErrorSerializer.
log.
WithFields(loglayer.Fields{"requestId": "abc-123"}). // persists
WithMetadata(loglayer.Metadata{"duration": 150}). // this log only
WithError(errors.New("timeout")). // this log only
Error("Request failed"){
"msg": "Request failed",
"requestId": "abc-123",
"duration": 150,
"err": { "message": "timeout" }
}See the dedicated pages for fields, metadata, and error handling.
Powerful Plugin System
Plugins extend the emission pipeline so you can rewrite, filter, or enrich entries globally without touching call sites. The built-in plugins/redact plugin walks structs, maps, and slices via reflection, redacting matched keys at any nesting depth. The plugins/datadogtrace and plugins/oteltrace plugins inject trace IDs from the bound context. See Plugins for the catalog and Creating Plugins to write your own.
Multi-Transport Support
Send your logs to multiple destinations simultaneously:
log := loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{
pretty.New(pretty.Config{}), // dev console
structured.New(structured.Config{Writer: jsonFile}), // shipping
},
})
log.Info("user signed in") // both transports receive itSee more about multi-transport support.
Targeted Log Routing with Groups
In a large system with many subsystems, you often want certain logs to go to certain destinations. Groups let you tag logs by category and route them to specific transports with per-group log levels:
log := loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{...},
Routing: loglayer.RoutingConfig{
Groups: map[string]loglayer.LogGroup{
"database": {Transports: []string{"datadog"}, Level: loglayer.LogLevelError},
"auth": {Transports: []string{"datadog", "console"}, Level: loglayer.LogLevelWarn},
},
},
})
// Tag individual logs
log.WithGroup("database").Error("connection lost")
// Or create a dedicated logger for a subsystem
dbLogger := log.WithGroup("database")
dbLogger.Error("pool exhausted") // routed to datadog onlyNarrow focus to a specific subsystem at runtime via an environment variable, no code changes:
loglayer.New(loglayer.Config{
Routing: loglayer.RoutingConfig{
ActiveGroups: loglayer.ActiveGroupsFromEnv("LOGLAYER_GROUPS"),
},
})LOGLAYER_GROUPS=database,auth go run .See more about groups.
HTTP and Cloud Shipping
Send logs directly to any HTTP endpoint without a third-party logging library, with built-in batching, retries, and a pluggable encoder. The HTTP transport is the foundation; the Datadog transport is built on top of it for the Datadog Logs intake API.
Easy Testing
Built-in mocks make testing painless:
// Silent mock for tests that don't care about output
log := loglayer.NewMock()
// Capturing transport for tests that assert on what was logged
lib := &lltest.TestLoggingLibrary{}
log := loglayer.New(loglayer.Config{
Transport: lltest.New(lltest.Config{Library: lib}),
})
log.WithMetadata(loglayer.Metadata{"k": "v"}).Info("msg")
line := lib.PopLine()
require.Equal(t, "msg", line.Messages[0])See more about testing.
