Skip to content

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), oteltrace and datadogtrace for 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.Logger as a backend, or install a slog.Handler so every slog.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.

go
log.
    WithMetadata(loglayer.Metadata{"userId": "1234"}).
    WithError(errors.New("something went wrong")).
    Error("user action failed")
json
{
  "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:

go
// 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:

TypeMethodScopePurpose
FieldsWithFields()Persistent across all logs from the derived loggerRequest IDs, user info, session data
MetadataWithMetadata()Single log entry onlyEvent-specific details, durations, counts
ErrorsWithError()Single log entry onlyAn 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.
go
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")
json
{
  "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:

go
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 it

See 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:

go
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 only

Narrow focus to a specific subsystem at runtime via an environment variable, no code changes:

go
loglayer.New(loglayer.Config{
    Routing: loglayer.RoutingConfig{
        ActiveGroups: loglayer.ActiveGroupsFromEnv("LOGLAYER_GROUPS"),
    },
})
sh
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:

go
// 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.