diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 30fdba5..0b78909 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -31,9 +31,9 @@ import ( "github.com/kardianos/service" "github.com/miekg/dns" "github.com/pelletier/go-toml/v2" - "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" + "go.uber.org/zap" "tailscale.com/logtail/backoff" "tailscale.com/net/netmon" @@ -224,7 +224,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { if addr, err := net.ResolveUnixAddr("unix", sockPath); err == nil { if conn, err := net.Dial(addr.Network(), addr.String()); err == nil { lc := &logConn{conn: conn} - consoleWriter.Out = io.MultiWriter(os.Stdout, lc) + consoleWriter = newHumanReadableZapCore(io.MultiWriter(os.Stdout, lc), consoleWriterLevel) p.logConn = lc } else { if !errors.Is(err, os.ErrNotExist) { @@ -307,7 +307,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { return } - cdLogger := p.logger.Load().With().Str("mode", "cd").Logger() + cdLogger := p.logger.Load().With().Str("mode", "cd") // Performs self-uninstallation if the ControlD device does not exist. var uer *controld.ErrorResponse if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode { @@ -339,8 +339,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath { // After processCDFlags, log config may change, so reset mainLog and re-init logging. - l := zerolog.New(io.Discard) - mainLog.Store(&ctrld.Logger{Logger: &l}) + l := zap.NewNop() + mainLog.Store(&ctrld.Logger{Logger: l}) // Copy logs written so far to new log file if possible. if buf, err := os.ReadFile(oldLogPath); err == nil { @@ -603,11 +603,11 @@ func deactivationPinSet() bool { } func processCDFlags(cfg *ctrld.Config) (*controld.ResolverConfig, error) { - logger := mainLog.Load().With().Str("mode", "cd").Logger() + logger := mainLog.Load().With().Str("mode", "cd") logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID) bo := backoff.NewBackoff("processCDFlags", logf, 30*time.Second) bo.LogLongerThan = 30 * time.Second - ctx := ctrld.LoggerCtx(context.Background(), mainLog.Load()) + ctx := ctrld.LoggerCtx(context.Background(), logger) resolverConfig, err := controld.FetchResolverConfig(ctx, cdUID, rootCmd.Version, cdDev) for { if errUrlNetworkError(err) { @@ -1210,7 +1210,7 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, notifyFunc func(), fatal bool) ( return errors.Join(udpErr, tcpErr) } - logMsg := func(e *zerolog.Event, listenerNum int, format string, v ...any) { + logMsg := func(e *ctrld.LogEvent, listenerNum int, format string, v ...any) { e.MsgFunc(func() string { return fmt.Sprintf("listener.%d %s", listenerNum, fmt.Sprintf(format, v...)) }) @@ -1773,7 +1773,7 @@ func doValidateCdRemoteConfig(cdUID string, fatal bool) error { } // uninstallInvalidCdUID performs self-uninstallation because the ControlD device does not exist. -func uninstallInvalidCdUID(p *prog, logger zerolog.Logger, doStop bool) bool { +func uninstallInvalidCdUID(p *prog, logger *ctrld.Logger, doStop bool) bool { s, err := newService(p, svcConfig) if err != nil { logger.Warn().Err(err).Msg("failed to create new service") diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 8e8ffc0..1707153 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -270,7 +270,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c _, _ = patchNetIfaceName(iff) name = iff.Name } - logger := mainLog.Load().With().Str("iface", name).Logger() + logger := mainLog.Load().With().Str("iface", name) logger.Debug().Msg("setting DNS successfully") if res.All { // Log that DNS is set for other interfaces. diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index 33ca60c..b24fb89 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -1099,7 +1099,7 @@ func (p *prog) doSelfUninstall(pr *proxyResponse) { return } - logger := p.logger.Load().With().Str("mode", "self-uninstall").Logger() + logger := p.logger.Load().With().Str("mode", "self-uninstall") if p.refusedQueryCount > selfUninstallMaxQueries { p.checkingSelfUninstall = true loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load()) diff --git a/cmd/cli/log_writer.go b/cmd/cli/log_writer.go index c2880c0..d7f6839 100644 --- a/cmd/cli/log_writer.go +++ b/cmd/cli/log_writer.go @@ -10,7 +10,8 @@ import ( "sync" "time" - "github.com/rs/zerolog" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" "github.com/Control-D-Inc/ctrld" ) @@ -95,16 +96,15 @@ func (lw *logWriter) Write(p []byte) (int, error) { // initLogging initializes global logging setup. func (p *prog) initLogging(backup bool) { - zerolog.TimeFieldFormat = time.RFC3339 + ".000" - logWriters := initLoggingWithBackup(backup) + logCores := initLoggingWithBackup(backup) // Initializing internal logging after global logging. - p.initInternalLogging(logWriters) + p.initInternalLogging(logCores) p.logger.Store(mainLog.Load()) } // initInternalLogging performs internal logging if there's no log enabled. -func (p *prog) initInternalLogging(writers []io.Writer) { +func (p *prog) initInternalLogging(externalCores []zapcore.Core) { if !p.needInternalLogging() { return } @@ -118,27 +118,25 @@ func (p *prog) initInternalLogging(writers []io.Writer) { lw := p.internalLogWriter wlw := p.internalWarnLogWriter p.mu.Unlock() - // If ctrld was run without explicit verbose level, - // run the internal logging at debug level, so we could + + // Create zap cores for different writers + var cores []zapcore.Core + cores = append(cores, externalCores...) + + // Add core for internal log writer. + // Run the internal logging at debug level, so we could // have enough information for troubleshooting. - if verbose == 0 { - for i := range writers { - w := &zerolog.FilteredLevelWriter{ - Writer: zerolog.LevelWriterAdapter{Writer: writers[i]}, - Level: zerolog.NoticeLevel, - } - writers[i] = w - } - zerolog.SetGlobalLevel(zerolog.DebugLevel) - } - writers = append(writers, lw) - writers = append(writers, &zerolog.FilteredLevelWriter{ - Writer: zerolog.LevelWriterAdapter{Writer: wlw}, - Level: zerolog.WarnLevel, - }) - multi := zerolog.MultiLevelWriter(writers...) - l := mainLog.Load().Output(multi).With().Logger() - mainLog.Store(&ctrld.Logger{Logger: &l}) + internalCore := newHumanReadableZapCore(lw, zapcore.DebugLevel) + cores = append(cores, internalCore) + + // Add core for internal warn log writer + warnCore := newHumanReadableZapCore(wlw, zapcore.WarnLevel) + cores = append(cores, warnCore) + + // Create a multi-core logger + multiCore := zapcore.NewTee(cores...) + logger := zap.New(multiCore) + mainLog.Store(&ctrld.Logger{Logger: logger}) } // needInternalLogging reports whether prog needs to run internal logging. @@ -202,3 +200,49 @@ func (p *prog) logReader() (*logReader, error) { } return lr, nil } + +// newHumanReadableZapCore creates a zap core optimized for human-readable log output. +// +// Features: +// - Uses development encoder configuration for enhanced readability +// - Console encoding with colored log levels for easy visual scanning +// - Millisecond precision timestamps in human-friendly format +// - Structured field output with clear key-value pairs +// - Ideal for development, debugging, and interactive terminal sessions +// +// Parameters: +// - w: The output writer (e.g., os.Stdout, file, buffer) +// - level: Minimum log level to capture (e.g., Debug, Info, Warn, Error) +// +// Returns a zapcore.Core configured for human consumption. +func newHumanReadableZapCore(w io.Writer, level zapcore.Level) zapcore.Core { + encoderConfig := zap.NewDevelopmentEncoderConfig() + encoderConfig.TimeKey = "time" + encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.StampMilli) + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + encoder := zapcore.NewConsoleEncoder(encoderConfig) + return zapcore.NewCore(encoder, zapcore.AddSync(w), level) +} + +// newMachineFriendlyZapCore creates a zap core optimized for machine processing and log aggregation. +// +// Features: +// - Uses production encoder configuration for consistent, parseable output +// - Console encoding with non-colored log levels for log parsing tools +// - Millisecond precision timestamps in ISO-like format +// - Structured field output optimized for log aggregation systems +// - Ideal for production environments, log shipping, and automated analysis +// +// Parameters: +// - w: The output writer (e.g., os.Stdout, file, buffer) +// - level: Minimum log level to capture (e.g., Debug, Info, Warn, Error) +// +// Returns a zapcore.Core configured for machine consumption and log aggregation. +func newMachineFriendlyZapCore(w io.Writer, level zapcore.Level) zapcore.Core { + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.TimeKey = "time" + encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.StampMilli) + encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder + encoder := zapcore.NewConsoleEncoder(encoderConfig) + return zapcore.NewCore(encoder, zapcore.AddSync(w), level) +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 53b8309..cb06504 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -5,10 +5,10 @@ import ( "os" "path/filepath" "sync/atomic" - "time" "github.com/kardianos/service" - "github.com/rs/zerolog" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" "github.com/Control-D-Inc/ctrld" ) @@ -40,9 +40,10 @@ var ( cleanup bool startOnly bool - mainLog atomic.Pointer[ctrld.Logger] - consoleWriter zerolog.ConsoleWriter - noConfigStart bool + mainLog atomic.Pointer[ctrld.Logger] + consoleWriter zapcore.Core + consoleWriterLevel zapcore.Level + noConfigStart bool ) const ( @@ -53,8 +54,8 @@ const ( ) func init() { - l := zerolog.New(io.Discard) - mainLog.Store(&ctrld.Logger{Logger: &l}) + l := zap.NewNop() + mainLog.Store(&ctrld.Logger{Logger: l}) } func Main() { @@ -82,23 +83,23 @@ func normalizeLogFilePath(logFilePath string) string { // initConsoleLogging initializes console logging, then storing to mainLog. func initConsoleLogging() { - consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { - w.TimeFormat = time.StampMilli - }) - multi := zerolog.MultiLevelWriter(consoleWriter) - l := mainLog.Load().Output(multi).With().Timestamp().Logger() - mainLog.Store(&ctrld.Logger{Logger: &l}) - + consoleWriterLevel = zapcore.InfoLevel switch { case silent: - zerolog.SetGlobalLevel(zerolog.NoLevel) + // For silent mode, use a no-op logger + l := zap.NewNop() + mainLog.Store(&ctrld.Logger{Logger: l}) case verbose == 1: - zerolog.SetGlobalLevel(zerolog.InfoLevel) + // Info level is default case verbose > 1: - zerolog.SetGlobalLevel(zerolog.DebugLevel) + // Debug level + consoleWriterLevel = zapcore.DebugLevel default: - zerolog.SetGlobalLevel(zerolog.NoticeLevel) + // Notice level maps to Info in zap } + consoleWriter = newHumanReadableZapCore(os.Stdout, consoleWriterLevel) + l := zap.New(consoleWriter) + mainLog.Store(&ctrld.Logger{Logger: l}) } // initInteractiveLogging is like initLogging, but the ProxyLogger is discarded @@ -108,7 +109,6 @@ func initConsoleLogging() { func initInteractiveLogging() { old := cfg.Service.LogPath cfg.Service.LogPath = "" - zerolog.TimeFieldFormat = time.RFC3339 + ".000" initLoggingWithBackup(false) cfg.Service.LogPath = old } @@ -119,7 +119,7 @@ func initInteractiveLogging() { // This is only used in runCmd for special handling in case of logging config // change in cd mode. Without special reason, the caller should use initLogging // wrapper instead of calling this function directly. -func initLoggingWithBackup(doBackup bool) []io.Writer { +func initLoggingWithBackup(doBackup bool) []zapcore.Core { var writers []io.Writer if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" { // Create parent directory if necessary. @@ -146,32 +146,53 @@ func initLoggingWithBackup(doBackup bool) []io.Writer { } writers = append(writers, logFile) } - writers = append(writers, consoleWriter) - multi := zerolog.MultiLevelWriter(writers...) - l := mainLog.Load().Output(multi).With().Logger() - mainLog.Store(&ctrld.Logger{Logger: &l}) - zerolog.SetGlobalLevel(zerolog.NoticeLevel) + // Create zap cores for different writers + var cores []zapcore.Core + cores = append(cores, consoleWriter) + + // Determine log level logLevel := cfg.Service.LogLevel switch { case silent: - zerolog.SetGlobalLevel(zerolog.NoLevel) - return writers + // For silent mode, use a no-op logger + l := zap.NewNop() + mainLog.Store(&ctrld.Logger{Logger: l}) + return cores case verbose == 1: logLevel = "info" case verbose > 1: logLevel = "debug" } - if logLevel == "" { - return writers + + // Parse log level + var level zapcore.Level + switch logLevel { + case "debug": + level = zapcore.DebugLevel + case "info": + level = zapcore.InfoLevel + case "warn": + level = zapcore.WarnLevel + case "error": + level = zapcore.ErrorLevel + default: + level = zapcore.InfoLevel // default level } - level, err := zerolog.ParseLevel(logLevel) - if err != nil { - mainLog.Load().Warn().Err(err).Msg("could not set log level") - return writers + + consoleWriter.Enabled(level) + // Add cores for all writers + for _, writer := range writers { + core := newMachineFriendlyZapCore(writer, level) + cores = append(cores, core) } - zerolog.SetGlobalLevel(level) - return writers + + // Create a multi-core logger + multiCore := zapcore.NewTee(cores...) + logger := zap.New(multiCore) + mainLog.Store(&ctrld.Logger{Logger: logger}) + + return cores } func initCache() { diff --git a/cmd/cli/main_test.go b/cmd/cli/main_test.go index c7b8b17..d0a1149 100644 --- a/cmd/cli/main_test.go +++ b/cmd/cli/main_test.go @@ -5,7 +5,8 @@ import ( "strings" "testing" - "github.com/rs/zerolog" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" "github.com/Control-D-Inc/ctrld" ) @@ -13,7 +14,19 @@ import ( var logOutput strings.Builder func TestMain(m *testing.M) { - l := zerolog.New(&logOutput) - mainLog.Store(&ctrld.Logger{Logger: &l}) + // Create a custom writer that writes to logOutput + writer := zapcore.AddSync(&logOutput) + + // Create zap encoder + encoderConfig := zap.NewDevelopmentEncoderConfig() + encoder := zapcore.NewConsoleEncoder(encoderConfig) + + // Create core that writes to our string builder + core := zapcore.NewCore(encoder, writer, zap.DebugLevel) + + // Create logger + l := zap.New(core) + + mainLog.Store(&ctrld.Logger{Logger: l}) os.Exit(m.Run()) } diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index 5d3c101..8f56b83 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -24,7 +24,6 @@ import ( "github.com/Masterminds/semver/v3" "github.com/kardianos/service" - "github.com/rs/zerolog" "github.com/spf13/viper" "golang.org/x/sync/singleflight" "tailscale.com/net/netmon" @@ -296,7 +295,7 @@ func (p *prog) apiConfigReload() { ticker := time.NewTicker(timeDurationOrDefault(p.cfg.Service.RefetchTime, 3600) * time.Second) defer ticker.Stop() - logger := p.logger.Load().With().Str("mode", "api-reload").Logger() + logger := p.logger.Load().With().Str("mode", "api-reload") logger.Debug().Msg("starting custom config reload timer") lastUpdated := time.Now().Unix() curVerStr := curVersion() @@ -310,7 +309,7 @@ func (p *prog) apiConfigReload() { l.Msgf("current version is not stable, skipping self-upgrade: %s", curVerStr) } - doReloadApiConfig := func(forced bool, logger zerolog.Logger) { + doReloadApiConfig := func(forced bool, logger *ctrld.Logger) { loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load()) resolverConfig, err := controld.FetchResolverConfig(loggerCtx, cdUID, rootCmd.Version, cdDev) selfUninstallCheck(err, p, logger) @@ -321,7 +320,7 @@ func (p *prog) apiConfigReload() { // Performing self-upgrade check for production version. if isStable { - _ = selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, &logger) + _ = selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, logger) } if resolverConfig.DeactivationPin != nil { @@ -384,7 +383,7 @@ func (p *prog) apiConfigReload() { for { select { case <-p.apiForceReloadCh: - doReloadApiConfig(true, logger.With().Bool("forced", true).Logger()) + doReloadApiConfig(true, logger.With().Bool("forced", true)) case <-ticker.C: doReloadApiConfig(false, logger) case <-p.stopCh: @@ -578,7 +577,7 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) { if !reload { // Stop writing log to unix socket. - consoleWriter.Out = os.Stdout + consoleWriter = newHumanReadableZapCore(os.Stdout, consoleWriterLevel) p.initLogging(false) if p.logConn != nil { _ = p.logConn.Close() @@ -758,7 +757,7 @@ func (p *prog) setDnsForRunningIface(nameservers []string) (runningIface *net.In return } - logger := p.logger.Load().With().Str("iface", p.runningIface).Logger() + logger := p.logger.Load().With().Str("iface", p.runningIface) const maxDNSRetryAttempts = 3 const retryDelay = 1 * time.Second @@ -774,7 +773,7 @@ func (p *prog) setDnsForRunningIface(nameservers []string) (runningIface *net.In newIface := p.findWorkingInterface() if newIface != p.runningIface { p.runningIface = newIface - logger = p.logger.Load().With().Str("iface", p.runningIface).Logger() + logger = p.logger.Load().With().Str("iface", p.runningIface) logger.Info().Msg("switched to new interface") continue } @@ -930,7 +929,7 @@ func (p *prog) resetDNSForRunningIface(isStart bool, restoreStatic bool) (runnin p.Debug().Msg("no running interface, skipping resetDNS") return } - logger := p.logger.Load().With().Str("iface", p.runningIface).Logger() + logger := p.logger.Load().With().Str("iface", p.runningIface) netIface, err := netInterface(p.runningIface) if err != nil { logger.Error().Err(err).Msg("could not get interface") @@ -1416,7 +1415,7 @@ func (p *prog) dnsChanged(iface *net.Interface, nameservers []string) bool { } // selfUninstallCheck checks if the error dues to controld.InvalidConfigCode, perform self-uninstall then. -func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) { +func selfUninstallCheck(uninstallErr error, p *prog, logger *ctrld.Logger) { var uer *controld.ErrorResponse if errors.As(uninstallErr, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode { p.stopDnsWatchers() @@ -1431,7 +1430,7 @@ func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) { // // The callers must ensure curVer and logger are non-nil. // Returns true if upgrade is allowed, false otherwise. -func shouldUpgrade(vt string, cv *semver.Version, logger *zerolog.Logger) bool { +func shouldUpgrade(vt string, cv *semver.Version, logger *ctrld.Logger) bool { if vt == "" { logger.Debug().Msg("no version target set, skipped checking self-upgrade") return false @@ -1468,7 +1467,7 @@ func shouldUpgrade(vt string, cv *semver.Version, logger *zerolog.Logger) bool { // performUpgrade executes the self-upgrade command. // Returns true if upgrade was initiated successfully, false otherwise. -func performUpgrade(vt string, logger *zerolog.Logger) bool { +func performUpgrade(vt string, logger *ctrld.Logger) bool { exe, err := os.Executable() if err != nil { logger.Error().Err(err).Msg("failed to get executable path, skipped self-upgrade") @@ -1490,7 +1489,7 @@ func performUpgrade(vt string, logger *zerolog.Logger) bool { // // The callers must ensure curVer and logger are non-nil. // Returns true if upgrade is allowed and should proceed, false otherwise. -func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) bool { +func selfUpgradeCheck(vt string, cv *semver.Version, logger *ctrld.Logger) bool { if shouldUpgrade(vt, cv, logger) { return performUpgrade(vt, logger) } diff --git a/cmd/cli/prog_log.go b/cmd/cli/prog_log.go index dec20e9..91e797e 100644 --- a/cmd/cli/prog_log.go +++ b/cmd/cli/prog_log.go @@ -1,33 +1,33 @@ package cli -import "github.com/rs/zerolog" +import "github.com/Control-D-Inc/ctrld" // Debug starts a new message with debug level. -func (p *prog) Debug() *zerolog.Event { +func (p *prog) Debug() *ctrld.LogEvent { return p.logger.Load().Debug() } // Warn starts a new message with warn level. -func (p *prog) Warn() *zerolog.Event { +func (p *prog) Warn() *ctrld.LogEvent { return p.logger.Load().Warn() } // Info starts a new message with info level. -func (p *prog) Info() *zerolog.Event { +func (p *prog) Info() *ctrld.LogEvent { return p.logger.Load().Info() } // Fatal starts a new message with fatal level. -func (p *prog) Fatal() *zerolog.Event { +func (p *prog) Fatal() *ctrld.LogEvent { return p.logger.Load().Fatal() } // Error starts a new message with error level. -func (p *prog) Error() *zerolog.Event { +func (p *prog) Error() *ctrld.LogEvent { return p.logger.Load().Error() } // Notice starts a new message with notice level. -func (p *prog) Notice() *zerolog.Event { +func (p *prog) Notice() *ctrld.LogEvent { return p.logger.Load().Notice() } diff --git a/cmd/cli/prog_test.go b/cmd/cli/prog_test.go index 1fee462..eccc30b 100644 --- a/cmd/cli/prog_test.go +++ b/cmd/cli/prog_test.go @@ -5,8 +5,8 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "go.uber.org/zap" "github.com/Control-D-Inc/ctrld" ) @@ -173,10 +173,10 @@ func Test_shouldUpgrade(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { // Create test logger - testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger() + testLogger := &ctrld.Logger{Logger: zap.NewNop()} // Call the function and capture the result - result := shouldUpgrade(tc.versionTarget, tc.currentVersion, &testLogger) + result := shouldUpgrade(tc.versionTarget, tc.currentVersion, testLogger) // Assert the expected result assert.Equal(t, tc.shouldUpgrade, result, tc.description) @@ -221,10 +221,10 @@ func Test_selfUpgradeCheck(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { // Create test logger - testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger() + testLogger := &ctrld.Logger{Logger: zap.NewNop()} // Call the function and capture the result - result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, &testLogger) + result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, testLogger) // Assert the expected result assert.Equal(t, tc.shouldUpgrade, result, tc.description) @@ -256,8 +256,10 @@ func Test_performUpgrade(t *testing.T) { for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { + // Create test logger + testLogger := &ctrld.Logger{Logger: zap.NewNop()} // Call the function and capture the result - result := performUpgrade(tc.versionTarget) + result := performUpgrade(tc.versionTarget, testLogger) assert.Equal(t, tc.expectedResult, result, tc.description) }) } diff --git a/cmd/cli/self_kill_others.go b/cmd/cli/self_kill_others.go index e9fb1f8..d656c12 100644 --- a/cmd/cli/self_kill_others.go +++ b/cmd/cli/self_kill_others.go @@ -5,10 +5,10 @@ package cli import ( "os" - "github.com/rs/zerolog" + "github.com/Control-D-Inc/ctrld" ) -func selfUninstall(p *prog, logger zerolog.Logger) { +func selfUninstall(p *prog, logger *ctrld.Logger) { if uninstallInvalidCdUID(p, logger, false) { logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID) os.Exit(0) diff --git a/cmd/cli/self_kill_unix.go b/cmd/cli/self_kill_unix.go index 157425f..8e7488b 100644 --- a/cmd/cli/self_kill_unix.go +++ b/cmd/cli/self_kill_unix.go @@ -9,10 +9,10 @@ import ( "runtime" "syscall" - "github.com/rs/zerolog" + "github.com/Control-D-Inc/ctrld" ) -func selfUninstall(p *prog, logger zerolog.Logger) { +func selfUninstall(p *prog, logger *ctrld.Logger) { if runtime.GOOS == "linux" { selfUninstallLinux(p, logger) } @@ -37,7 +37,7 @@ func selfUninstall(p *prog, logger zerolog.Logger) { os.Exit(0) } -func selfUninstallLinux(p *prog, logger zerolog.Logger) { +func selfUninstallLinux(p *prog, logger *ctrld.Logger) { if uninstallInvalidCdUID(p, logger, true) { logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID) os.Exit(0) diff --git a/go.mod b/go.mod index a911c76..f276d96 100644 --- a/go.mod +++ b/go.mod @@ -30,11 +30,11 @@ require ( github.com/prometheus/client_model v0.5.0 github.com/prometheus/prom2json v1.3.3 github.com/quic-go/quic-go v0.48.2 - github.com/rs/zerolog v1.28.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.9.0 github.com/vishvananda/netlink v1.2.1-beta.2 + go.uber.org/zap v1.27.0 golang.org/x/net v0.38.0 golang.org/x/sync v0.12.0 golang.org/x/sys v0.31.0 @@ -65,8 +65,6 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mdlayher/netlink v1.7.2 // indirect @@ -90,6 +88,7 @@ require ( github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect github.com/vishvananda/netns v0.0.4 // indirect go.uber.org/mock v0.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.36.0 // indirect diff --git a/go.sum b/go.sum index 25af133..546e1a8 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c h1:UqFsxmwiCh/DBvwJB0m7KQ2QFDd6DdUkosznfMppdhE= -github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= @@ -213,12 +211,6 @@ github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -282,7 +274,6 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= @@ -330,8 +321,14 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= @@ -482,12 +479,9 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= diff --git a/internal/net/net.go b/internal/net/net.go index f4b5586..ec8910b 100644 --- a/internal/net/net.go +++ b/internal/net/net.go @@ -3,7 +3,6 @@ package net import ( "context" "errors" - "io" "net" "os" "os/signal" @@ -12,7 +11,7 @@ import ( "syscall" "time" - "github.com/rs/zerolog" + "go.uber.org/zap" "tailscale.com/logtail/backoff" ) @@ -34,8 +33,8 @@ var Dialer = &net.Dialer{ Dial: func(ctx context.Context, network, address string) (net.Conn, error) { d := ParallelDialer{} d.Timeout = 10 * time.Second - l := zerolog.New(io.Discard) - return d.DialContext(ctx, "udp", []string{v4BootstrapDNS, v6BootstrapDNS}, &l) + l := zap.NewNop() + return d.DialContext(ctx, "udp", []string{v4BootstrapDNS, v6BootstrapDNS}, l) }, }, } @@ -161,7 +160,7 @@ type ParallelDialer struct { net.Dialer } -func (d *ParallelDialer) DialContext(ctx context.Context, network string, addrs []string, logger *zerolog.Logger) (net.Conn, error) { +func (d *ParallelDialer) DialContext(ctx context.Context, network string, addrs []string, logger *zap.Logger) (net.Conn, error) { if len(addrs) == 0 { return nil, errors.New("empty addresses") } @@ -181,16 +180,16 @@ func (d *ParallelDialer) DialContext(ctx context.Context, network string, addrs for _, addr := range addrs { go func(addr string) { defer wg.Done() - logger.Debug().Msgf("dialing to %s", addr) + logger.Debug("dialing to", zap.String("address", addr)) conn, err := d.Dialer.DialContext(ctx, network, addr) if err != nil { - logger.Debug().Msgf("failed to dial %s: %v", addr, err) + logger.Debug("failed to dial", zap.String("address", addr), zap.Error(err)) } select { case ch <- ¶llelDialerResult{conn: conn, err: err}: case <-done: if conn != nil { - logger.Debug().Msgf("connection closed: %s", conn.RemoteAddr()) + logger.Debug("connection closed", zap.String("remote_address", conn.RemoteAddr().String())) conn.Close() } } @@ -201,7 +200,7 @@ func (d *ParallelDialer) DialContext(ctx context.Context, network string, addrs for res := range ch { if res.err == nil { cancel() - logger.Debug().Msgf("connected to %s", res.conn.RemoteAddr()) + logger.Debug("connected to", zap.String("remote_address", res.conn.RemoteAddr().String())) return res.conn, res.err } errs = append(errs, res.err) diff --git a/log.go b/log.go index 7b7037b..c961268 100644 --- a/log.go +++ b/log.go @@ -3,8 +3,11 @@ package ctrld import ( "context" "fmt" + "io" + "time" - "github.com/rs/zerolog" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) // LoggerCtxKey is the context.Context key for a logger. @@ -17,13 +20,13 @@ func LoggerCtx(ctx context.Context, l *Logger) context.Context { // A Logger provides fast, leveled, structured logging. type Logger struct { - *zerolog.Logger + *zap.Logger } -var noOpZeroLogger = zerolog.Nop() +var noOpZapLogger = zap.NewNop() // NopLogger returns a logger which all operation are no-op. -var NopLogger = &Logger{&noOpZeroLogger} +var NopLogger = &Logger{noOpZapLogger} // LoggerFromCtx returns the logger associated with given ctx. // @@ -38,9 +41,80 @@ func LoggerFromCtx(ctx context.Context) *Logger { // ReqIdCtxKey is the context.Context key for a request id. type ReqIdCtxKey struct{} -// Log emits the logs for a particular zerolog event. +// LogEvent represents a logging event with structured fields +type LogEvent struct { + logger *zap.Logger + level zapcore.Level + fields []zap.Field +} + +// Msg logs the message with the collected fields +func (e *LogEvent) Msg(msg string) { + e.logger.Check(e.level, msg).Write(e.fields...) +} + +// Msgf logs a formatted message with the collected fields +func (e *LogEvent) Msgf(format string, v ...any) { + e.Msg(fmt.Sprintf(format, v...)) +} + +// MsgFunc logs a message from a function with the collected fields +func (e *LogEvent) MsgFunc(fn func() string) { + e.Msg(fn()) +} + +// Str adds a string field to the event +func (e *LogEvent) Str(key, val string) *LogEvent { + e.fields = append(e.fields, zap.String(key, val)) + return e +} + +// Int adds an integer field to the event +func (e *LogEvent) Int(key string, val int) *LogEvent { + e.fields = append(e.fields, zap.Int(key, val)) + return e +} + +// Int64 adds an int64 field to the event +func (e *LogEvent) Int64(key string, val int64) *LogEvent { + e.fields = append(e.fields, zap.Int64(key, val)) + return e +} + +// Err adds an error field to the event +func (e *LogEvent) Err(err error) *LogEvent { + if err != nil { + e.fields = append(e.fields, zap.Error(err)) + } + return e +} + +// Bool adds a boolean field to the event +func (e *LogEvent) Bool(key string, val bool) *LogEvent { + e.fields = append(e.fields, zap.Bool(key, val)) + return e +} + +// Interface adds an interface field to the event +func (e *LogEvent) Interface(key string, val interface{}) *LogEvent { + e.fields = append(e.fields, zap.Any(key, val)) + return e +} + +// Any adds an interface field to the event (alias for Interface) +func (e *LogEvent) Any(key string, val interface{}) *LogEvent { + return e.Interface(key, val) +} + +// Strs adds a string slice field to the event +func (e *LogEvent) Strs(key string, vals []string) *LogEvent { + e.fields = append(e.fields, zap.Strings(key, vals)) + return e +} + +// Log emits the logs for a particular logging event. // The request id associated with the context will be included if presents. -func Log(ctx context.Context, e *zerolog.Event, format string, v ...any) { +func Log(ctx context.Context, e *LogEvent, format string, v ...any) { id, ok := ctx.Value(ReqIdCtxKey{}).(string) if !ok { e.Msgf(format, v...) @@ -50,3 +124,123 @@ func Log(ctx context.Context, e *zerolog.Event, format string, v ...any) { return fmt.Sprintf("[%s] %s", id, fmt.Sprintf(format, v...)) }) } + +// Logger methods that mimic zerolog API +func (l *Logger) Debug() *LogEvent { + return &LogEvent{ + logger: l.Logger, + level: zapcore.DebugLevel, + fields: []zap.Field{}, + } +} + +func (l *Logger) Info() *LogEvent { + return &LogEvent{ + logger: l.Logger, + level: zapcore.InfoLevel, + fields: []zap.Field{}, + } +} + +func (l *Logger) Warn() *LogEvent { + return &LogEvent{ + logger: l.Logger, + level: zapcore.WarnLevel, + fields: []zap.Field{}, + } +} + +func (l *Logger) Error() *LogEvent { + return &LogEvent{ + logger: l.Logger, + level: zapcore.ErrorLevel, + fields: []zap.Field{}, + } +} + +func (l *Logger) Fatal() *LogEvent { + return &LogEvent{ + logger: l.Logger, + level: zapcore.FatalLevel, + fields: []zap.Field{}, + } +} + +func (l *Logger) Notice() *LogEvent { + return &LogEvent{ + logger: l.Logger, + level: zapcore.InfoLevel, // zap doesn't have Notice level, use Info + fields: []zap.Field{}, + } +} + +// With returns a logger with additional fields +func (l *Logger) With() *Logger { + return l +} + +// Str adds a string field to the logger +func (l *Logger) Str(key, val string) *Logger { + // Create a new logger with the field added + newLogger := l.Logger.With(zap.String(key, val)) + return &Logger{newLogger} +} + +// Err adds an error field to the logger +func (l *Logger) Err(err error) *Logger { + // Create a new logger with the error field added + newLogger := l.Logger.With(zap.Error(err)) + return &Logger{newLogger} +} + +// Any adds an interface field to the logger +func (l *Logger) Any(key string, val interface{}) *Logger { + // Create a new logger with the field added + newLogger := l.Logger.With(zap.Any(key, val)) + return &Logger{newLogger} +} + +// Bool adds a boolean field to the logger +func (l *Logger) Bool(key string, val bool) *Logger { + // Create a new logger with the field added + newLogger := l.Logger.With(zap.Bool(key, val)) + return &Logger{newLogger} +} + +// Msgf logs a formatted message at info level +func (l *Logger) Msgf(format string, v ...any) { + l.Info().Msgf(format, v...) +} + +// Msg logs a message at info level +func (l *Logger) Msg(msg string) { + l.Info().Msg(msg) +} + +// Output returns a logger with the specified output +func (l *Logger) Output(w io.Writer) *Logger { + // Create a new zap logger with the writer + encoderConfig := zap.NewDevelopmentEncoderConfig() + encoderConfig.TimeKey = "time" + encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.RFC3339) + encoder := zapcore.NewConsoleEncoder(encoderConfig) + core := zapcore.NewCore(encoder, zapcore.AddSync(w), zapcore.InfoLevel) + newLogger := zap.New(core) + return &Logger{newLogger} +} + +// GetLogger returns the underlying logger +func (l *Logger) GetLogger() *Logger { + return l +} + +// Write implements io.Writer to allow direct writing to the logger +func (l *Logger) Write(p []byte) (n int, err error) { + l.Info().Msg(string(p)) + return len(p), nil +} + +// Printf logs a formatted message at info level +func (l *Logger) Printf(format string, v ...any) { + l.Info().Msgf(format, v...) +}