| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- // Package logging wires the nebula runtime-reconfigurable slog handler used
- // by nebula.Main and the nebula CLI binaries. Callers build a logger with
- // NewLogger, then call ApplyConfig at startup and from a config reload
- // callback to push logging.level, logging.format, and
- // logging.disable_timestamp changes onto the logger without rebuilding it.
- package logging
- import (
- "context"
- "fmt"
- "io"
- "log/slog"
- "strings"
- "sync/atomic"
- "time"
- )
- // Config is the subset of *config.C that ApplyConfig reads. Declaring it
- // here keeps the logging package from depending on config directly, which
- // would cycle through the shared test helpers (test.NewLogger imports
- // logging, and config's tests import test). *config.C satisfies this
- // interface structurally with no adapter.
- type Config interface {
- GetString(key, def string) string
- GetBool(key string, def bool) bool
- }
- // LevelTrace is a custom slog level below Debug, used when logging.level is
- // "trace". slog has no builtin trace level; the value is one step below
- // slog.LevelDebug in slog's 4-point spacing.
- const LevelTrace = slog.Level(-8)
- // NewLogger returns a *slog.Logger whose level, format, and timestamp
- // emission can be reconfigured at runtime via ApplyConfig and the SSH debug
- // commands. The default configuration is info-level text output so log
- // calls made before ApplyConfig runs still produce output. Timestamps
- // follow slog's default RFC3339Nano format; set logging.disable_timestamp
- // in config to suppress them.
- //
- // ApplyConfig and the SSH commands discover the reconfig surface via
- // structural type-assertion on l.Handler(), so replacement implementations
- // (tests, platform-specific sinks) need only implement the subset of
- // {SetLevel(slog.Level), SetFormat(string) error, SetDisableTimestamp(bool)}
- // they care about. Callers that pass a plain *slog.Logger without these
- // methods get a silent no-op; reconfiguration is always opt-in.
- func NewLogger(w io.Writer) *slog.Logger {
- return slog.New(NewHandler(w))
- }
- // NewHandler builds the *Handler that NewLogger wraps. Exported for
- // platform-specific sinks (notably cmd/nebula-service/logs_windows.go)
- // that want to wrap the handler with extra behavior, such as tagging each
- // record with its Event Log severity, while still benefiting from all the
- // level / format / timestamp / WithAttrs machinery implemented here.
- func NewHandler(w io.Writer) *Handler {
- root := &handlerRoot{}
- root.level.Set(slog.LevelInfo)
- opts := &slog.HandlerOptions{Level: &root.level}
- return &Handler{
- root: root,
- text: slog.NewTextHandler(w, opts),
- json: slog.NewJSONHandler(w, opts),
- }
- }
- // handlerRoot carries the reconfiguration state shared by every logger
- // derived from a NewHandler call. All fields are consulted on the log
- // path and updated lock-free.
- type handlerRoot struct {
- level slog.LevelVar
- disableTimestamp atomic.Bool
- // jsonMode picks which of the pre-derived inner handlers Handler.Handle
- // dispatches to. Flipping it propagates instantly to every derived logger
- // without rebuilding or chain-replaying anything.
- jsonMode atomic.Bool
- }
- // Handler is the slog.Handler returned by NewHandler. It holds two
- // pre-derived slog handlers -- one text, one json -- both built from the
- // same accumulated WithAttrs/WithGroup state. Handle picks which one to
- // dispatch to based on handlerRoot.jsonMode, so a SetFormat call takes
- // effect immediately across the whole process without having to rebuild
- // any derived loggers.
- type Handler struct {
- root *handlerRoot
- text slog.Handler
- json slog.Handler
- }
- func (h *Handler) Enabled(_ context.Context, l slog.Level) bool {
- return h.root.level.Level() <= l
- }
- func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
- if h.root.disableTimestamp.Load() {
- r.Time = time.Time{}
- }
- if h.root.jsonMode.Load() {
- return h.json.Handle(ctx, r)
- }
- return h.text.Handle(ctx, r)
- }
- func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
- if len(attrs) == 0 {
- return h
- }
- return &Handler{
- root: h.root,
- text: h.text.WithAttrs(attrs),
- json: h.json.WithAttrs(attrs),
- }
- }
- func (h *Handler) WithGroup(name string) slog.Handler {
- if name == "" {
- return h
- }
- return &Handler{
- root: h.root,
- text: h.text.WithGroup(name),
- json: h.json.WithGroup(name),
- }
- }
- // SetLevel updates the effective log level. Propagates to every derived
- // logger via the shared LevelVar.
- func (h *Handler) SetLevel(level slog.Level) { h.root.level.Set(level) }
- // GetLevel reports the current log level.
- func (h *Handler) GetLevel() slog.Level { return h.root.level.Level() }
- // SetFormat flips the output format atomically. Valid formats are "text"
- // and "json". Every derived logger sees the new format on its next Handle
- // call; no rebuild or registration is required.
- func (h *Handler) SetFormat(format string) error {
- switch format {
- case "text":
- h.root.jsonMode.Store(false)
- case "json":
- h.root.jsonMode.Store(true)
- default:
- return fmt.Errorf("unknown log format `%s`. possible formats: %s", format, []string{"text", "json"})
- }
- return nil
- }
- // GetFormat reports the currently selected format name.
- func (h *Handler) GetFormat() string {
- if h.root.jsonMode.Load() {
- return "json"
- }
- return "text"
- }
- // SetDisableTimestamp toggles whether Handle zeroes r.Time before
- // dispatching (slog's builtin text/json handlers skip emitting the time
- // attribute on a zero time).
- func (h *Handler) SetDisableTimestamp(v bool) { h.root.disableTimestamp.Store(v) }
- // ApplyConfig reads logging.level, logging.format, and (optionally)
- // logging.disable_timestamp from c and applies them to l. The reconfig
- // surface is discovered via structural type-assertion on l.Handler(), so
- // foreign handlers silently opt out of whichever capabilities they do not
- // implement.
- //
- // nebula.Main does NOT call this function on your behalf; callers that want
- // config-driven log level / format / timestamp updates invoke it at
- // startup and register it as a reload callback themselves. This keeps the
- // library from mutating an embedder's logger without their say-so.
- func ApplyConfig(l *slog.Logger, c Config) error {
- h := l.Handler()
- lvl, err := ParseLevel(strings.ToLower(c.GetString("logging.level", "info")))
- if err != nil {
- return err
- }
- if ls, ok := h.(interface{ SetLevel(slog.Level) }); ok {
- ls.SetLevel(lvl)
- }
- format := strings.ToLower(c.GetString("logging.format", "text"))
- if fs, ok := h.(interface{ SetFormat(string) error }); ok {
- if err := fs.SetFormat(format); err != nil {
- return err
- }
- }
- if ts, ok := h.(interface{ SetDisableTimestamp(bool) }); ok {
- ts.SetDisableTimestamp(c.GetBool("logging.disable_timestamp", false))
- }
- return nil
- }
- // ParseLevel converts a config-string level name ("trace", "debug", "info",
- // "warn"/"warning", "error", "fatal"/"panic") to a slog.Level. "fatal" and
- // "panic" are accepted for backwards compatibility with pre-slog configs
- // and both map to slog.LevelError.
- func ParseLevel(s string) (slog.Level, error) {
- switch s {
- case "trace":
- return LevelTrace, nil
- case "debug":
- return slog.LevelDebug, nil
- case "info":
- return slog.LevelInfo, nil
- case "warn", "warning":
- return slog.LevelWarn, nil
- case "error":
- return slog.LevelError, nil
- case "fatal", "panic":
- return slog.LevelError, nil
- default:
- return 0, fmt.Errorf("not a valid logging level: %q", s)
- }
- }
- // LevelName returns a human-readable name for a slog.Level matching the
- // strings accepted by ParseLevel.
- func LevelName(l slog.Level) string {
- switch {
- case l <= LevelTrace:
- return "trace"
- case l <= slog.LevelDebug:
- return "debug"
- case l <= slog.LevelInfo:
- return "info"
- case l <= slog.LevelWarn:
- return "warn"
- default:
- return "error"
- }
- }
|