diff --git a/config.go b/config.go index c064490..d40c256 100644 --- a/config.go +++ b/config.go @@ -3,20 +3,22 @@ package log import ( "strings" + "time" ) // Config holds all logger configuration values type Config struct { // Basic settings Level int64 `toml:"level"` - Name string `toml:"name"` + Name string `toml:"name"` // Base name for log files Directory string `toml:"directory"` Format string `toml:"format"` // "txt" or "json" Extension string `toml:"extension"` // Formatting - ShowTimestamp bool `toml:"show_timestamp"` - ShowLevel bool `toml:"show_level"` + ShowTimestamp bool `toml:"show_timestamp"` + ShowLevel bool `toml:"show_level"` + TimestampFormat string `toml:"timestamp_format"` // Time format for log timestamps // Buffer and size limits BufferSize int64 `toml:"buffer_size"` // Channel buffer size @@ -60,8 +62,9 @@ var defaultConfig = Config{ Extension: "log", // Formatting - ShowTimestamp: true, - ShowLevel: true, + ShowTimestamp: true, + ShowLevel: true, + TimestampFormat: time.RFC3339Nano, // Buffer and size limits BufferSize: 1024, @@ -110,8 +113,8 @@ func (c *Config) validate() error { if c.Format != "txt" && c.Format != "json" { return fmtErrorf("invalid format: '%s' (use txt or json)", c.Format) } - if strings.HasPrefix(c.Extension, ".") { - return fmtErrorf("extension should not start with dot: %s", c.Extension) + if strings.TrimSpace(c.TimestampFormat) == "" { + return fmtErrorf("timestamp_format cannot be empty") } if c.BufferSize <= 0 { return fmtErrorf("buffer_size must be positive: %d", c.BufferSize) diff --git a/format.go b/format.go index aae8ee1..decbbc9 100644 --- a/format.go +++ b/format.go @@ -10,13 +10,15 @@ import ( // serializer manages the buffered writing of log entries. type serializer struct { - buf []byte + buf []byte + timestampFormat string } // newSerializer creates a serializer instance. func newSerializer() *serializer { return &serializer{ - buf: make([]byte, 0, 4096), // Initial reasonable capacity + buf: make([]byte, 0, 4096), // Initial reasonable capacity + timestampFormat: time.RFC3339Nano, // Default until configured } } @@ -42,7 +44,7 @@ func (s *serializer) serializeJSON(flags int64, timestamp time.Time, level int64 if flags&FlagShowTimestamp != 0 { s.buf = append(s.buf, `"time":"`...) - s.buf = timestamp.AppendFormat(s.buf, time.RFC3339Nano) + s.buf = timestamp.AppendFormat(s.buf, s.timestampFormat) s.buf = append(s.buf, '"') needsComma = true } @@ -90,7 +92,7 @@ func (s *serializer) serializeText(flags int64, timestamp time.Time, level int64 needsSpace := false if flags&FlagShowTimestamp != 0 { - s.buf = timestamp.AppendFormat(s.buf, time.RFC3339Nano) + s.buf = timestamp.AppendFormat(s.buf, s.timestampFormat) needsSpace = true } @@ -150,7 +152,7 @@ func (s *serializer) writeTextValue(v any) { case nil: s.buf = append(s.buf, "null"...) case time.Time: - s.buf = val.AppendFormat(s.buf, time.RFC3339Nano) + s.buf = val.AppendFormat(s.buf, s.timestampFormat) case error: str := val.Error() if len(str) == 0 || strings.ContainsRune(str, ' ') { @@ -206,7 +208,7 @@ func (s *serializer) writeJSONValue(v any) { s.buf = append(s.buf, "null"...) case time.Time: s.buf = append(s.buf, '"') - s.buf = val.AppendFormat(s.buf, time.RFC3339Nano) + s.buf = val.AppendFormat(s.buf, s.timestampFormat) s.buf = append(s.buf, '"') case error: s.buf = append(s.buf, '"') @@ -278,4 +280,12 @@ func (s *serializer) writeString(str string) { } } +// Update cached format +func (s *serializer) setTimestampFormat(format string) { + if format == "" { + format = time.RFC3339Nano + } + s.timestampFormat = format +} + const hexChars = "0123456789abcdef" \ No newline at end of file diff --git a/go.mod b/go.mod index 670c1d7..5a78d04 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/lixenwraith/log go 1.24.5 require ( - github.com/lixenwraith/config v0.0.0-20250701170607-8515fa0543b6 + github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497 github.com/panjf2000/gnet/v2 v2.9.1 github.com/valyala/fasthttp v1.63.0 ) diff --git a/go.sum b/go.sum index 3307b07..acf3114 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lixenwraith/config v0.0.0-20250701170607-8515fa0543b6 h1:qE4SpAJWFaLkdRyE0FjTPBBRYE7LOvcmRCB5p86W73Q= github.com/lixenwraith/config v0.0.0-20250701170607-8515fa0543b6/go.mod h1:4wPJ3HnLrYrtUwTinngCsBgtdIXsnxkLa7q4KAIbwY8= +github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497 h1:ixTIdJSd945n/IhMRwGwQVmQnQ1nUr5z1wn31jXq9FU= +github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497/go.mod h1:y7kgDrWIFROWJJ6ASM/SPTRRAj27FjRGWh2SDLcdQ68= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= diff --git a/logger.go b/logger.go index 3a4a058..91b9a13 100644 --- a/logger.go +++ b/logger.go @@ -174,6 +174,11 @@ func (l *Logger) applyAndReconfigureLocked() error { return fmtErrorf("failed to create log directory '%s': %w", dir, err) } + // Update serializer format when config changes + if tsFormat, err := l.config.String("log.timestamp_format"); err == nil && tsFormat != "" { + l.serializer.setTimestampFormat(tsFormat) + } + // Get current state wasInitialized := l.state.IsInitialized.Load() disableFile, _ := l.config.Bool("log.disable_file") @@ -281,6 +286,7 @@ func (l *Logger) loadCurrentConfig() *Config { cfg.Extension, _ = l.config.String("log.extension") cfg.ShowTimestamp, _ = l.config.Bool("log.show_timestamp") cfg.ShowLevel, _ = l.config.Bool("log.show_level") + cfg.TimestampFormat, _ = l.config.String("log.timestamp_format") cfg.BufferSize, _ = l.config.Int64("log.buffer_size") cfg.MaxSizeMB, _ = l.config.Int64("log.max_size_mb") cfg.MaxTotalSizeMB, _ = l.config.Int64("log.max_total_size_mb") diff --git a/storage.go b/storage.go index f88b09f..155eed3 100644 --- a/storage.go +++ b/storage.go @@ -186,18 +186,17 @@ func (l *Logger) getLogDirSize(dir, fileExt string) (int64, error) { func (l *Logger) cleanOldLogs(required int64) error { dir, _ := l.config.String("log.directory") fileExt, _ := l.config.String("log.extension") + name, _ := l.config.String("log.name") entries, err := os.ReadDir(dir) if err != nil { return fmtErrorf("failed to read log directory '%s' for cleanup: %w", dir, err) } - currentLogFileName := "" - cfPtr := l.state.CurrentFile.Load() - if cfPtr != nil { - if clf, ok := cfPtr.(*os.File); ok && clf != nil { - currentLogFileName = filepath.Base(clf.Name()) - } + // Get the static log filename to exclude from deletion + staticLogName := name + if fileExt != "" { + staticLogName = name + "." + fileExt } type logFileMeta struct { @@ -208,7 +207,10 @@ func (l *Logger) cleanOldLogs(required int64) error { var logs []logFileMeta targetExt := "." + fileExt for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != targetExt || entry.Name() == currentLogFileName { + if entry.IsDir() || entry.Name() == staticLogName { + continue + } + if fileExt != "" && filepath.Ext(entry.Name()) != targetExt { continue } info, errInfo := entry.Info() @@ -251,7 +253,7 @@ func (l *Logger) cleanOldLogs(required int64) error { func (l *Logger) updateEarliestFileTime() { dir, _ := l.config.String("log.directory") fileExt, _ := l.config.String("log.extension") - baseName, _ := l.config.String("log.name") + name, _ := l.config.String("log.name") entries, err := os.ReadDir(dir) if err != nil { @@ -260,22 +262,24 @@ func (l *Logger) updateEarliestFileTime() { } var earliest time.Time - currentLogFileName := "" - cfPtr := l.state.CurrentFile.Load() - if cfPtr != nil { - if clf, ok := cfPtr.(*os.File); ok && clf != nil { - currentLogFileName = filepath.Base(clf.Name()) - } + // Get the active log filename to exclude from timestamp tracking + staticLogName := name + if fileExt != "" { + staticLogName = name + "." + fileExt } targetExt := "." + fileExt - prefix := baseName + "_" + prefix := name + "_" for _, entry := range entries { if entry.IsDir() { continue } fname := entry.Name() - if !strings.HasPrefix(fname, prefix) || filepath.Ext(fname) != targetExt || fname == currentLogFileName { + // Skip the active log file + if fname == staticLogName { + continue + } + if !strings.HasPrefix(fname, prefix) || (fileExt != "" && filepath.Ext(fname) != targetExt) { continue } info, errInfo := entry.Info() @@ -293,6 +297,7 @@ func (l *Logger) updateEarliestFileTime() { func (l *Logger) cleanExpiredLogs(oldest time.Time) error { dir, _ := l.config.String("log.directory") fileExt, _ := l.config.String("log.extension") + name, _ := l.config.String("log.name") retentionPeriodHrs, _ := l.config.Float64("log.retention_period_hrs") rpDuration := time.Duration(retentionPeriodHrs * float64(time.Hour)) @@ -309,18 +314,20 @@ func (l *Logger) cleanExpiredLogs(oldest time.Time) error { return fmtErrorf("failed to read log directory '%s' for retention cleanup: %w", dir, err) } - currentLogFileName := "" - cfPtr := l.state.CurrentFile.Load() - if cfPtr != nil { - if clf, ok := cfPtr.(*os.File); ok && clf != nil { - currentLogFileName = filepath.Base(clf.Name()) - } + // Get the active log filename to exclude from deletion + staticLogName := name + if fileExt != "" { + staticLogName = name + "." + fileExt } targetExt := "." + fileExt var deletedCount int for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != targetExt || entry.Name() == currentLogFileName { + if entry.IsDir() || entry.Name() == staticLogName { + continue + } + // Only consider files with correct extension + if fileExt != "" && filepath.Ext(entry.Name()) != targetExt { continue } info, errInfo := entry.Info() @@ -344,29 +351,37 @@ func (l *Logger) cleanExpiredLogs(oldest time.Time) error { return nil } -// generateLogFileName creates a unique log filename using a timestamp -func (l *Logger) generateLogFileName(timestamp time.Time) string { +// getStaticLogFilePath returns the full path to the active log file +func (l *Logger) getStaticLogFilePath() string { + dir, _ := l.config.String("log.directory") + name, _ := l.config.String("log.name") + ext, _ := l.config.String("log.extension") + + // Handle extension with or without dot + filename := name + if ext != "" { + filename = name + "." + ext + } + + return filepath.Join(dir, filename) +} + +// generateArchiveLogFileName creates a timestamped filename for archived logs during rotation +func (l *Logger) generateArchiveLogFileName(timestamp time.Time) string { name, _ := l.config.String("log.name") ext, _ := l.config.String("log.extension") tsFormat := timestamp.Format("060102_150405") nano := timestamp.Nanosecond() - return fmt.Sprintf("%s_%s_%d.%s", name, tsFormat, nano, ext) + + if ext != "" { + return fmt.Sprintf("%s_%s_%d.%s", name, tsFormat, nano, ext) + } + return fmt.Sprintf("%s_%s_%d", name, tsFormat, nano) } // createNewLogFile generates a unique name and opens a new log file func (l *Logger) createNewLogFile() (*os.File, error) { - dir, _ := l.config.String("log.directory") - filename := l.generateLogFileName(time.Now()) - fullPath := filepath.Join(dir, filename) - - for i := 0; i < 5; i++ { - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - break - } - time.Sleep(1 * time.Millisecond) - filename := l.generateLogFileName(time.Now()) - fullPath = filepath.Join(dir, filename) - } + fullPath := l.getStaticLogFilePath() file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { @@ -375,27 +390,71 @@ func (l *Logger) createNewLogFile() (*os.File, error) { return file, nil } -// rotateLogFile handles closing the current log file and opening a new one +// rotateLogFile implements the rename-on-rotate strategy +// Closes current file, renames it with timestamp, creates new static file func (l *Logger) rotateLogFile() error { + // Get current file handle + cfPtr := l.state.CurrentFile.Load() + if cfPtr == nil { + // No current file, just create a new one + newFile, err := l.createNewLogFile() + if err != nil { + return fmtErrorf("failed to create log file during rotation: %w", err) + } + l.state.CurrentFile.Store(newFile) + l.state.CurrentSize.Store(0) + l.state.TotalRotations.Add(1) + return nil + } + + currentFile, ok := cfPtr.(*os.File) + if !ok || currentFile == nil { + // Invalid file handle, create new one + newFile, err := l.createNewLogFile() + if err != nil { + return fmtErrorf("failed to create log file during rotation: %w", err) + } + l.state.CurrentFile.Store(newFile) + l.state.CurrentSize.Store(0) + l.state.TotalRotations.Add(1) + return nil + } + + // Close current file before renaming + if err := currentFile.Close(); err != nil { + l.internalLog("failed to close log file before rotation: %v\n", err) + // Continue with rotation anyway + } + + // Generate archive filename with current timestamp + dir, _ := l.config.String("log.directory") + archiveName := l.generateArchiveLogFileName(time.Now()) + archivePath := filepath.Join(dir, archiveName) + + // Rename current file to archive name + currentPath := l.getStaticLogFilePath() + if err := os.Rename(currentPath, archivePath); err != nil { + // The original file is closed and couldn't be renamed. This is a terminal state for file logging. + l.internalLog("failed to rename log file from '%s' to '%s': %v. file logging disabled.", + currentPath, archivePath, err) + l.state.LoggerDisabled.Store(true) + return fmtErrorf("failed to rotate log file, logging is disabled: %w", err) + } + + // Create new log file at static path newFile, err := l.createNewLogFile() if err != nil { - return fmtErrorf("failed to create new log file for rotation: %w", err) + return fmtErrorf("failed to create new log file after rotation: %w", err) } - oldFilePtr := l.state.CurrentFile.Swap(newFile) + // Update state + l.state.CurrentFile.Store(newFile) l.state.CurrentSize.Store(0) - - if oldFilePtr != nil { - if oldFile, ok := oldFilePtr.(*os.File); ok && oldFile != nil { - if err := oldFile.Close(); err != nil { - l.internalLog("failed to close old log file '%s': %v\n", oldFile.Name(), err) - // Continue with new file anyway - } - } - } - - l.updateEarliestFileTime() l.state.TotalRotations.Add(1) + + // Update earliest file time after successful rotation + l.updateEarliestFileTime() + return nil } diff --git a/utility.go b/utility.go index bf8d7a2..dd8fe75 100644 --- a/utility.go +++ b/utility.go @@ -109,6 +109,10 @@ func validateConfigValue(key string, value interface{}) error { if v, ok := value.(string); ok && strings.HasPrefix(v, ".") { return fmtErrorf("extension should not start with dot: %s", v) } + case "timestamp_format": + if v, ok := value.(string); ok && strings.TrimSpace(v) == "" { + return fmtErrorf("timestamp_format cannot be empty") + } case "buffer_size": if v, ok := value.(int64); ok && v <= 0 { return fmtErrorf("buffer_size must be positive: %d", v)