From 43c98b08f9798f3287d7776cc6e69c71101623b4b953958d66f791cdb5f80d08 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Wed, 9 Jul 2025 21:21:14 -0400 Subject: [PATCH] e1.5.0 Log sink options added. --- README.md | 50 ++++++++++++++ config.go | 14 ++++ example/sink/main.go | 155 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 +- go.sum | 8 +-- logger.go | 109 ++++++++++++++++++++---------- processor.go | 76 +++++++++++++++------ state.go | 9 ++- storage.go | 17 +++++ 9 files changed, 381 insertions(+), 61 deletions(-) create mode 100644 example/sink/main.go diff --git a/README.md b/README.md index 0166024..8bb553a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,9 @@ if err != nil { | `max_check_interval_ms` | `int64` | Maximum interval (ms) for adaptive disk checks | `60000` | | `heartbeat_level` | `int64` | Heartbeat detail level (0=disabled, 1=proc, 2=proc+disk, 3=proc+disk+sys) | `0` | | `heartbeat_interval_s` | `int64` | Interval (s) between heartbeat messages | `60` | +| `enable_stdout` | `bool` | Mirror all log output to stdout/stderr | `false` | +| `stdout_target` | `string` | Target for console output (`"stdout"` or `"stderr"`) | `"stdout"` | +| `disable_file` | `bool` | Disable file output entirely (console-only mode) | `false` | ## API Reference @@ -210,6 +213,53 @@ Called on an initialized `*Logger` instance. the configured `level` filter. - **`FlagShowTimestamp`, `FlagShowLevel`, `FlagDefault`**: Record flag constants controlling output format. +### Console Output Configuration + +The logger supports flexible output routing with options to mirror logs to stdout/stderr or disable file output entirely. + +**Console-only logging** (no file output): +```go +logger := log.NewLogger() +err := logger.InitWithDefaults( +"enable_stdout=true", +"disable_file=true", +"level=-4", // Debug level +) +defer logger.Shutdown() + +logger.Info("This goes only to console") +``` + +**Dual output** (both file and console): +```go +gologger := log.NewLogger() +err := logger.InitWithDefaults( +"directory=/var/log/app", +"enable_stdout=true", +"stdout_target=stderr", // Use stderr to keep stdout clean +) +defer logger.Shutdown() + +logger.Info("This goes to both file and stderr") +``` + +**Dynamic configuration** (toggle at runtime): +```go +// Start with file-only logging +logger := log.NewLogger() +cfg := config.New() +cfg.Load("app.toml", os.Args[1:]) +logger.Init(cfg, "logging") + +// Later, enable console output dynamically +cfg.Set("logging.enable_stdout", true) +logger.Init(cfg, "logging") // Reconfigure + +// Or disable file output +cfg.Set("logging.disable_file", true) +logger.Init(cfg, "logging") // Now console-only +``` + ## Implementation Details - **Lock-Free Hot Path:** Logging methods (`l.Info`, `l.Debug`, etc.) operate without locks, using atomic operations to diff --git a/config.go b/config.go index ba0911c..0c2e8b0 100644 --- a/config.go +++ b/config.go @@ -40,6 +40,11 @@ type Config struct { // Heartbeat configuration HeartbeatLevel int64 `toml:"heartbeat_level"` // 0=disabled, 1=proc only, 2=proc+disk, 3=proc+disk+sys HeartbeatIntervalS int64 `toml:"heartbeat_interval_s"` // Interval seconds for heartbeat + + // Stdout/console output settings + EnableStdout bool `toml:"enable_stdout"` // Mirror logs to stdout/stderr + StdoutTarget string `toml:"stdout_target"` // "stdout" or "stderr" + DisableFile bool `toml:"disable_file"` // Disable file output entirely } // defaultConfig is the single source for all configurable default values @@ -77,6 +82,11 @@ var defaultConfig = Config{ // Heartbeat settings HeartbeatLevel: 0, HeartbeatIntervalS: 60, + + // Stdout settings + EnableStdout: false, + StdoutTarget: "stdout", + DisableFile: false, } // DefaultConfig returns a copy of the default configuration @@ -139,5 +149,9 @@ func (c *Config) validate() error { if c.HeartbeatLevel > 0 && c.HeartbeatIntervalS <= 0 { return fmtErrorf("heartbeat_interval_s must be positive when heartbeat is enabled: %d", c.HeartbeatIntervalS) } + if c.StdoutTarget != "stdout" && c.StdoutTarget != "stderr" { + return fmtErrorf("invalid stdout_target: '%s' (use stdout or stderr)", c.StdoutTarget) + } + return nil } \ No newline at end of file diff --git a/example/sink/main.go b/example/sink/main.go new file mode 100644 index 0000000..9988f02 --- /dev/null +++ b/example/sink/main.go @@ -0,0 +1,155 @@ +// FILE: main.go +package main + +import ( + "fmt" + "os" + "time" + + "github.com/lixenwraith/log" +) + +const ( + logDirectory = "./temp_logs" + logInterval = 200 * time.Millisecond // Shorter interval for quicker tests +) + +// main orchestrates the different test scenarios. +func main() { + // Ensure a clean state by removing the previous log directory. + if err := os.RemoveAll(logDirectory); err != nil { + fmt.Printf("Warning: could not remove old log directory: %v\n", err) + } + if err := os.MkdirAll(logDirectory, 0755); err != nil { + fmt.Printf("Fatal: could not create log directory: %v\n", err) + os.Exit(1) + } + + fmt.Println("--- Running Logger Test Suite ---") + fmt.Printf("! All file-based logs will be in the '%s' directory.\n\n", logDirectory) + + // --- Scenario 1: Test different configurations on fresh logger instances --- + fmt.Println("--- SCENARIO 1: Testing configurations in isolation (new logger per test) ---") + testFileOnly() + testStdoutOnly() + testStderrOnly() + testNoOutput() + + // --- Scenario 2: Test reconfiguration on a single logger instance --- + fmt.Println("\n--- SCENARIO 2: Testing reconfiguration on a single logger instance ---") + testReconfigurationTransitions() + + fmt.Println("\n--- Logger Test Suite Complete ---") + fmt.Printf("Check the '%s' directory for log files.\n", logDirectory) +} + +// testFileOnly tests the default behavior: writing only to a file. +func testFileOnly() { + logger := log.NewLogger() + runTestPhase(logger, "1.1: File-Only", + "directory="+logDirectory, + "name=file_only_log", // Give it a unique name + "level=-4", + ) + shutdownLogger(logger, "1.1: File-Only") +} + +// testStdoutOnly tests writing only to the standard output. +func testStdoutOnly() { + logger := log.NewLogger() + runTestPhase(logger, "1.2: Stdout-Only", + "enable_stdout=true", + "disable_file=true", // Explicitly disable file + "level=-4", + ) + shutdownLogger(logger, "1.2: Stdout-Only") +} + +// testStderrOnly tests writing only to the standard error stream. +func testStderrOnly() { + fmt.Fprintln(os.Stderr, "\n---") // Separator for stderr output + logger := log.NewLogger() + runTestPhase(logger, "1.3: Stderr-Only", + "enable_stdout=true", + "stdout_target=stderr", + "disable_file=true", + "level=-4", + ) + fmt.Fprintln(os.Stderr, "---") // Separator for stderr output + shutdownLogger(logger, "1.3: Stderr-Only") +} + +// testNoOutput tests a configuration where all logging is disabled. +func testNoOutput() { + logger := log.NewLogger() + runTestPhase(logger, "1.4: No-Output (logs should be dropped)", + "enable_stdout=false", // Ensure stdout is off + "disable_file=true", // Ensure file is off + "level=-4", + ) + shutdownLogger(logger, "1.4: No-Output") +} + +// testReconfigurationTransitions tests the logger's ability to handle state changes. +func testReconfigurationTransitions() { + logger := log.NewLogger() + + // Phase A: Start with dual output + runTestPhase(logger, "2.1: Reconfig - Initial (Dual File+Stdout)", + "directory="+logDirectory, + "name=reconfig_log", + "enable_stdout=true", + "disable_file=false", + "level=-4", + ) + + // Phase B: Transition to file-disabled + runTestPhase(logger, "2.2: Reconfig - Transition to Stdout-Only", + "enable_stdout=true", + "disable_file=true", // The key change + "level=-4", + ) + + // Phase C: Transition back to dual-output. This is the critical test. + runTestPhase(logger, "2.3: Reconfig - Transition back to Dual (File+Stdout)", + "directory="+logDirectory, // Re-specify directory + "name=reconfig_log", + "enable_stdout=true", + "disable_file=false", // Re-enable file + "level=-4", + ) + + // Phase D: Test different levels on the final reconfigured state + fmt.Println("\n[Phase 2.4: Reconfig - Testing log levels on final state]") + logger.Debug("final-state", "This is a debug message.") + logger.Info("final-state", "This is an info message.") + logger.Warn("final-state", "This is a warning message.") + logger.Error("final-state", "This is an error message.") + time.Sleep(logInterval) + + shutdownLogger(logger, "2: Reconfiguration") +} + +// runTestPhase is a helper to initialize and run a standard logging test. +func runTestPhase(logger *log.Logger, phaseName string, overrides ...string) { + fmt.Printf("\n[Phase %s]\n", phaseName) + fmt.Println(" Config:", overrides) + + err := logger.InitWithDefaults(overrides...) + if err != nil { + fmt.Printf(" ERROR: Failed to initialize/reconfigure logger: %v\n", err) + os.Exit(1) + } + + logger.Info("event", "start_phase", "name", phaseName) + time.Sleep(logInterval) + logger.Info("event", "end_phase", "name", phaseName) + time.Sleep(logInterval) // Give time for flush +} + +// shutdownLogger is a helper to gracefully shut down the logger instance. +func shutdownLogger(l *log.Logger, phaseName string) { + if err := l.Shutdown(500 * time.Millisecond); err != nil { + fmt.Printf(" WARNING: Shutdown error in phase '%s': %v\n", phaseName, err) + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 2c9f4ea..9fcf7c0 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index 0e750ea..3307b07 100644 --- a/go.sum +++ b/go.sum @@ -30,10 +30,10 @@ 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= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/logger.go b/logger.go index 2810ad9..e96b0e3 100644 --- a/logger.go +++ b/logger.go @@ -4,6 +4,7 @@ package log import ( "errors" "fmt" + "io" "os" "strings" "sync" @@ -173,52 +174,48 @@ func (l *Logger) applyAndReconfigureLocked() error { return fmtErrorf("failed to create log directory '%s': %w", dir, err) } - // Check if we need to restart the processor + // Get current state wasInitialized := l.state.IsInitialized.Load() - processorNeedsRestart := !wasInitialized + disableFile, _ := l.config.Bool("log.disable_file") - // Always restart the processor if initialized, to handle any config changes - // This is the simplest approach that works reliably for all config changes - if wasInitialized { - processorNeedsRestart = true + // Get current file handle + currentFilePtr := l.state.CurrentFile.Load() + var currentFile *os.File + if currentFilePtr != nil { + currentFile, _ = currentFilePtr.(*os.File) } - // Restart processor if needed - if processorNeedsRestart { - // Close the old channel if reconfiguring - if wasInitialized { - oldCh := l.getCurrentLogChannel() - if oldCh != nil { - // Swap in a temporary closed channel - tempClosedChan := make(chan logRecord) - close(tempClosedChan) - l.state.ActiveLogChannel.Store(tempClosedChan) + // Determine if we need a new file + needsNewFile := !wasInitialized || currentFile == nil - // Close the actual old channel - close(oldCh) + // Handle file state transitions + if disableFile { + // When disabling file output, properly close the current file + if currentFile != nil { + // Sync and close the file + _ = currentFile.Sync() + if err := currentFile.Close(); err != nil { + fmt.Fprintf(os.Stderr, "log: warning - failed to close log file during disable: %v\n", err) } } - - // Create the new channel - bufferSize, _ := l.config.Int64("log.buffer_size") - newLogChannel := make(chan logRecord, bufferSize) - l.state.ActiveLogChannel.Store(newLogChannel) - - // Start the new processor - l.state.ProcessorExited.Store(false) - go l.processLogs(newLogChannel) - } - - // Initialize new log file if needed - currentFileHandle := l.state.CurrentFile.Load() - needsNewFile := !wasInitialized || currentFileHandle == nil - - if needsNewFile { + l.state.CurrentFile.Store((*os.File)(nil)) + l.state.CurrentSize.Store(0) + } else if needsNewFile { + // When enabling file output or initializing, create new file logFile, err := l.createNewLogFile() if err != nil { l.state.LoggerDisabled.Store(true) - return fmtErrorf("failed to create initial/new log file: %w", err) + return fmtErrorf("failed to create log file: %w", err) } + + // Close old file if transitioning from one file to another + if currentFile != nil && currentFile != logFile { + _ = currentFile.Sync() + if err := currentFile.Close(); err != nil { + fmt.Fprintf(os.Stderr, "log: warning - failed to close old log file: %v\n", err) + } + } + l.state.CurrentFile.Store(logFile) l.state.CurrentSize.Store(0) if fi, errStat := logFile.Stat(); errStat == nil { @@ -226,6 +223,45 @@ func (l *Logger) applyAndReconfigureLocked() error { } } + // Close the old channel if reconfiguring + if wasInitialized { + oldCh := l.getCurrentLogChannel() + if oldCh != nil { + // Create new channel then close old channel + bufferSize, _ := l.config.Int64("log.buffer_size") + newLogChannel := make(chan logRecord, bufferSize) + l.state.ActiveLogChannel.Store(newLogChannel) + close(oldCh) + + // Start new processor with new channel + l.state.ProcessorExited.Store(false) + go l.processLogs(newLogChannel) + } + } else { + // Initial startup + bufferSize, _ := l.config.Int64("log.buffer_size") + newLogChannel := make(chan logRecord, bufferSize) + l.state.ActiveLogChannel.Store(newLogChannel) + l.state.ProcessorExited.Store(false) + go l.processLogs(newLogChannel) + } + + // Setup stdout writer based on config + enableStdout, _ := l.config.Bool("log.enable_stdout") + if enableStdout { + target, _ := l.config.String("log.stdout_target") + if target == "stderr" { + var writer io.Writer = os.Stderr + l.state.StdoutWriter.Store(&sink{w: writer}) + } else if target == "stdout" { + var writer io.Writer = os.Stdout + l.state.StdoutWriter.Store(&sink{w: writer}) + } + } else { + var writer io.Writer = io.Discard + l.state.StdoutWriter.Store(&sink{w: writer}) + } + // Mark as initialized l.state.IsInitialized.Store(true) l.state.ShutdownCalled.Store(false) @@ -260,6 +296,9 @@ func (l *Logger) loadCurrentConfig() *Config { cfg.EnablePeriodicSync, _ = l.config.Bool("log.enable_periodic_sync") cfg.HeartbeatLevel, _ = l.config.Int64("log.heartbeat_level") cfg.HeartbeatIntervalS, _ = l.config.Int64("log.heartbeat_interval_s") + cfg.EnableStdout, _ = l.config.Bool("log.enable_stdout") + cfg.StdoutTarget, _ = l.config.String("log.stdout_target") + cfg.DisableFile, _ = l.config.Bool("log.disable_file") return cfg } diff --git a/processor.go b/processor.go index 57623bb..415b396 100644 --- a/processor.go +++ b/processor.go @@ -12,21 +12,24 @@ const ( // Threshold for triggering reactive disk check reactiveCheckThresholdBytes int64 = 10 * 1024 * 1024 // Factors to adjust check interval - adaptiveIntervalFactor float64 = 1.5 // Slow down factor - adaptiveSpeedUpFactor float64 = 0.8 // Speed up factor + adaptiveIntervalFactor float64 = 1.5 // Slow down + adaptiveSpeedUpFactor float64 = 0.8 // Speed up ) // processLogs is the main log processing loop running in a separate goroutine func (l *Logger) processLogs(ch <-chan logRecord) { - l.state.ProcessorExited.Store(false) // Mark processor as running - defer l.state.ProcessorExited.Store(true) // Ensure flag is set on exit + l.state.ProcessorExited.Store(false) + defer l.state.ProcessorExited.Store(true) // Set up timers and state variables timers := l.setupProcessingTimers() defer l.closeProcessingTimers(timers) - // Perform an initial disk check on startup - l.performDiskCheck(true) // Force check and update status + // Perform an initial disk check on startup (skip if file output is disabled) + disableFile, _ := l.config.Bool("log.disable_file") + if !disableFile { + l.performDiskCheck(true) + } // Send initial heartbeats immediately instead of waiting for first tick heartbeatLevel, _ := l.config.Int64("log.heartbeat_level") @@ -65,8 +68,8 @@ func (l *Logger) processLogs(ch <-chan logRecord) { // Reactive Check Trigger if bytesSinceLastCheck > reactiveCheckThresholdBytes { - if l.performDiskCheck(false) { // Check without forcing cleanup yet - bytesSinceLastCheck = 0 // Reset if check OK + if l.performDiskCheck(false) { + bytesSinceLastCheck = 0 logsSinceLastCheck = 0 lastCheckTime = time.Now() } @@ -78,9 +81,8 @@ func (l *Logger) processLogs(ch <-chan logRecord) { case <-timers.diskCheckTicker.C: // Periodic disk check - if l.performDiskCheck(true) { // Periodic check, force cleanup if needed + if l.performDiskCheck(true) { l.adjustDiskCheckInterval(timers, lastCheckTime, logsSinceLastCheck) - // Reset counters after successful periodic check bytesSinceLastCheck = 0 logsSinceLastCheck = 0 lastCheckTime = time.Now() @@ -193,8 +195,6 @@ func (l *Logger) setupHeartbeatTimer(timers *TimerSet) <-chan time.Time { if intervalS <= 0 { intervalS = 60 // Default to 60 seconds } - // Create a new ticker that's offset slightly to avoid skipping the first tick - // by creating it and then waiting until exactly the next interval time timers.heartbeatTicker = time.NewTicker(time.Duration(intervalS) * time.Second) return timers.heartbeatTicker.C } @@ -203,11 +203,14 @@ func (l *Logger) setupHeartbeatTimer(timers *TimerSet) <-chan time.Time { // processLogRecord handles individual log records, returning bytes written func (l *Logger) processLogRecord(record logRecord) int64 { - if !l.state.DiskStatusOK.Load() { + // Check if the record should process this record + disableFile, _ := l.config.Bool("log.disable_file") + if !disableFile && !l.state.DiskStatusOK.Load() { l.state.DroppedLogs.Add(1) return 0 } + // Serialize the log entry once format, _ := l.config.String("log.format") data := l.serializer.serialize( format, @@ -219,6 +222,25 @@ func (l *Logger) processLogRecord(record logRecord) int64 { ) dataLen := int64(len(data)) + // Mirror to stdout if enabled + enableStdout, _ := l.config.Bool("log.enable_stdout") + if enableStdout { + if s := l.state.StdoutWriter.Load(); s != nil { + // Assert to concrete type: *sink + if sinkWrapper, ok := s.(*sink); ok && sinkWrapper != nil { + // Use the wrapped writer (sinkWrapper.w) + _, _ = sinkWrapper.w.Write(data) + } + } + } + + // Skip file operations if file output is disabled + if disableFile { + l.state.TotalLogsProcessed.Add(1) + return dataLen // Return data length for adaptive interval calculations + } + + // File rotation check currentFileSize := l.state.CurrentSize.Load() estimatedSize := currentFileSize + dataLen @@ -229,6 +251,7 @@ func (l *Logger) processLogRecord(record logRecord) int64 { } } + // Write to file cfPtr := l.state.CurrentFile.Load() if currentLogFile, isFile := cfPtr.(*os.File); isFile && currentLogFile != nil { n, err := currentLogFile.Write(data) @@ -349,7 +372,7 @@ func (l *Logger) handleHeartbeat() { func (l *Logger) logProcHeartbeat() { processed := l.state.TotalLogsProcessed.Load() dropped := l.state.DroppedLogs.Load() - sequence := l.state.HeartbeatSequence.Add(1) // Increment and get sequence number + sequence := l.state.HeartbeatSequence.Add(1) startTimeVal := l.state.LoggerStartTime.Load() var uptimeHours float64 = 0 @@ -436,19 +459,34 @@ func (l *Logger) logSysHeartbeat() { l.writeHeartbeatRecord(LevelSys, sysArgs) } -// writeHeartbeatRecord handles the common logic for writing a heartbeat record +// writeHeartbeatRecord handles common logic for writing a heartbeat record func (l *Logger) writeHeartbeatRecord(level int64, args []any) { if l.state.LoggerDisabled.Load() || l.state.ShutdownCalled.Load() { return } - if !l.state.DiskStatusOK.Load() { - return - } - + // Serialize heartbeat data format, _ := l.config.String("log.format") hbData := l.serializer.serialize(format, FlagDefault|FlagShowLevel, time.Now(), level, "", args) + // Mirror to stdout if enabled + enableStdout, _ := l.config.Bool("log.enable_stdout") + if enableStdout { + if s := l.state.StdoutWriter.Load(); s != nil { + // Assert to concrete type: *sink + if sinkWrapper, ok := s.(*sink); ok && sinkWrapper != nil { + // Use the wrapped writer (sinkWrapper.w) + _, _ = sinkWrapper.w.Write(hbData) + } + } + } + + disableFile, _ := l.config.Bool("log.disable_file") + if disableFile || !l.state.DiskStatusOK.Load() { + return + } + + // Write to file cfPtr := l.state.CurrentFile.Load() if cfPtr == nil { fmtFprintf(os.Stderr, "log: error - current file handle is nil during heartbeat\n") diff --git a/state.go b/state.go index d059487..d11bdc8 100644 --- a/state.go +++ b/state.go @@ -2,6 +2,7 @@ package log import ( + "io" "os" "strconv" "strings" @@ -31,6 +32,7 @@ type State struct { LoggedDrops atomic.Uint64 // Counter for dropped logs message already logged ActiveLogChannel atomic.Value // stores chan logRecord + StdoutWriter atomic.Value // stores io.Writer (os.Stdout, os.Stderr, or io.Discard) // Heartbeat statistics HeartbeatSequence atomic.Uint64 // Counter for heartbeat sequence numbers @@ -40,6 +42,11 @@ type State struct { TotalDeletions atomic.Uint64 // Counter for successful log deletions (cleanup/retention) } +// sink is a wrapper around an io.Writer, atomic value type change workaround +type sink struct { + w io.Writer +} + // Init initializes or reconfigures the logger using the provided config.Config instance func (l *Logger) Init(cfg *config.Config, basePath string) error { if cfg == nil { @@ -220,4 +227,4 @@ func (l *Logger) Flush(timeout time.Duration) error { case <-time.After(timeout): return fmtErrorf("timeout waiting for flush confirmation (%v)", timeout) } -} +} \ No newline at end of file diff --git a/storage.go b/storage.go index 234eefa..0cf3609 100644 --- a/storage.go +++ b/storage.go @@ -13,6 +13,12 @@ import ( // performSync syncs the current log file func (l *Logger) performSync() { + // Skip sync if file output is disabled + disableFile, _ := l.config.Bool("log.disable_file") + if disableFile { + return + } + cfPtr := l.state.CurrentFile.Load() if cfPtr != nil { if currentLogFile, isFile := cfPtr.(*os.File); isFile && currentLogFile != nil { @@ -33,6 +39,17 @@ func (l *Logger) performSync() { // performDiskCheck checks disk space, triggers cleanup if needed, and updates status // Returns true if disk is OK, false otherwise func (l *Logger) performDiskCheck(forceCleanup bool) bool { + // Skip all disk checks if file output is disabled + disableFile, _ := l.config.Bool("log.disable_file") + if disableFile { + // Always return OK status when file output is disabled + if !l.state.DiskStatusOK.Load() { + l.state.DiskStatusOK.Store(true) + l.state.DiskFullLogged.Store(false) + } + return true + } + dir, _ := l.config.String("log.directory") ext, _ := l.config.String("log.extension") maxTotalMB, _ := l.config.Int64("log.max_total_size_mb")