Multiple Transports
A LogLayer instance can dispatch every log entry to several transports at once. Set Transports (plural) on the config:
log := loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{
console.New(console.Config{
BaseConfig: transport.BaseConfig{ID: "console"},
}),
structured.New(structured.Config{
BaseConfig: transport.BaseConfig{ID: "ship"},
Writer: logFile,
}),
},
})
log.Info("user signed in") // both transports receive itDispatch Semantics
- Transports are called sequentially in registration order on the goroutine that called the log method.
- Disabled transports (per the transport's enabled flag or its
BaseConfig.Levelfilter) are skipped. - Transports do not see each other's output.
Authoring a custom transport? See Creating Transports for the dispatch-side contract (immutable params, error handling, concurrency).
Why Sequential (Not Parallel)
Most transports are pure formatting + a write. Goroutine setup costs more than the work itself. If a transport does I/O that genuinely blocks, that transport should buffer or queue internally, making the whole dispatch loop async would penalize the common case.
Per-Transport Level Filtering
Each transport has its own minimum level via BaseConfig.Level. This applies in addition to the logger's level state, and is the typical way to "log everything to console, ship only warnings":
loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{
console.New(console.Config{
BaseConfig: transport.BaseConfig{ID: "console"},
// no Level set → defaults to Trace (every level)
}),
structured.New(structured.Config{
BaseConfig: transport.BaseConfig{
ID: "ship",
Level: loglayer.LogLevelWarn,
},
Writer: logFile,
}),
},
})
log.Info("local-only") // console only
log.Warn("everywhere") // bothTransport IDs
Every transport has an ID() method; set it at construction via transport.BaseConfig{ID: ...}. When empty, a random ID is assigned, so RemoveTransport / GetLoggerInstance by ID silently under-delivers: RemoveTransport returns false, GetLoggerInstance returns nil, and the transport stays in the dispatch list. Confirm the assigned ID with the constructed transport's ID() before relying on it. Keep the constructed transport handle: *LogLayer offers no ID-lookup method, so once the transport is handed to the config, its ID is unrecoverable from the logger.
The example below sets ID: "ship" at construction, then removes that transport at runtime with the same string:
import (
"os"
"go.loglayer.dev/v3"
"go.loglayer.dev/v3/transport"
"go.loglayer.dev/transports/structured/v3"
)
logFile, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
panic(err)
}
log := loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{
structured.New(structured.Config{
BaseConfig: transport.BaseConfig{ID: "ship"},
Writer: logFile,
}),
},
})
removed := log.RemoveTransport("ship") // true: the ID matches constructionFor a transport constructed without BaseConfig.ID, reading ID() off the constructed value (before wiring it into the logger) is the way to confirm the auto-generated ID. Transports keep their IDs when passed to AddTransport / SetTransports by value or pointer.
Adding and Removing at Runtime
log.AddTransport(extraTransport)
log.RemoveTransport("ship")
log.SetTransports(t1, t2) // replace allSee Transport Management.
Single vs Multiple
For one transport, set Transport (singular). For more than one, use Transports (a slice). Both produce a working logger; pick the one that matches your call site.
loglayer.New(loglayer.Config{Transport: t}) // single
loglayer.New(loglayer.Config{Transports: []loglayer.Transport{t1, t2}}) // multipleRecipe: Multi-Output Production Setup
A realistic production setup. Pretty is colorized terminal output for the developer attached to the process; structured writes JSON-per-line to a rolling file for local correlation; Datadog ships everything to the Logs HTTP intake. Each transport has its own minimum level so the noisy Debug lines stay local.
import (
"os"
"go.loglayer.dev/v3"
"go.loglayer.dev/v3/transport"
"go.loglayer.dev/transports/datadog/v3"
"go.loglayer.dev/transports/pretty/v3"
"go.loglayer.dev/transports/structured/v3"
)
logFile, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
panic(err)
}
log := loglayer.New(loglayer.Config{
Transports: []loglayer.Transport{
pretty.New(pretty.Config{
BaseConfig: transport.BaseConfig{ID: "pretty", Level: loglayer.LogLevelDebug},
}),
structured.New(structured.Config{
BaseConfig: transport.BaseConfig{ID: "file", Level: loglayer.LogLevelInfo},
Writer: logFile,
}),
datadog.New(datadog.Config{
BaseConfig: transport.BaseConfig{ID: "datadog", Level: loglayer.LogLevelWarn},
APIKey: os.Getenv("DATADOG_API_KEY"),
Site: datadog.SiteUS1,
Service: "checkout-api",
}),
},
})Every emission fans out to all three transports in registration order. Each transport's own minimum level filters independently: log.Debug(...) reaches pretty only; log.Info(...) reaches pretty and the file; log.Warn(...) and above reach all three.
For routing rules beyond level filters (e.g. send audit.* only to the file, never to Datadog), see Groups.
