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

303 lines
8.7 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 logging
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ANSI color sequences matching the mgutz/ansi values the old pfxlog
// formatter used, so colored output looks the same as the pre-slog binaries.
const (
ansiRed = "\033[31m"
ansiYellow = "\033[33m"
ansiWhite = "\033[37m"
ansiBlue = "\033[34m"
ansiLightBlack = "\033[90m"
ansiCyan = "\033[36m"
ansiLightCyan = "\033[96m"
ansiDefaultFg = "\033[39m"
)
// Special attr keys carried over from pfxlog: ChannelsKey holds a []string of
// channel names rendered as |a, b| after the function, ContextKey holds a
// string rendered as [ctx]. Both are excluded from the fields block.
const (
ChannelsKey = "_channels"
ContextKey = "_context"
)
// PrettyOptions configures PrettyHandler's human-readable output.
type PrettyOptions struct {
// AbsoluteTime renders the record's time as a wall-clock timestamp using
// TimestampFormat instead of seconds since StartTimestamp.
AbsoluteTime bool
// StartTimestamp is the baseline for the relative [seconds] time column.
// DefaultPrettyOptions sets it to the start of today in local time,
// matching pfxlog's StartingToday behavior.
StartTimestamp time.Time
// TimestampFormat is the time layout used when AbsoluteTime is set.
TimestampFormat string
// TrimPrefix is removed from the front of function names before they are
// rendered. DefaultPrettyOptions sets "github.com/openziti/".
TrimPrefix string
// UseColor enables ANSI coloring of every colored segment (level label,
// timestamp, function, fields). When false the output contains no escape
// sequences at all.
UseColor bool
}
// DefaultPrettyOptions returns PrettyOptions matching the pre-slog pfxlog
// defaults: relative time since the start of today, "github.com/openziti/"
// trimmed from function names, and color off unless PFXLOG_USE_COLOR opts in.
func DefaultPrettyOptions() *PrettyOptions {
now := time.Now()
return &PrettyOptions{
StartTimestamp: time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()),
TimestampFormat: "2006-01-02 15:04:05.000",
TrimPrefix: "github.com/openziti/",
UseColor: useColor(),
}
}
// useColor decides the default color setting. ziti has always run its pretty
// logs without color (cmd/main sets pfxlog's NoColor), so color is off unless a
// caller opts in via PFXLOG_USE_COLOR. TTY detection is deliberately not used:
// it would turn color on in interactive terminals where the pre-slog binaries
// showed none.
func useColor() bool {
if env := os.Getenv("PFXLOG_USE_COLOR"); env != "" {
if v, err := strconv.ParseBool(env); err == nil {
return v
}
}
return false
}
// PrettyHandler is a slog.Handler that renders records in the pfxlog pretty
// format the pre-slog ziti binaries produced:
//
// [ 12.345] ERROR ziti/controller/server.Run: {k=[v]} something failed
//
// All seven canonical levels (trace through panic) render with their pfxlog
// labels; non-canonical levels fall back to slog's offset form. The handler
// does no level gating; that lives upstream in the registry chain.
type PrettyHandler struct {
opts PrettyOptions
out io.Writer
lock *sync.Mutex
attrs []slog.Attr
}
// NewPrettyHandler builds a PrettyHandler writing to out. A nil opts uses
// DefaultPrettyOptions(); out defaults to os.Stderr when nil.
func NewPrettyHandler(out io.Writer, opts *PrettyOptions) *PrettyHandler {
if out == nil {
out = os.Stderr
}
if opts == nil {
opts = DefaultPrettyOptions()
}
return &PrettyHandler{
opts: *opts,
out: out,
lock: &sync.Mutex{},
}
}
func (h *PrettyHandler) Enabled(context.Context, slog.Level) bool {
return true
}
func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error {
var out strings.Builder
recordTime := r.Time
if recordTime.IsZero() {
recordTime = time.Now()
}
var timeLabel string
if h.opts.AbsoluteTime {
timeLabel = "[" + recordTime.Format(h.opts.TimestampFormat) + "]"
} else {
timeLabel = fmt.Sprintf("[%8.3f]", recordTime.Sub(h.opts.StartTimestamp).Seconds())
}
out.WriteString(h.colored(ansiBlue, timeLabel))
out.WriteString(" " + h.levelLabel(r.Level))
function := h.functionFor(r)
// collect handler attrs then record attrs; later keys overwrite earlier
// ones in the fields map, matching logrus WithField semantics
fields := map[string]any{}
addAttr := func(a slog.Attr) {
fields[a.Key] = a.Value.Any()
}
for _, a := range h.attrs {
addAttr(a)
}
r.Attrs(func(a slog.Attr) bool {
addAttr(a)
return true
})
// func/file attrs only stand in for the caller frame when there's no PC
// (bridged records keep them in Entry.Data); with a PC they'd be
// redundant with the resolved frame
if function == "" {
if fn, ok := fields["func"].(string); ok {
function = fn
delete(fields, "func")
delete(fields, "file")
}
}
function = strings.TrimPrefix(function, h.opts.TrimPrefix)
if channels, ok := fields[ChannelsKey].([]string); ok && len(channels) > 0 {
function += " |" + strings.Join(channels, ", ") + "|"
}
delete(fields, ChannelsKey)
if logCtx, ok := fields[ContextKey].(string); ok {
function += " [" + logCtx + "]"
}
delete(fields, ContextKey)
out.WriteString(" " + h.colored(ansiCyan, function) + ":")
if len(fields) > 0 {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
var fieldsStr strings.Builder
fieldsStr.WriteString("{")
for i, k := range keys {
if i > 0 {
fieldsStr.WriteString(" ")
}
fmt.Fprintf(&fieldsStr, "%s=[%v]", k, fields[k])
}
fieldsStr.WriteString("}")
out.WriteString(" " + h.colored(ansiLightCyan, fieldsStr.String()))
}
out.WriteString(" " + r.Message)
h.lock.Lock()
defer h.lock.Unlock()
_, err := fmt.Fprintln(h.out, out.String())
return err
}
// WithAttrs returns a handler whose output includes attrs in the fields
// block of every record, appended after any attrs already held.
func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs))
merged = append(merged, h.attrs...)
merged = append(merged, attrs...)
return &PrettyHandler{opts: h.opts, out: h.out, lock: h.lock, attrs: merged}
}
// WithGroup returns the handler unchanged: the pfxlog pretty format has no
// group concept, so group qualification is intentionally dropped, as it was
// in the pfxlog and df handlers this replaces.
func (h *PrettyHandler) WithGroup(string) slog.Handler {
return h
}
// functionFor resolves the record's caller function from its PC, or returns
// "" when there is no PC (bridged records carry func/file as attrs instead).
func (h *PrettyHandler) functionFor(r slog.Record) string {
if r.PC == 0 {
return ""
}
frames := runtime.CallersFrames([]uintptr{r.PC})
frame, _ := frames.Next()
return frame.Function
}
// levelLabel returns the 7-character pfxlog label for the level, colored when
// UseColor is set. Non-canonical levels render slog's offset form (for
// example "DEBUG+1") right-aligned to the same width.
func (h *PrettyHandler) levelLabel(l slog.Level) string {
var label string
switch l {
case LevelPanic:
label = " PANIC"
case LevelFatal:
label = " FATAL"
case slog.LevelError:
label = " ERROR"
case slog.LevelWarn:
label = "WARNING"
case slog.LevelInfo:
label = " INFO"
case slog.LevelDebug:
label = " DEBUG"
case LevelTrace:
label = " TRACE"
default:
label = fmt.Sprintf("%7s", l.String())
}
return h.colored(levelColor(l), label)
}
// levelColor buckets a level into the color of the canonical level at or
// below it, so non-canonical levels color like their nearest neighbor.
func levelColor(l slog.Level) string {
switch {
case l >= slog.LevelError:
return ansiRed
case l >= slog.LevelWarn:
return ansiYellow
case l >= slog.LevelInfo:
return ansiWhite
case l >= slog.LevelDebug:
return ansiBlue
default:
return ansiLightBlack
}
}
// colored wraps s in the given color and a foreground reset when UseColor is
// set, and returns s unchanged otherwise.
func (h *PrettyHandler) colored(color, s string) string {
if !h.opts.UseColor {
return s
}
return color + s + ansiDefaultFg
}