- adds BuildPrettyHandler and BuildHandlerForFormat in common/logging: pretty output wraps the hand-rolled logging.PrettyHandler (a direct port of pfxlog's; df/dl was dropped after review found level-label gaps, so there is no github.com/michaelquigley/df dependency) in the AsyncHandler chain; the format-aware builder picks pretty / json / text by --log-formatter so default look matches pre-slog - adds BuildTextHandler so --log-formatter=text emits logrus-TextFormatter- style key=value output (level=info msg=...) via a slog TextHandler rather than the colored pretty handler, restoring the pre-slog meaning of text - adds logging.Fatal: a slog-world fatal (slog provides none) that emits at LevelFatal durably via SyncEmit, then exits, so hard-exit paths do not lose the record to the async queue; converts the controller and router startup hard-exit sites from Error+os.Exit / Error+panic to it, dropping the router's startup panic - rewires agentlog.DefaultLogLevelCallbacks onto common/logging: SetLogLevel drives logging.SetGlobalLevel (lockstep slog + logrus), SetChannelLogLevel and ClearChannelLogLevel drive SetNamedLevel / ClearNamedLevel; per-channel overrides become slog-only per design - adds agentToSlog mapping across the seven canonical levels with an Info fallback for forward-compat - ziti/run Options.PreRun and ziti/tunnel rootPreRun build the slog handler chain via logging.BuildHandlerForFormat and call Install; --verbose seeds the initial level instead of mutating logrus directly; AsyncOptions flags exposed via logging.AddFlags on each persistent flag set - hardens the run command's logging flags: PreRun reads --verbose / --log-formatter across the command chain so they are honored at either the alias-parent (ziti controller run) or child position, and the ziti controller / ziti router alias parents skip their legacy pfxlog/logrus PersistentPreRun setup for the run subcommand (which installs the slog chain itself), keeping it for sibling subcommands - adds Phase 7 acceptance tests in common/agentlog: TestInstallInvariant covers Out=io.Discard, noop formatter, ReportCaller, and the lockstep level mirror after Install; TestEndToEnd_AgentSetLogLevel walks the agent set-log-level path end to end across bridged-logrus and direct-slog routes; TestPerChannelOverride_AppliesToSlogOnly_NotPfxlog confirms the design's slog-only channel semantics - adds Fatal/Panic durability subprocess tests in common/logging that fork the test binary, Install the production handler chain, then call logrus.Fatal / logrus.Panic and assert the records reach stderr before exit/panic; proves the bridge's SyncEmit path flushes before os.Exit - adds doc/logging.md developer note covering how to write a slog line, channel-naming convention, the no-Warn/Error-in-hot-paths rule, the operator surface, the migration checklist, AsyncOptions tunables, and what's deliberately deferred - adds doc/design/slog-conversion-plan.md with the code-grounded per-package channel inventory, the sdk-golang embedder-injection pattern, conversion order with deep analysis for the first four chunks, and cross-repo coordination notes
6.5 KiB
Logging in ziti
ziti's logging foundation is log/slog under the hood, with a logrus
bridge so legacy call sites keep working unchanged. Output looks the
same as it did before (pretty by default, JSON with
--log-formatter=json). The reason for the change is operator
control: with slog, the agent can set the global level and lift any
named channel above the global without restarting the process, and
the bridge keeps logrus's mutex out of the per-record hot path for
call sites that have migrated.
This note covers what you need to know to write new code, migrate existing code, and avoid the rough edges.
How to write a new log line
In new code, pick a channel name for your package or subsystem and hold a logger at package scope:
package link
import "github.com/openziti/ziti/v2/common/logging"
// channelName is the agent-facing name for this package's log records.
// Operators can set its level at runtime with
// ziti agent set-channel-log-level router.link debug
var log = logging.For("router.link")
func dial(ctx context.Context, remote *Identity) error {
log.Info("dialing", "remote", remote.Id, "underlay", remote.Underlay)
if err := remote.Connect(ctx); err != nil {
log.Warn("dial failed", "remote", remote.Id, "error", err)
return err
}
log.Debug("dialed", "remote", remote.Id, "elapsed", time.Since(start))
return nil
}
logging.For(name) returns a *slog.Logger whose handler binds
channel: name as the first attr, so every record carries the
channel it came from. Loggers are cached per name, so subsequent
calls return the same pointer.
If you have nothing meaningful to channel-name (e.g. one-off CLI
glue), use slog.Default(). Anything that participates in the
operator's per-channel control surface should go through logging.For.
Channel naming
Use subsystem.area form, lowercased, dot-separated. Examples that
match the structure of the codebase:
router.link,router.xgress,router.forwardercontroller.gossip,controller.fabricfabric.ctrl,fabric.routeredge.api,edge.identitytransport.tls
Pick the name at a subsystem boundary, not per-method. Method-level channels make sense only when you've already discovered that the subsystem channel is too coarse for triage; the default is one channel per package or per logical area.
The hot-path rule
Do not introduce Warn/Error log lines at per-event rate. A line that fires once per packet, per circuit message, per gossip tick, or per connection is a hot path. At those rates, every log call goes through the leaf handler's write lock, and Warn/Error in particular defeat the bridge's drop-summary path because they sit above the default block threshold.
If you need to see hot-path detail, write it at Debug or Trace and let the operator enable the channel on demand:
log.Debug("payload received", "circuit", c.Id, "len", len(p.Data))
Reviewers will bounce conversion PRs that introduce new Warn/Error at hot-path rates.
Operator surface
Once a package uses logging.For(name), operators can drive that
channel via the agent:
ziti agent set-log-level info # global level, both slog and logrus
ziti agent set-log-level debug # raise everything
ziti agent set-channel-log-level router.link debug # lift one channel
ziti agent clear-channel-log-level router.link # drop back to global
set-log-level moves both worlds in lockstep (the slog Registry's
global AND logrus.SetLevel), so legacy pfxlog call sites observe the
same global threshold as slog ones. set-channel-log-level is
slog-only by design: it lifts records emitted through
logging.For(name) above the global, but pfxlog.Logger() /
pfxlog.ChannelLogger(name) calls keep observing the global level
until the call site migrates. This is the migration carrot, not an
oversight.
Migrating an existing package
Conversion is mechanical:
- Add a package-scoped logger:
var log = logging.For("subsystem.area"). - Document the channel name at the top of the package (godoc comment).
- Replace
pfxlog.Logger()andpfxlog.ContextLogger(...)call sites withlog(or a context-derived child, e.g.log.With("circuit", c.Id)). - Keep every line at the level it had before. Conversion is not a level audit.
- Don't introduce new Warn/Error at per-event rate.
- Update tests as needed. slog uses positional
key, valuepairs rather thanpfxlog.WithField(...).
Until a package is migrated, its pfxlog.Logger() calls still work
(via the bridge) but the package has no per-channel agent control.
Tunables
AsyncOptions controls the bridge's queue. The defaults are fine for
production; the flags exist so operators can adjust under
investigation:
| Flag | Default | What it controls |
|---|---|---|
--log-queue-size |
4096 | Bounded capacity of the async log queue |
--log-block-threshold |
warn |
Lowest level that blocks under queue saturation (records below this drop and bump a summary counter) |
--log-summary-interval |
5s | Cadence of the drop-summary record when records have been dropped |
If you see the bridge dropping records (the periodic summary line mentions it), the question is usually "what's emitting so much?", not "is the queue too small?" — but the knob is there.
Architecture, briefly
common/loggingis the slog foundation:AsyncHandler, the named-logger Registry, the logrus bridge, level helpers, and the format handlers (pfxlog-shape JSON viaBuildHandler, pfxlog-shape pretty viaBuildPrettyHandler).common/agentlogwires the agent's transport-neutral log-level commands ontologging.SetGlobalLevel/SetNamedLevel/ClearNamedLevel. The controller, router, and tunnel binaries each register this from theirPreRun.- Design docs: logging-refactor.md and logging-refactor-progress.md.
Deferred, not missing
The current implementation deliberately does not include:
- PC-based method/file level overrides. The agent commands operate at the channel level only.
- An OTel adapter. Records still flow through the local handler chain; export to OTel is a future addition.
- Persistent yaml-driven level overrides. Level changes via the agent are in-memory and reset on restart.
- pfxlog removal. Legacy
pfxlog.Logger()calls keep working through the bridge; the conversion tologging.Foris incremental and per package.
These are tracked in the design doc and are explicitly out of scope for the current foundation work.