Files
Paul Lorenz 0aefe599c9 Install slog and route agent log-level callbacks through common/logging. Fixes #3910
- 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
2026-06-15 15:34:31 -04:00

186 lines
7.6 KiB
Go

/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package agentlog
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/ziti/v2/common/agent"
"github.com/openziti/ziti/v2/common/logging"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)
// recordingHandler captures every record that reaches it. The tests in this
// file install a sync recording handler (rather than the production
// AsyncHandler) so they don't depend on a queue drain to flush before
// assertions; this isolates the level-filtering behavior under test from the
// AsyncHandler's lifecycle.
type recordingHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r.Clone())
return nil
}
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
func (h *recordingHandler) messages() []string {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]string, len(h.records))
for i, r := range h.records {
out[i] = r.Message
}
return out
}
// installForTest installs the sync recording handler onto logrus.StandardLogger
// (pfxlog v0.6.10 dispatches there, so the bridge must be wired to the
// standard logger for pfxlog calls to reach slog) and registers a cleanup
// that restores logrus's previous Out / Formatter / Level / ReportCaller and
// clears all hooks. Tests in this file mutate global logrus state by design.
func installForTest(t *testing.T, level slog.Level) *recordingHandler {
t.Helper()
rec := &recordingHandler{}
std := logrus.StandardLogger()
prevOut := std.Out
prevFmt := std.Formatter
prevLevel := std.Level
prevReport := std.ReportCaller
prevHooks := std.Hooks
t.Cleanup(func() {
std.SetOutput(prevOut)
std.SetFormatter(prevFmt)
std.SetLevel(prevLevel)
std.SetReportCaller(prevReport)
std.ReplaceHooks(prevHooks)
})
logging.Install(rec, level)
return rec
}
// TestInstallInvariant proves logrus.StandardLogger is fully captured by the
// bridge after Install: output is io.Discard, formatter is the noop, the
// caller's level is mirrored to logrus, ReportCaller is on so the bridge has
// a PC to forward, and subsequent SetGlobalLevel updates keep the two worlds
// in lockstep. This is the invariant the Phase 6 design hangs on.
func TestInstallInvariant(t *testing.T) {
installForTest(t, slog.LevelInfo)
std := logrus.StandardLogger()
require.Equal(t, io.Discard, std.Out, "output must be io.Discard")
require.True(t, std.ReportCaller, "ReportCaller must be enabled")
require.Equal(t, logrus.InfoLevel, std.Level, "logrus level must mirror the slog initial level")
require.Equal(t, slog.LevelInfo, logging.GlobalLevel())
// noopFormatter is unexported in common/logging; assert by behavior: the
// formatter returns an empty byte slice for any entry, which is the
// contract that makes the io.Discard sink benign.
out, err := std.Formatter.Format(&logrus.Entry{Message: "x"})
require.NoError(t, err)
require.Empty(t, out)
// Subsequent SetGlobalLevel must keep logrus pre-filter in lockstep.
logging.SetGlobalLevel(logging.LevelTrace)
require.Equal(t, logrus.TraceLevel, std.Level)
logging.SetGlobalLevel(slog.LevelWarn)
require.Equal(t, logrus.WarnLevel, std.Level)
}
// TestEndToEnd_AgentSetLogLevel proves the agent callback chain drives both
// sides of the bridge. After cb.SetLogLevel(InfoLevel), logrus's pre-filter
// drops Debug records (so they never reach the bridge) and the slog Registry
// reports the matching global; Info records flow through both the bridged
// logrus path (via pfxlog.Logger()) and the direct slog path (via
// logging.For()). This is the path `ziti agent set-log-level info` takes.
func TestEndToEnd_AgentSetLogLevel(t *testing.T) {
rec := installForTest(t, slog.LevelWarn)
cb := DefaultLogLevelCallbacks()
// Initial level Warn: Info from either side is filtered.
pfxlog.Logger().Info("pre-info-pfxlog")
logging.For("zone.x").Info("pre-info-slog")
require.Empty(t, rec.messages(), "pre-SetLogLevel Info records must be filtered")
// Operator runs `ziti agent set-log-level info`; lockstep moves both sides.
cb.SetLogLevel(agent.InfoLevel)
require.Equal(t, slog.LevelInfo, logging.GlobalLevel())
require.Equal(t, logrus.InfoLevel, logrus.StandardLogger().Level)
// Below threshold remains filtered.
pfxlog.Logger().Debug("post-debug-pfxlog")
logging.For("zone.x").Debug("post-debug-slog")
// At threshold: both paths emit.
pfxlog.Logger().Info("post-info-pfxlog")
logging.For("zone.x").Info("post-info-slog")
got := rec.messages()
require.Contains(t, got, "post-info-pfxlog", "bridged logrus Info must reach slog after SetLogLevel(Info)")
require.Contains(t, got, "post-info-slog", "direct slog Info must reach the handler after SetLogLevel(Info)")
require.NotContains(t, got, "post-debug-pfxlog", "logrus Debug must be filtered by the pre-filter after SetLogLevel(Info)")
require.NotContains(t, got, "post-debug-slog", "slog Debug must be filtered by the global Registry level")
}
// TestPerChannelOverride_AppliesToSlogOnly_NotPfxlog proves Phase 7's slog-only
// channel-override semantics: when the global level is Info, an override
// lifting "test.gossip" to Debug enables slog Debug for that channel but does
// not affect pfxlog's channel-level mechanism. pfxlog.Logger() / ChannelLogger
// still observe the global logrus level (Info), so a pfxlog Debug call on the
// same channel name stays filtered. Clearing the override drops the channel
// back to the global level.
func TestPerChannelOverride_AppliesToSlogOnly_NotPfxlog(t *testing.T) {
rec := installForTest(t, slog.LevelInfo)
cb := DefaultLogLevelCallbacks()
cb.SetChannelLogLevel("test.gossip", agent.DebugLevel)
// slog override: Debug on the named channel is allowed through.
logging.For("test.gossip").Debug("slog-debug-allowed")
// pfxlog at Debug stays filtered because the global logrus level is Info
// and the per-channel override does not touch pfxlog's overrides map.
pfxlog.Logger().Debug("pfxlog-debug-filtered")
pfxlog.ChannelLogger("test.gossip").Debug("pfxlog-channel-debug-filtered")
// pfxlog at Info still gets through, confirming the bridge is wired.
pfxlog.Logger().Info("pfxlog-info-allowed")
got := rec.messages()
require.Contains(t, got, "slog-debug-allowed", "slog channel override must enable Debug for that channel")
require.Contains(t, got, "pfxlog-info-allowed", "bridge must continue to deliver pfxlog Info records")
require.NotContains(t, got, "pfxlog-debug-filtered", "pfxlog Debug must stay filtered by the global logrus level")
require.NotContains(t, got, "pfxlog-channel-debug-filtered", "pfxlog channel logger must not see the slog-only override")
cb.ClearChannelLogLevel("test.gossip")
logging.For("test.gossip").Debug("post-clear-debug-filtered")
require.NotContains(t, rec.messages(), "post-clear-debug-filtered", "Clear must drop the channel back to the global Info level")
}