v0.1.9 kv flag added, unix and nofs build tags, heartbeat and rotation filename and test update

This commit is contained in:
2026-08-01 05:09:51 -04:00
parent 24b7deebdb
commit 2af9af2359
15 changed files with 873 additions and 481 deletions
+14 -2
View File
@@ -7,8 +7,10 @@ import (
// Builder provides a fluent API for building logger configurations // Builder provides a fluent API for building logger configurations
// It wraps a Config instance and provides chainable methods for setting values // It wraps a Config instance and provides chainable methods for setting values
type Builder struct { type Builder struct {
cfg *Config err error // Accumulate errors for deferred handling
err error // Accumulate errors for deferred handling cfg *Config
ctxTag string
ctxVals []string
} }
// NewBuilder creates a new configuration builder with default values // NewBuilder creates a new configuration builder with default values
@@ -18,6 +20,13 @@ func NewBuilder() *Builder {
} }
} }
// ContextKeys names the record keys for Context values
func (b *Builder) ContextKeys(tag string, vals ...string) *Builder {
b.ctxTag = tag
b.ctxVals = vals
return b
}
// Build creates a new Logger instance with the specified configuration // Build creates a new Logger instance with the specified configuration
func (b *Builder) Build() (*Logger, error) { func (b *Builder) Build() (*Logger, error) {
if b.err != nil { if b.err != nil {
@@ -31,6 +40,9 @@ func (b *Builder) Build() (*Logger, error) {
if err := logger.ApplyConfig(b.cfg); err != nil { if err := logger.ApplyConfig(b.cfg); err != nil {
return nil, err return nil, err
} }
if b.ctxTag != "" || len(b.ctxVals) > 0 {
logger.SetContextKeys(b.ctxTag, b.ctxVals...)
}
return logger, nil return logger, nil
} }
+1 -1
View File
@@ -28,6 +28,7 @@ const (
FlagShowTimestamp = formatter.FlagShowTimestamp FlagShowTimestamp = formatter.FlagShowTimestamp
FlagShowLevel = formatter.FlagShowLevel FlagShowLevel = formatter.FlagShowLevel
FlagStructuredJSON = formatter.FlagStructuredJSON FlagStructuredJSON = formatter.FlagStructuredJSON
FlagKV = formatter.FlagKV
FlagNoTimestamp = formatter.FlagNoTimestamp FlagNoTimestamp = formatter.FlagNoTimestamp
FlagNoLevel = formatter.FlagNoLevel FlagNoLevel = formatter.FlagNoLevel
FlagDefault = formatter.FlagDefault FlagDefault = formatter.FlagDefault
@@ -57,4 +58,3 @@ const (
adaptiveIntervalFactor float64 = 1.5 // Slow down adaptiveIntervalFactor float64 = 1.5 // Slow down
adaptiveSpeedUpFactor float64 = 0.8 // Speed up adaptiveSpeedUpFactor float64 = 0.8 // Speed up
) )
+189 -42
View File
@@ -34,6 +34,7 @@ const (
FlagStructuredJSON int64 = 0b1000 FlagStructuredJSON int64 = 0b1000
FlagNoTimestamp int64 = 0b010000 FlagNoTimestamp int64 = 0b010000
FlagNoLevel int64 = 0b100000 FlagNoLevel int64 = 0b100000
FlagKV int64 = 0b1000000 // args are alternating string keys and values
FlagDefault = FlagShowTimestamp | FlagShowLevel FlagDefault = FlagShowTimestamp | FlagShowLevel
) )
@@ -44,7 +45,31 @@ type Formatter struct {
timestampFormat string timestampFormat string
showTimestamp bool showTimestamp bool
showLevel bool showLevel bool
ctxKeys ContextKeys
buf []byte buf []byte
// Serializers are stateless and format-fixed; built once to keep the
// per-record path allocation-free
serTxt *sanitizer.Serializer
serJSON *sanitizer.Serializer
serRaw *sanitizer.Serializer
}
// ContextSlots is the number of correlation values a Context carries
const ContextSlots = 3
// Context carries caller-stamped values emitted with a record.
// Tag names the source; Vals are correlation counters keyed by ContextKeys.
type Context struct {
Tag string
Vals [ContextSlots]uint64
}
// ContextKeys names the record keys for Context fields; empty names are omitted.
// Names are emitted verbatim and must be plain identifiers.
type ContextKeys struct {
Tag string
Vals [ContextSlots]string
} }
// New creates a formatter with the provided sanitizer // New creates a formatter with the provided sanitizer
@@ -62,6 +87,30 @@ func New(s ...*sanitizer.Sanitizer) *Formatter {
showTimestamp: true, showTimestamp: true,
showLevel: true, showLevel: true,
buf: make([]byte, 0, 1024), buf: make([]byte, 0, 1024),
serTxt: sanitizer.NewSerializer("txt", san),
serJSON: sanitizer.NewSerializer("json", san),
serRaw: sanitizer.NewSerializer("raw", san),
}
}
// ContextKeys sets the record keys used for Context values
func (f *Formatter) ContextKeys(tag string, vals ...string) *Formatter {
f.ctxKeys = ContextKeys{Tag: tag}
for i := 0; i < len(vals) && i < ContextSlots; i++ {
f.ctxKeys.Vals[i] = vals[i]
}
return f
}
// serializerFor returns the cached serializer for a normalized format name
func (f *Formatter) serializerFor(format string) *sanitizer.Serializer {
switch format {
case "json":
return f.serJSON
case "raw":
return f.serRaw
default:
return f.serTxt
} }
} }
@@ -94,14 +143,24 @@ func (f *Formatter) ShowTimestamp(show bool) *Formatter {
// Format formats using configured options resolved against explicit flags. // Format formats using configured options resolved against explicit flags.
// Returned slice aliases the internal buffer. // Returned slice aliases the internal buffer.
func (f *Formatter) Format(flags int64, timestamp time.Time, level int64, trace string, args []any) []byte { func (f *Formatter) Format(flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
f.buf = f.AppendFormat(f.buf[:0], flags, timestamp, level, trace, args) return f.FormatCtx(Context{}, flags, timestamp, level, trace, args)
}
// FormatCtx is Format with caller-stamped context.
// Returned slice aliases the internal buffer.
func (f *Formatter) FormatCtx(ctx Context, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
f.buf = f.AppendFormatCtx(f.buf[:0], ctx, flags, timestamp, level, trace, args)
return f.buf return f.buf
} }
// AppendFormat appends a formatted entry to dst using configured options // AppendFormat appends a formatted entry to dst using configured options
// resolved against explicit flags. Safe for concurrent use. // resolved against explicit flags. Safe for concurrent use.
func (f *Formatter) AppendFormat(dst []byte, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte { func (f *Formatter) AppendFormat(dst []byte, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
// CHANGED: D3 — additive resolution replaces `flags == 0` guard return f.AppendFormatCtx(dst, Context{}, flags, timestamp, level, trace, args)
}
// AppendFormatCtx is AppendFormat with caller-stamped context
func (f *Formatter) AppendFormatCtx(dst []byte, ctx Context, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
eff := flags &^ (FlagShowTimestamp | FlagShowLevel) eff := flags &^ (FlagShowTimestamp | FlagShowLevel)
if resolveShow(flags, FlagShowTimestamp, FlagNoTimestamp, f.showTimestamp) { if resolveShow(flags, FlagShowTimestamp, FlagNoTimestamp, f.showTimestamp) {
eff |= FlagShowTimestamp eff |= FlagShowTimestamp
@@ -109,31 +168,25 @@ func (f *Formatter) AppendFormat(dst []byte, flags int64, timestamp time.Time, l
if resolveShow(flags, FlagShowLevel, FlagNoLevel, f.showLevel) { if resolveShow(flags, FlagShowLevel, FlagNoLevel, f.showLevel) {
eff |= FlagShowLevel eff |= FlagShowLevel
} }
return f.AppendFormatWithOptions(dst, f.format, eff, timestamp, level, trace, args) return f.AppendFormatWithOptionsCtx(dst, f.format, ctx, eff, timestamp, level, trace, args)
}
func resolveShow(flags, show, no int64, configured bool) bool {
switch {
case flags&no != 0:
return false
case flags&show != 0:
return true
default:
return configured
}
} }
// FormatWithOptions formats with explicit format and flags, ignoring // FormatWithOptions formats with explicit format and flags, ignoring
// configured display defaults. Returned slice aliases the internal buffer. // configured display defaults. Returned slice aliases the internal buffer.
func (f *Formatter) FormatWithOptions(format string, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte { func (f *Formatter) FormatWithOptions(format string, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
f.buf = f.AppendFormatWithOptions(f.buf[:0], format, flags, timestamp, level, trace, args) f.buf = f.AppendFormatWithOptionsCtx(f.buf[:0], format, Context{}, flags, timestamp, level, trace, args)
return f.buf return f.buf
} }
// AppendFormatWithOptions is the allocation-explicit core. Safe for // AppendFormatWithOptions is the compatibility wrapper over the context core
// concurrent use. Unknown formats fall back to "txt".
func (f *Formatter) AppendFormatWithOptions(dst []byte, format string, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte { func (f *Formatter) AppendFormatWithOptions(dst []byte, format string, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
// FlagRaw completely bypasses formatting and sanitization return f.AppendFormatWithOptionsCtx(dst, format, Context{}, flags, timestamp, level, trace, args)
}
// AppendFormatWithOptionsCtx is the allocation-explicit core. Safe for
// concurrent use. Unknown formats fall back to "txt".
func (f *Formatter) AppendFormatWithOptionsCtx(dst []byte, format string, ctx Context, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
// FlagRaw completely bypasses formatting, context, and sanitization
if flags&FlagRaw != 0 { if flags&FlagRaw != 0 {
for i, arg := range args { for i, arg := range args {
if i > 0 { if i > 0 {
@@ -155,9 +208,8 @@ func (f *Formatter) AppendFormatWithOptions(dst []byte, format string, flags int
return dst return dst
} }
// unknown formats normalize to txt instead of returning nil
format = normalizeFormat(format) format = normalizeFormat(format)
serializer := sanitizer.NewSerializer(format, f.sanitizer) serializer := f.serializerFor(format)
switch format { switch format {
case "raw": case "raw":
@@ -166,9 +218,20 @@ func (f *Formatter) AppendFormatWithOptions(dst []byte, format string, flags int
} }
return dst return dst
case "json": case "json":
return f.appendJSON(dst, flags, timestamp, level, trace, args, serializer) return f.appendJSON(dst, ctx, flags, timestamp, level, trace, args, serializer)
default: // "txt" default: // "txt"
return f.appendTxt(dst, flags, timestamp, level, trace, args, serializer) return f.appendTxt(dst, ctx, flags, timestamp, level, trace, args, serializer)
}
}
func resolveShow(flags, show, no int64, configured bool) bool {
switch {
case flags&no != 0:
return false
case flags&show != 0:
return true
default:
return configured
} }
} }
@@ -188,10 +251,8 @@ func (f *Formatter) FormatValue(v any) []byte {
} }
// AppendValue appends a single formatted value to dst. Safe for concurrent use. // AppendValue appends a single formatted value to dst. Safe for concurrent use.
// ADDED: R1
func (f *Formatter) AppendValue(dst []byte, v any) []byte { func (f *Formatter) AppendValue(dst []byte, v any) []byte {
serializer := sanitizer.NewSerializer(normalizeFormat(f.format), f.sanitizer) return f.appendValue(dst, v, f.serializerFor(normalizeFormat(f.format)), false)
return f.appendValue(dst, v, serializer, false)
} }
// FormatArgs formats multiple arguments. Returned slice aliases the internal buffer. // FormatArgs formats multiple arguments. Returned slice aliases the internal buffer.
@@ -202,9 +263,8 @@ func (f *Formatter) FormatArgs(args ...any) []byte {
// AppendArgs appends multiple space-separated values to dst. Safe for // AppendArgs appends multiple space-separated values to dst. Safe for
// concurrent use. // concurrent use.
// ADDED: R1
func (f *Formatter) AppendArgs(dst []byte, args ...any) []byte { func (f *Formatter) AppendArgs(dst []byte, args ...any) []byte {
serializer := sanitizer.NewSerializer(normalizeFormat(f.format), f.sanitizer) serializer := f.serializerFor(normalizeFormat(f.format))
for i, arg := range args { for i, arg := range args {
dst = f.appendValue(dst, arg, serializer, i > 0) dst = f.appendValue(dst, arg, serializer, i > 0)
} }
@@ -278,8 +338,29 @@ func LevelToString(level int64) string {
} }
} }
// appendJSONKey writes a quoted literal key followed by ':'
func appendJSONKey(dst []byte, key string) []byte {
dst = append(dst, '"')
dst = append(dst, key...)
dst = append(dst, '"', ':')
return dst
}
// isKV reports whether args form an even-length list with string keys
func isKV(args []any) bool {
if len(args) == 0 || len(args)%2 != 0 {
return false
}
for i := 0; i < len(args); i += 2 {
if _, ok := args[i].(string); !ok {
return false
}
}
return true
}
// appendJSON unifies JSON output over a caller-provided buffer // appendJSON unifies JSON output over a caller-provided buffer
func (f *Formatter) appendJSON(dst []byte, flags int64, timestamp time.Time, level int64, trace string, args []any, serializer *sanitizer.Serializer) []byte { func (f *Formatter) appendJSON(dst []byte, ctx Context, flags int64, timestamp time.Time, level int64, trace string, args []any, serializer *sanitizer.Serializer) []byte {
dst = append(dst, '{') dst = append(dst, '{')
needsComma := false needsComma := false
@@ -300,6 +381,27 @@ func (f *Formatter) appendJSON(dst []byte, flags int64, timestamp time.Time, lev
needsComma = true needsComma = true
} }
// Caller-stamped context, emitted as top-level keys
if ctx.Tag != "" && f.ctxKeys.Tag != "" {
if needsComma {
dst = append(dst, ',')
}
dst = appendJSONKey(dst, f.ctxKeys.Tag)
serializer.WriteString(&dst, ctx.Tag)
needsComma = true
}
for i, key := range f.ctxKeys.Vals {
if key == "" {
continue
}
if needsComma {
dst = append(dst, ',')
}
dst = appendJSONKey(dst, key)
dst = strconv.AppendUint(dst, ctx.Vals[i], 10)
needsComma = true
}
if trace != "" { if trace != "" {
if needsComma { if needsComma {
dst = append(dst, ',') dst = append(dst, ',')
@@ -336,19 +438,32 @@ func (f *Formatter) appendJSON(dst []byte, flags int64, timestamp time.Time, lev
} }
} }
// Regular JSON with fields array
if len(args) > 0 { if len(args) > 0 {
if needsComma { if needsComma {
dst = append(dst, ',') dst = append(dst, ',')
} }
dst = append(dst, `"fields":[`...) // Keyed object when the caller declares k/v args, positional array otherwise
for i, arg := range args { if flags&FlagKV != 0 && isKV(args) {
if i > 0 { dst = append(dst, `"fields":{`...)
dst = append(dst, ',') for i := 0; i < len(args); i += 2 {
if i > 0 {
dst = append(dst, ',')
}
serializer.WriteString(&dst, args[i].(string))
dst = append(dst, ':')
dst = f.appendValue(dst, args[i+1], serializer, false)
} }
dst = f.appendValue(dst, arg, serializer, false) dst = append(dst, '}')
} else {
dst = append(dst, `"fields":[`...)
for i, arg := range args {
if i > 0 {
dst = append(dst, ',')
}
dst = f.appendValue(dst, arg, serializer, false)
}
dst = append(dst, ']')
} }
dst = append(dst, ']')
} }
dst = append(dst, '}', '\n') dst = append(dst, '}', '\n')
@@ -356,7 +471,7 @@ func (f *Formatter) appendJSON(dst []byte, flags int64, timestamp time.Time, lev
} }
// appendTxt handles txt format output over a caller-provided buffer // appendTxt handles txt format output over a caller-provided buffer
func (f *Formatter) appendTxt(dst []byte, flags int64, timestamp time.Time, level int64, trace string, args []any, serializer *sanitizer.Serializer) []byte { func (f *Formatter) appendTxt(dst []byte, ctx Context, flags int64, timestamp time.Time, level int64, trace string, args []any, serializer *sanitizer.Serializer) []byte {
needsSpace := false needsSpace := false
if flags&FlagShowTimestamp != 0 { if flags&FlagShowTimestamp != 0 {
@@ -372,14 +487,35 @@ func (f *Formatter) appendTxt(dst []byte, flags int64, timestamp time.Time, leve
needsSpace = true needsSpace = true
} }
if ctx.Tag != "" && f.ctxKeys.Tag != "" {
if needsSpace {
dst = append(dst, ' ')
}
dst = append(dst, f.ctxKeys.Tag...)
dst = append(dst, '=')
dst = f.appendValue(dst, ctx.Tag, serializer, false)
needsSpace = true
}
for i, key := range f.ctxKeys.Vals {
if key == "" {
continue
}
if needsSpace {
dst = append(dst, ' ')
}
dst = append(dst, key...)
dst = append(dst, '=')
dst = strconv.AppendUint(dst, ctx.Vals[i], 10)
needsSpace = true
}
if trace != "" { if trace != "" {
if needsSpace { if needsSpace {
dst = append(dst, ' ') dst = append(dst, ' ')
} }
// Sanitize trace to prevent terminal control sequence injection // Sanitize trace to prevent terminal control sequence injection
traceHandler := sanitizer.NewSerializer("txt", f.sanitizer)
tempBuf := make([]byte, 0, len(trace)*2) tempBuf := make([]byte, 0, len(trace)*2)
traceHandler.WriteString(&tempBuf, trace) f.serTxt.WriteString(&tempBuf, trace)
// Extract content without quotes if added by txt serializer // Extract content without quotes if added by txt serializer
if len(tempBuf) > 2 && tempBuf[0] == '"' && tempBuf[len(tempBuf)-1] == '"' { if len(tempBuf) > 2 && tempBuf[0] == '"' && tempBuf[len(tempBuf)-1] == '"' {
dst = append(dst, tempBuf[1:len(tempBuf)-1]...) dst = append(dst, tempBuf[1:len(tempBuf)-1]...)
@@ -389,9 +525,21 @@ func (f *Formatter) appendTxt(dst []byte, flags int64, timestamp time.Time, leve
needsSpace = true needsSpace = true
} }
for _, arg := range args { if flags&FlagKV != 0 && isKV(args) {
dst = f.appendValue(dst, arg, serializer, needsSpace) for i := 0; i < len(args); i += 2 {
needsSpace = true if needsSpace {
dst = append(dst, ' ')
}
dst = append(dst, args[i].(string)...)
dst = append(dst, '=')
dst = f.appendValue(dst, args[i+1], serializer, false)
needsSpace = true
}
} else {
for _, arg := range args {
dst = f.appendValue(dst, arg, serializer, needsSpace)
needsSpace = true
}
} }
dst = append(dst, '\n') dst = append(dst, '\n')
@@ -402,4 +550,3 @@ func (f *Formatter) appendTxt(dst []byte, flags int64, timestamp time.Time, leve
func (f *Formatter) Reset() { func (f *Formatter) Reset() {
f.buf = f.buf[:0] f.buf = f.buf[:0]
} }
+2 -4
View File
@@ -128,18 +128,16 @@ func (l *Logger) logSysHeartbeat() {
l.writeHeartbeatRecord(LevelSys, sysArgs) l.writeHeartbeatRecord(LevelSys, sysArgs)
} }
// writeHeartbeatRecord creates and sends a heartbeat log record through the main processing channel // writeHeartbeatRecord creates and sends a heartbeat record, bypassing the level gate
func (l *Logger) writeHeartbeatRecord(level int64, args []any) { func (l *Logger) writeHeartbeatRecord(level int64, args []any) {
if l.state.LoggerDisabled.Load() || l.state.ShutdownCalled.Load() { if l.state.LoggerDisabled.Load() || l.state.ShutdownCalled.Load() {
return return
} }
// Create heartbeat record with appropriate flags
record := logRecord{ record := logRecord{
Flags: FlagDefault | FlagShowLevel, Flags: FlagDefault | FlagKV,
TimeStamp: time.Now(), TimeStamp: time.Now(),
Level: level, Level: level,
Trace: "",
Args: args, Args: args,
} }
+3 -4
View File
@@ -53,7 +53,7 @@ func TestFullLifecycle(t *testing.T) {
// MaxSizeKB=1 forces rotation, so assertions span every file in the directory // MaxSizeKB=1 forces rotation, so assertions span every file in the directory
mustEventually(t, 3*time.Second, "proc heartbeat emitted", func() bool { mustEventually(t, 3*time.Second, "proc heartbeat emitted", func() bool {
return strings.Contains(readAllLogs(t, tmpDir), `"type","proc"`) return strings.Contains(readAllLogs(t, tmpDir), `"type":"proc"`)
}) })
mustNoErr(t, logger.Flush(time.Second), "Flush") mustNoErr(t, logger.Flush(time.Second), "Flush")
@@ -63,8 +63,8 @@ func TestFullLifecycle(t *testing.T) {
contains(t, content, `"user_id":123`, "structured field") contains(t, content, `"user_id":123`, "structured field")
contains(t, content, "raw data write", "raw write") contains(t, content, "raw data write", "raw write")
contains(t, content, "after reconfiguration", "post-reconfiguration record") contains(t, content, "after reconfiguration", "post-reconfiguration record")
contains(t, content, `"type","disk"`, "disk heartbeat") contains(t, content, `"type":"disk"`, "disk heartbeat")
contains(t, content, `"type","sys"`, "sys heartbeat") contains(t, content, `"type":"sys"`, "sys heartbeat")
files, err := os.ReadDir(tmpDir) files, err := os.ReadDir(tmpDir)
mustNoErr(t, err, "ReadDir") mustNoErr(t, err, "ReadDir")
@@ -162,4 +162,3 @@ func TestErrorRecovery(t *testing.T) {
isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK after recovery") isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK after recovery")
}) })
} }
+388 -251
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"os" "os"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -17,10 +18,16 @@ import (
type Logger struct { type Logger struct {
currentConfig atomic.Value // stores *Config currentConfig atomic.Value // stores *Config
formatter atomic.Value // stores *formatter.Formatter formatter atomic.Value // stores *formatter.Formatter
ctxKeys atomic.Pointer[formatter.ContextKeys]
spawner atomic.Pointer[func(func())]
errHandler atomic.Pointer[func(string)]
state State state State
initMu sync.Mutex initMu sync.Mutex
} }
// levelOff closes the emit gate without touching configuration
const levelOff int64 = math.MaxInt64
// NewLogger creates a new Logger instance with default settings // NewLogger creates a new Logger instance with default settings
func NewLogger() *Logger { func NewLogger() *Logger {
l := &Logger{} l := &Logger{}
@@ -28,14 +35,20 @@ func NewLogger() *Logger {
// Set default configuration // Set default configuration
defaultCfg := DefaultConfig() defaultCfg := DefaultConfig()
l.currentConfig.Store(defaultCfg) l.currentConfig.Store(defaultCfg)
l.rebuildFormatter(defaultCfg)
// Initialize default formatter to prevent nil access // Emission stays closed until ApplyConfig and Start succeed
defaultFormatter := formatter.New(sanitizer.New()). l.state.Level.Store(levelOff)
Type(defaultCfg.Format). l.state.Flags.Store(flagsFromConfig(defaultCfg))
TimestampFormat(defaultCfg.TimestampFormat). l.state.TraceDepth.Store(defaultCfg.TraceDepth)
ShowLevel(defaultCfg.ShowLevel).
ShowTimestamp(defaultCfg.ShowTimestamp) // // Initialize default formatter to prevent nil access
l.formatter.Store(defaultFormatter) // defaultFormatter := formatter.New(sanitizer.New()).
// Type(defaultCfg.Format).
// TimestampFormat(defaultCfg.TimestampFormat).
// ShowLevel(defaultCfg.ShowLevel).
// ShowTimestamp(defaultCfg.ShowTimestamp)
// l.formatter.Store(defaultFormatter)
// Initialize the state // Initialize the state
l.state.IsInitialized.Store(false) l.state.IsInitialized.Store(false)
@@ -54,13 +67,17 @@ func NewLogger() *Logger {
l.state.TotalRotations.Store(0) l.state.TotalRotations.Store(0)
l.state.TotalDeletions.Store(0) l.state.TotalDeletions.Store(0)
// Create a closed channel initially to prevent nil pointer issues // Typed nil: a non-blocking send on a nil channel always takes default
initialChan := make(chan logRecord) l.state.ActiveLogChannel.Store((chan logRecord)(nil))
close(initialChan)
l.state.ActiveLogChannel.Store(initialChan)
l.state.flushRequestChan = make(chan chan struct{}, 1) l.state.flushRequestChan = make(chan chan struct{}, 1)
// // Create a closed channel initially to prevent nil pointer issues
// initialChan := make(chan logRecord)
// close(initialChan)
// l.state.ActiveLogChannel.Store(initialChan)
//
// l.state.flushRequestChan = make(chan chan struct{}, 1)
return l return l
} }
@@ -112,237 +129,6 @@ func (l *Logger) GetConfig() *Config {
return l.getConfig().Clone() return l.getConfig().Clone()
} }
// Start begins log processing. Safe to call multiple times
// Returns error if logger is not initialized
func (l *Logger) Start() error {
if !l.state.IsInitialized.Load() {
return fmtErrorf("logger not initialized, call ApplyConfig first")
}
// Check if processor didn't exit cleanly last time
if l.state.Started.Load() && !l.state.ProcessorExited.Load() {
// Force stop to clean up
l.internalLog("warning - processor still running from previous start, forcing stop\n")
if err := l.Stop(); err != nil {
return fmtErrorf("failed to stop hung processor: %w", err)
}
}
// Only start if not already started
if l.state.Started.CompareAndSwap(false, true) {
cfg := l.getConfig()
// Create log channel
logChannel := make(chan logRecord, cfg.BufferSize)
l.state.ActiveLogChannel.Store(logChannel)
// Start processor
l.state.ProcessorExited.Store(false)
go l.processLogs(logChannel)
}
return nil
}
// Stop halts log processing. Can be restarted with Start()
// Returns nil if already stopped
func (l *Logger) Stop(timeout ...time.Duration) error {
if !l.state.Started.CompareAndSwap(true, false) {
return nil // Already stopped
}
// Calculate effective timeout
var effectiveTimeout time.Duration
if len(timeout) > 0 {
effectiveTimeout = timeout[0]
} else {
cfg := l.getConfig()
effectiveTimeout = 2 * time.Duration(cfg.FlushIntervalMs) * time.Millisecond
}
// Get current channel and close it
ch := l.getCurrentLogChannel()
if ch != nil {
// Create closed channel for immediate replacement
closedChan := make(chan logRecord)
close(closedChan)
l.state.ActiveLogChannel.Store(closedChan)
// Close the actual channel to signal processor
close(ch)
}
// Wait for processor to exit (with timeout)
deadline := time.Now().Add(effectiveTimeout)
for time.Now().Before(deadline) {
if l.state.ProcessorExited.Load() {
break
}
time.Sleep(10 * time.Millisecond)
}
if !l.state.ProcessorExited.Load() {
return fmtErrorf("processor did not exit within timeout (%v)", effectiveTimeout)
}
return nil
}
// Shutdown gracefully closes the logger, attempting to flush pending records
// If no timeout is provided, uses a default of 2x flush interval
func (l *Logger) Shutdown(timeout ...time.Duration) error {
if !l.state.ShutdownCalled.CompareAndSwap(false, true) {
return nil
}
l.state.LoggerDisabled.Store(true)
if !l.state.IsInitialized.Load() {
l.state.ShutdownCalled.Store(false)
l.state.LoggerDisabled.Store(false)
l.state.ProcessorExited.Store(true)
return nil
}
var stopErr error
if l.state.Started.Load() {
stopErr = l.Stop(timeout...)
}
l.state.IsInitialized.Store(false)
var finalErr error
cfPtr := l.state.CurrentFile.Load()
if cfPtr != nil {
if currentLogFile, ok := cfPtr.(*os.File); ok && currentLogFile != nil {
if err := currentLogFile.Sync(); err != nil {
syncErr := fmtErrorf("failed to sync log file '%s' during shutdown: %w", currentLogFile.Name(), err)
finalErr = errors.Join(finalErr, syncErr)
}
if err := currentLogFile.Close(); err != nil {
closeErr := fmtErrorf("failed to close log file '%s' during shutdown: %w", currentLogFile.Name(), err)
finalErr = errors.Join(finalErr, closeErr)
}
l.state.CurrentFile.Store((*os.File)(nil))
}
}
if stopErr != nil {
finalErr = errors.Join(finalErr, stopErr)
}
return finalErr
}
// Flush explicitly triggers a sync of the current log file buffer to disk and waits for completion or timeout
func (l *Logger) Flush(timeout time.Duration) error {
l.state.flushMutex.Lock()
defer l.state.flushMutex.Unlock()
// State checks
if !l.state.IsInitialized.Load() || l.state.ShutdownCalled.Load() {
return fmtErrorf("logger not initialized or already shut down")
}
if !l.state.Started.Load() {
return fmtErrorf("logger not started")
}
// Create a channel to wait for confirmation from the processor
confirmChan := make(chan struct{})
// Send the request with the confirmation channel
select {
case l.state.flushRequestChan <- confirmChan:
// Request sent
case <-time.After(minWaitTime): // Short timeout to prevent blocking if processor is stuck
return fmtErrorf("failed to send flush request to processor (possible deadlock or high load)")
}
select {
case <-confirmChan:
return nil
case <-time.After(timeout):
return fmtErrorf("timeout waiting for flush confirmation (%v)", timeout)
}
}
// Debug logs a message at debug level
func (l *Logger) Debug(args ...any) {
flags := l.getFlags()
cfg := l.getConfig()
l.log(flags, LevelDebug, cfg.TraceDepth, args...)
}
// Info logs a message at info level
func (l *Logger) Info(args ...any) {
flags := l.getFlags()
cfg := l.getConfig()
l.log(flags, LevelInfo, cfg.TraceDepth, args...)
}
// Warn logs a message at warning level
func (l *Logger) Warn(args ...any) {
flags := l.getFlags()
cfg := l.getConfig()
l.log(flags, LevelWarn, cfg.TraceDepth, args...)
}
// Error logs a message at error level
func (l *Logger) Error(args ...any) {
flags := l.getFlags()
cfg := l.getConfig()
l.log(flags, LevelError, cfg.TraceDepth, args...)
}
// DebugTrace logs a debug message with function call trace
func (l *Logger) DebugTrace(depth int, args ...any) {
flags := l.getFlags()
l.log(flags, LevelDebug, int64(depth), args...)
}
// InfoTrace logs an info message with function call trace
func (l *Logger) InfoTrace(depth int, args ...any) {
flags := l.getFlags()
l.log(flags, LevelInfo, int64(depth), args...)
}
// WarnTrace logs a warning message with function call trace
func (l *Logger) WarnTrace(depth int, args ...any) {
flags := l.getFlags()
l.log(flags, LevelWarn, int64(depth), args...)
}
// ErrorTrace logs an error message with function call trace
func (l *Logger) ErrorTrace(depth int, args ...any) {
flags := l.getFlags()
l.log(flags, LevelError, int64(depth), args...)
}
// Log writes a timestamp-only record without level information
func (l *Logger) Log(args ...any) {
l.log(FlagShowTimestamp|FlagNoLevel, LevelInfo, 0, args...)
}
// Message writes a plain record without timestamp or level info
func (l *Logger) Message(args ...any) {
l.log(FlagNoTimestamp|FlagNoLevel, LevelInfo, 0, args...)
}
// LogTrace writes a timestamp record with call trace but no level info
func (l *Logger) LogTrace(depth int, args ...any) {
l.log(FlagShowTimestamp|FlagNoLevel, LevelInfo, int64(depth), args...)
}
// LogStructured logs a message with structured fields as proper JSON
func (l *Logger) LogStructured(level int64, message string, fields map[string]any) {
l.log(l.getFlags()|FlagStructuredJSON, level, 0, message, fields)
}
// Write outputs raw, unformatted data ignoring configured format and sanitization without trailing new line
func (l *Logger) Write(args ...any) {
l.log(FlagRaw, LevelInfo, 0, args...)
}
// getConfig returns the current configuration (thread-safe) // getConfig returns the current configuration (thread-safe)
func (l *Logger) getConfig() *Config { func (l *Logger) getConfig() *Config {
return l.currentConfig.Load().(*Config) return l.currentConfig.Load().(*Config)
@@ -353,20 +139,19 @@ func (l *Logger) applyConfig(cfg *Config) error {
oldCfg := l.getConfig() oldCfg := l.getConfig()
l.currentConfig.Store(cfg) l.currentConfig.Store(cfg)
// Create formatter with sanitizer // Shared formatter and sanitizer constructor with SetContextKeys
s := sanitizer.New().Policy(cfg.Sanitization) l.rebuildFormatter(cfg)
newFormatter := formatter.New(s).
Type(cfg.Format). // Emit fast-path mirrors
TimestampFormat(cfg.TimestampFormat). l.state.Flags.Store(flagsFromConfig(cfg))
ShowLevel(cfg.ShowLevel). l.state.TraceDepth.Store(cfg.TraceDepth)
ShowTimestamp(cfg.ShowTimestamp)
l.formatter.Store(newFormatter)
// Ensure log directory exists if file output is enabled // Ensure log directory exists if file output is enabled
if cfg.EnableFile { if cfg.EnableFile {
if err := os.MkdirAll(cfg.Directory, 0755); err != nil { if err := os.MkdirAll(cfg.Directory, 0755); err != nil {
l.state.LoggerDisabled.Store(true) l.state.LoggerDisabled.Store(true)
l.currentConfig.Store(oldCfg) // Rollback l.currentConfig.Store(oldCfg) // Rollback
l.refreshLevelGate()
return fmtErrorf("failed to create log directory '%s': %w", cfg.Directory, err) return fmtErrorf("failed to create log directory '%s': %w", cfg.Directory, err)
} }
} }
@@ -453,6 +238,7 @@ func (l *Logger) applyConfig(cfg *Config) error {
l.state.ShutdownCalled.Store(false) l.state.ShutdownCalled.Store(false)
l.state.DiskFullLogged.Store(false) l.state.DiskFullLogged.Store(false)
l.state.DiskStatusOK.Store(true) l.state.DiskStatusOK.Store(true)
l.refreshLevelGate()
// Restart processor if it was running and needs restart // Restart processor if it was running and needs restart
if needsRestart { if needsRestart {
@@ -461,3 +247,354 @@ func (l *Logger) applyConfig(cfg *Config) error {
return nil return nil
} }
// Start begins log processing. Safe to call multiple times
// Returns error if logger is not initialized
func (l *Logger) Start() error {
if !l.state.IsInitialized.Load() {
return fmtErrorf("logger not initialized, call ApplyConfig first")
}
// Check if processor didn't exit cleanly last time
if l.state.Started.Load() && !l.state.ProcessorExited.Load() {
// Force stop to clean up
l.internalLog("warning - processor still running from previous start, forcing stop\n")
if err := l.Stop(); err != nil {
return fmtErrorf("failed to stop hung processor: %w", err)
}
}
// Only start if not already started
if l.state.Started.CompareAndSwap(false, true) {
cfg := l.getConfig()
// Create log channels
ch := make(chan logRecord, cfg.BufferSize)
stop := make(chan struct{})
done := make(chan struct{})
l.state.ActiveLogChannel.Store(ch)
l.state.ProcStop.Store(stop)
l.state.ProcDone.Store(done)
l.state.ProcessorExited.Store(false)
// Start processor
l.spawn(func() { l.processLogs(ch, stop, done) })
}
l.refreshLevelGate()
return nil
}
// Stop halts log processing. Can be restarted with Start()
// The record channel is never closed: producers are detached to a nil channel
// first, so a concurrent send falls through to the drop counter instead of
// racing a close.
func (l *Logger) Stop(timeout ...time.Duration) error {
if !l.state.Started.CompareAndSwap(true, false) {
return nil // Already stopped
}
l.refreshLevelGate()
// Calculate effective timeout
var effectiveTimeout time.Duration
if len(timeout) > 0 {
effectiveTimeout = timeout[0]
} else {
effectiveTimeout = 2 * time.Duration(l.getConfig().FlushIntervalMs) * time.Millisecond
}
if effectiveTimeout < minWaitTime {
effectiveTimeout = minWaitTime
}
// 1. Detach producers
l.state.ActiveLogChannel.Store((chan logRecord)(nil))
// 2. Signal the processor to drain and exit
if s, ok := l.state.ProcStop.Load().(chan struct{}); ok && s != nil {
close(s)
}
// 3. Join
d, _ := l.state.ProcDone.Load().(chan struct{})
if d == nil {
return nil
}
select {
case <-d:
return nil
case <-time.After(effectiveTimeout):
return fmtErrorf("processor did not exit within timeout (%v)", effectiveTimeout)
}
}
// Shutdown gracefully closes the logger, attempting to flush pending records
// If no timeout is provided, uses a default of 2x flush interval
func (l *Logger) Shutdown(timeout ...time.Duration) error {
if !l.state.ShutdownCalled.CompareAndSwap(false, true) {
return nil
}
l.state.LoggerDisabled.Store(true)
l.refreshLevelGate()
if !l.state.IsInitialized.Load() {
l.state.ShutdownCalled.Store(false)
l.state.LoggerDisabled.Store(false)
l.state.ProcessorExited.Store(true)
l.refreshLevelGate()
return nil
}
var stopErr error
if l.state.Started.Load() {
stopErr = l.Stop(timeout...)
}
l.state.IsInitialized.Store(false)
var finalErr error
cfPtr := l.state.CurrentFile.Load()
if cfPtr != nil {
if currentLogFile, ok := cfPtr.(*os.File); ok && currentLogFile != nil {
if err := currentLogFile.Sync(); err != nil {
syncErr := fmtErrorf("failed to sync log file '%s' during shutdown: %w", currentLogFile.Name(), err)
finalErr = errors.Join(finalErr, syncErr)
}
if err := currentLogFile.Close(); err != nil {
closeErr := fmtErrorf("failed to close log file '%s' during shutdown: %w", currentLogFile.Name(), err)
finalErr = errors.Join(finalErr, closeErr)
}
l.state.CurrentFile.Store((*os.File)(nil))
}
}
if stopErr != nil {
finalErr = errors.Join(finalErr, stopErr)
}
return finalErr
}
// Flush explicitly triggers a sync of the current log file buffer to disk and waits for completion or timeout
func (l *Logger) Flush(timeout time.Duration) error {
l.state.flushMutex.Lock()
defer l.state.flushMutex.Unlock()
// State checks
if !l.state.IsInitialized.Load() || l.state.ShutdownCalled.Load() {
return fmtErrorf("logger not initialized or already shut down")
}
if !l.state.Started.Load() {
return fmtErrorf("logger not started")
}
// Create a channel to wait for confirmation from the processor
confirmChan := make(chan struct{})
// Send the request with the confirmation channel
select {
case l.state.flushRequestChan <- confirmChan:
// Request sent
case <-time.After(minWaitTime): // Short timeout to prevent blocking if processor is stuck
return fmtErrorf("failed to send flush request to processor (possible deadlock or high load)")
}
select {
case <-confirmChan:
return nil
case <-time.After(timeout):
return fmtErrorf("timeout waiting for flush confirmation (%v)", timeout)
}
}
// SetSpawn installs the goroutine launcher used by Start. Hosts that own
// panic recovery and terminal teardown pass their own launcher here.
// Call before Start; a nil fn restores the default.
func (l *Logger) SetSpawn(fn func(func())) {
if fn == nil {
l.spawner.Store(nil)
return
}
l.spawner.Store(&fn)
}
// SetErrorHandler routes internal diagnostics to fn instead of stderr.
// Required for TUI hosts, where stderr writes corrupt the display.
func (l *Logger) SetErrorHandler(fn func(string)) {
if fn == nil {
l.errHandler.Store(nil)
return
}
l.errHandler.Store(&fn)
}
// SetContextKeys names the record keys for Context values; empty names are
// omitted. Safe to call at any time; rebuilds the formatter.
func (l *Logger) SetContextKeys(tag string, vals ...string) {
l.initMu.Lock()
defer l.initMu.Unlock()
k := formatter.ContextKeys{Tag: tag}
for i := 0; i < len(vals) && i < formatter.ContextSlots; i++ {
k.Vals[i] = vals[i]
}
l.ctxKeys.Store(&k)
l.rebuildFormatter(l.getConfig())
}
// SetLevel changes the emit threshold in place, leaving the formatter and the
// processor untouched
func (l *Logger) SetLevel(level int64) {
l.initMu.Lock()
cfg := l.getConfig().Clone()
cfg.Level = level
l.currentConfig.Store(cfg)
l.initMu.Unlock()
l.refreshLevelGate()
}
// Enabled reports whether a record at level would be emitted. Single atomic
// load: the intended guard for hot call sites, where argument slices are
// built before the call and would otherwise escape to the heap.
func (l *Logger) Enabled(level int64) bool {
return level >= l.state.Level.Load()
}
// Flags returns the default record flags derived from display config
func (l *Logger) Flags() int64 {
return l.state.Flags.Load()
}
// LogContext emits a record with caller-supplied context and explicit flags
func (l *Logger) LogContext(ctx Context, flags, level, depth int64, args ...any) {
if level < l.state.Level.Load() {
return
}
l.emit(ctx, flags, level, depth, args)
}
// spawn runs fn via the configured launcher; the default is a bare goroutine
func (l *Logger) spawn(fn func()) {
if p := l.spawner.Load(); p != nil {
(*p)(fn)
return
}
go fn()
}
// rebuildFormatter installs a formatter matching cfg and the current context keys
func (l *Logger) rebuildFormatter(cfg *Config) {
f := formatter.New(sanitizer.New().Policy(cfg.Sanitization)).
Type(cfg.Format).
TimestampFormat(cfg.TimestampFormat).
ShowLevel(cfg.ShowLevel).
ShowTimestamp(cfg.ShowTimestamp)
if k := l.ctxKeys.Load(); k != nil {
f.ContextKeys(k.Tag, k.Vals[:]...)
}
l.formatter.Store(f)
}
// flagsFromConfig derives the default record flags from display settings
func flagsFromConfig(cfg *Config) int64 {
var flags int64
if cfg.ShowLevel {
flags |= FlagShowLevel
}
if cfg.ShowTimestamp {
flags |= FlagShowTimestamp
}
return flags
}
// refreshLevelGate recomputes the emit gate from lifecycle state.
// MUST be called after every transition of IsInitialized, Started,
// LoggerDisabled, or ShutdownCalled.
func (l *Logger) refreshLevelGate() {
if !l.state.IsInitialized.Load() || !l.state.Started.Load() ||
l.state.LoggerDisabled.Load() || l.state.ShutdownCalled.Load() {
l.state.Level.Store(levelOff)
return
}
l.state.Level.Store(l.getConfig().Level)
}
// === Logging methods ===
// Debug logs a message at debug level
func (l *Logger) Debug(args ...any) {
if LevelDebug < l.state.Level.Load() {
return
}
l.emit(Context{}, l.getFlags(), LevelDebug, l.state.TraceDepth.Load(), args)
}
// Info logs a message at info level
func (l *Logger) Info(args ...any) {
if LevelInfo < l.state.Level.Load() {
return
}
l.emit(Context{}, l.getFlags(), LevelInfo, l.state.TraceDepth.Load(), args)
}
// Warn logs a message at warning level
func (l *Logger) Warn(args ...any) {
if LevelWarn < l.state.Level.Load() {
return
}
l.emit(Context{}, l.getFlags(), LevelWarn, l.state.TraceDepth.Load(), args)
}
// Error logs a message at error level
func (l *Logger) Error(args ...any) {
if LevelError < l.state.Level.Load() {
return
}
l.emit(Context{}, l.getFlags(), LevelError, l.state.TraceDepth.Load(), args)
}
// DebugTrace logs a debug message with function call trace
func (l *Logger) DebugTrace(depth int, args ...any) {
l.LogContext(Context{}, l.getFlags(), LevelDebug, int64(depth), args...)
}
// InfoTrace logs an info message with function call trace
func (l *Logger) InfoTrace(depth int, args ...any) {
l.LogContext(Context{}, l.getFlags(), LevelInfo, int64(depth), args...)
}
// WarnTrace logs a warning message with function call trace
func (l *Logger) WarnTrace(depth int, args ...any) {
l.LogContext(Context{}, l.getFlags(), LevelWarn, int64(depth), args...)
}
// ErrorTrace logs an error message with function call trace
func (l *Logger) ErrorTrace(depth int, args ...any) {
l.LogContext(Context{}, l.getFlags(), LevelError, int64(depth), args...)
}
// Log writes a timestamp-only record without level information
func (l *Logger) Log(args ...any) {
l.LogContext(Context{}, FlagShowTimestamp|FlagNoLevel, LevelInfo, 0, args...)
}
// Message writes a plain record without timestamp or level info
func (l *Logger) Message(args ...any) {
l.LogContext(Context{}, FlagNoTimestamp|FlagNoLevel, LevelInfo, 0, args...)
}
// LogTrace writes a timestamp record with call trace but no level info
func (l *Logger) LogTrace(depth int, args ...any) {
l.LogContext(Context{}, FlagShowTimestamp|FlagNoLevel, LevelInfo, int64(depth), args...)
}
// LogStructured logs a message with structured fields as proper JSON
func (l *Logger) LogStructured(level int64, message string, fields map[string]any) {
l.LogContext(Context{}, l.getFlags()|FlagStructuredJSON, level, 0, message, fields)
}
// Write outputs raw, unformatted data ignoring configured format and sanitization
func (l *Logger) Write(args ...any) {
l.LogContext(Context{}, FlagRaw, LevelInfo, 0, args...)
}
+37 -37
View File
@@ -7,10 +7,14 @@ import (
"github.com/lixenwraith/log/formatter" "github.com/lixenwraith/log/formatter"
) )
// processLogs is the main log processing loop running in a separate goroutine // processLogs is the main log processing loop running in a separate goroutine.
func (l *Logger) processLogs(ch <-chan logRecord) { // Exits on stop, draining buffered records first. No panic recovery: a fault
l.state.ProcessorExited.Store(false) // here is fatal by design and is surfaced by the host's spawner.
defer l.state.ProcessorExited.Store(true) func (l *Logger) processLogs(ch <-chan logRecord, stop <-chan struct{}, done chan<- struct{}) {
defer func() {
l.state.ProcessorExited.Store(true)
close(done)
}()
// Set up timers and state variables // Set up timers and state variables
timers := l.setupProcessingTimers() timers := l.setupProcessingTimers()
@@ -45,12 +49,12 @@ func (l *Logger) processLogs(ch <-chan logRecord) {
// --- Main Loop --- // --- Main Loop ---
for { for {
select { select {
case record, ok := <-ch: case <-stop:
if !ok { l.drain(ch)
l.performSync() l.performSync()
return return
}
case record := <-ch:
// Process the received log record // Process the received log record
bytesWritten := l.processLogRecord(record) bytesWritten := l.processLogRecord(record)
if bytesWritten > 0 { if bytesWritten > 0 {
@@ -81,7 +85,7 @@ func (l *Logger) processLogs(ch <-chan logRecord) {
} }
case confirmChan := <-l.state.flushRequestChan: case confirmChan := <-l.state.flushRequestChan:
// Barrier semantics — drain queued records before sync // Barrier: drain queued records before sync
l.handleFlushRequest(ch, confirmChan) l.handleFlushRequest(ch, confirmChan)
case <-timers.retentionChan: case <-timers.retentionChan:
@@ -93,6 +97,26 @@ func (l *Logger) processLogs(ch <-chan logRecord) {
} }
} }
// drain processes every buffered record without blocking
func (l *Logger) drain(ch <-chan logRecord) {
for {
select {
case record := <-ch:
l.processLogRecord(record)
default:
return
}
}
}
// handleFlushRequest drains pending records, then syncs. Gives Flush barrier
// semantics: records enqueued before the Flush call are processed first.
func (l *Logger) handleFlushRequest(ch <-chan logRecord, confirmChan chan struct{}) {
l.drain(ch)
l.performSync()
close(confirmChan)
}
// processLogRecord handles individual log records and returns bytes written // processLogRecord handles individual log records and returns bytes written
func (l *Logger) processLogRecord(record logRecord) int64 { func (l *Logger) processLogRecord(record logRecord) int64 {
c := l.getConfig() c := l.getConfig()
@@ -113,7 +137,8 @@ func (l *Logger) processLogRecord(record logRecord) int64 {
f := formatterPtr.(*formatter.Formatter) f := formatterPtr.(*formatter.Formatter)
// Format the log entry using atomically-loaded formatter // Format the log entry using atomically-loaded formatter
formattedData := f.Format( formattedData := f.FormatCtx(
record.Ctx,
record.Flags, record.Flags,
record.TimeStamp, record.TimeStamp,
record.Level, record.Level,
@@ -193,27 +218,6 @@ func (l *Logger) handleFlushTick() {
} }
} }
// handleFlushRequest drains pending records, then syncs. Gives Flush barrier semantics:
// Records enqueued before the Flush call are processed before confirmation.
// Channel close is left to the main loop.
func (l *Logger) handleFlushRequest(ch <-chan logRecord, confirmChan chan struct{}) {
for {
select {
case record, ok := <-ch:
if !ok {
l.performSync()
close(confirmChan)
return
}
l.processLogRecord(record)
default:
l.performSync()
close(confirmChan)
return
}
}
}
// handleRetentionCheck performs file retention check and cleanup // handleRetentionCheck performs file retention check and cleanup
func (l *Logger) handleRetentionCheck() { func (l *Logger) handleRetentionCheck() {
c := l.getConfig() c := l.getConfig()
@@ -244,10 +248,7 @@ func (l *Logger) adjustDiskCheckInterval(timers *TimerSet, lastCheckTime time.Ti
return return
} }
elapsed := time.Since(lastCheckTime) elapsed := max(time.Since(lastCheckTime), minWaitTime) // Min arbitrary reasonable value
if elapsed < minWaitTime { // Min arbitrary reasonable value
elapsed = minWaitTime
}
logsPerSecond := float64(logsSinceLastCheck) / elapsed.Seconds() logsPerSecond := float64(logsSinceLastCheck) / elapsed.Seconds()
targetLogsPerSecond := float64(100) // Baseline targetLogsPerSecond := float64(100) // Baseline
@@ -281,4 +282,3 @@ func (l *Logger) adjustDiskCheckInterval(timers *TimerSet, lastCheckTime time.Ti
timers.diskCheckTicker.Reset(newInterval) timers.diskCheckTicker.Reset(newInterval)
} }
+40 -10
View File
@@ -7,8 +7,37 @@ import (
"time" "time"
) )
// // procRecords parses PROC heartbeat records out of json-formatted content.
// // Heartbeat arguments are emitted as a flat key/value array.
// func procRecords(tb testing.TB, content string) []map[string]any {
// tb.Helper()
// var out []map[string]any
// for _, line := range strings.Split(content, "\n") {
// if !strings.Contains(line, `"level":"PROC"`) {
// continue
// }
// var entry map[string]any
// if json.Unmarshal([]byte(line), &entry) != nil {
// continue
// }
// fields, ok := entry["fields"].([]any)
// if !ok {
// continue
// }
// rec := make(map[string]any, len(fields)/2)
// for i := 0; i+1 < len(fields); i += 2 {
// if key, ok := fields[i].(string); ok {
// rec[key] = fields[i+1]
// }
// }
// out = append(out, rec)
// }
// return out
// }
// procRecords parses PROC heartbeat records out of json-formatted content. // procRecords parses PROC heartbeat records out of json-formatted content.
// Heartbeat arguments are emitted as a flat key/value array. // Heartbeat arguments are emitted as a keyed object (FlagKV); the flat
// key/value array is still accepted for records written without the flag.
func procRecords(tb testing.TB, content string) []map[string]any { func procRecords(tb testing.TB, content string) []map[string]any {
tb.Helper() tb.Helper()
var out []map[string]any var out []map[string]any
@@ -20,17 +49,18 @@ func procRecords(tb testing.TB, content string) []map[string]any {
if json.Unmarshal([]byte(line), &entry) != nil { if json.Unmarshal([]byte(line), &entry) != nil {
continue continue
} }
fields, ok := entry["fields"].([]any) switch fields := entry["fields"].(type) {
if !ok { case map[string]any:
continue out = append(out, fields)
} case []any:
rec := make(map[string]any, len(fields)/2) rec := make(map[string]any, len(fields)/2)
for i := 0; i+1 < len(fields); i += 2 { for i := 0; i+1 < len(fields); i += 2 {
if key, ok := fields[i].(string); ok { if key, ok := fields[i].(string); ok {
rec[key] = fields[i+1] rec[key] = fields[i+1]
}
} }
out = append(out, rec)
} }
out = append(out, rec)
} }
return out return out
} }
+64 -73
View File
@@ -7,56 +7,27 @@ import (
"time" "time"
) )
// getCurrentLogChannel safely retrieves the current log channel // getCurrentLogChannel returns the active record channel, nil when detached
func (l *Logger) getCurrentLogChannel() chan logRecord { func (l *Logger) getCurrentLogChannel() chan logRecord {
chVal := l.state.ActiveLogChannel.Load() ch, _ := l.state.ActiveLogChannel.Load().(chan logRecord)
// No defensive nil check required in correct use of initialized logger return ch
return chVal.(chan logRecord)
} }
// getFlags from config // getFlags returns the cached default record flags
func (l *Logger) getFlags() int64 { func (l *Logger) getFlags() int64 {
var flags int64 = 0 return l.state.Flags.Load()
cfg := l.getConfig()
if cfg.ShowLevel {
flags |= FlagShowLevel
}
if cfg.ShowTimestamp {
flags |= FlagShowTimestamp
}
return flags
} }
// sendLogRecord handles safe sending to the active channel // sendLogRecord queues a record without blocking. The channel is never closed,
// so no recovery is needed: a detached (nil) channel takes the default branch.
func (l *Logger) sendLogRecord(record logRecord) { func (l *Logger) sendLogRecord(record logRecord) {
defer func() { if l.state.LoggerDisabled.Load() {
if r := recover(); r != nil {
// A panic is only expected when a race condition occurs during shutdown
if err, ok := r.(error); ok && err.Error() == "send on closed channel" {
// Expected race condition between logging and shutdown
l.handleFailedSend()
} else {
// Unexpected panic, re-throw to surface
panic(r)
}
}
}()
if l.state.ShutdownCalled.Load() ||
l.state.LoggerDisabled.Load() ||
!l.state.Started.Load() {
// Process drops even if logger is disabled or shutting down
l.handleFailedSend() l.handleFailedSend()
return return
} }
ch := l.getCurrentLogChannel() ch := l.getCurrentLogChannel()
// Non-blocking send
select { select {
case ch <- record: case ch <- record:
// Success
default: default:
l.handleFailedSend() l.handleFailedSend()
} }
@@ -68,59 +39,79 @@ func (l *Logger) handleFailedSend() {
l.state.TotalDroppedLogs.Add(1) // Total counter l.state.TotalDroppedLogs.Add(1) // Total counter
} }
// log handles the core logging logic // emit builds and queues a record; the caller has already passed the level gate
func (l *Logger) log(flags int64, level int64, depth int64, args ...any) { func (l *Logger) emit(ctx Context, flags, level, depth int64, args []any) {
// State checks
if !l.state.IsInitialized.Load() {
return
}
if !l.state.Started.Load() {
// Log to internal error channel if configured
cfg := l.getConfig()
if cfg.InternalErrorsToStderr {
l.internalLog("warning - logger not started, dropping log entry\n")
}
return
}
// Discard or proceed based on level
cfg := l.getConfig()
if level < cfg.Level {
return
}
// Get trace info from runtime
// Depth filter hard-coded based on call stack of current package design // Depth filter hard-coded based on call stack of current package design
var trace string var trace string
if depth > 0 { if depth > 0 {
const skipTrace = 3 // log.Info -> log -> getTrace (Adjust if call stack changes) const skipTrace = 3 // Logger.Info -> emit -> getTrace
trace = getTrace(depth, skipTrace) trace = getTrace(depth, skipTrace)
} }
record := logRecord{ l.sendLogRecord(logRecord{
Ctx: ctx,
Flags: flags, Flags: flags,
TimeStamp: time.Now(), TimeStamp: time.Now(),
Level: level, Level: level,
Trace: trace, Trace: trace,
Args: args, Args: args,
} })
l.sendLogRecord(record)
} }
// internalLog handles writing internal logger diagnostics to stderr if enabled // internalLog reports logger diagnostics to the registered handler, or to
// stderr when configured. Hosts owning a terminal must register a handler.
func (l *Logger) internalLog(format string, args ...any) { func (l *Logger) internalLog(format string, args ...any) {
// Check if internal error reporting is enabled
cfg := l.getConfig()
if !cfg.InternalErrorsToStderr {
return
}
// Ensure consistent "log: " prefix
if !strings.HasPrefix(format, "log: ") { if !strings.HasPrefix(format, "log: ") {
format = "log: " + format format = "log: " + format
} }
// Write to stderr if h := l.errHandler.Load(); h != nil {
(*h)(strings.TrimRight(fmt.Sprintf(format, args...), "\n"))
return
}
if !l.getConfig().InternalErrorsToStderr {
return
}
fmt.Fprintf(os.Stderr, format, args...) fmt.Fprintf(os.Stderr, format, args...)
} }
// // log handles the core logging logic
// func (l *Logger) log(flags int64, level int64, depth int64, args ...any) {
// // State checks
// if !l.state.IsInitialized.Load() {
// return
// }
//
// if !l.state.Started.Load() {
// // Log to internal error channel if configured
// cfg := l.getConfig()
// if cfg.InternalErrorsToStderr {
// l.internalLog("warning - logger not started, dropping log entry\n")
// }
// return
// }
//
// // Discard or proceed based on level
// cfg := l.getConfig()
// if level < cfg.Level {
// return
// }
//
// // Get trace info from runtime
// // Depth filter hard-coded based on call stack of current package design
// var trace string
// if depth > 0 {
// const skipTrace = 3 // log.Info -> log -> getTrace (Adjust if call stack changes)
// trace = getTrace(depth, skipTrace)
// }
//
// record := logRecord{
// Flags: flags,
// TimeStamp: time.Now(),
// Level: level,
// Trace: trace,
// Args: args,
// }
// l.sendLogRecord(record)
// }
+8
View File
@@ -16,6 +16,12 @@ type State struct {
Started atomic.Bool // Tracks calls to Start() and Stop() Started atomic.Bool // Tracks calls to Start() and Stop()
ProcessorExited atomic.Bool // Tracks if the processor goroutine is running or has exited ProcessorExited atomic.Bool // Tracks if the processor goroutine is running or has exited
// Emit fast path: config mirrors refreshed by applyConfig and the
// lifecycle transitions. Level holds levelOff while emission is closed
Level atomic.Int64
Flags atomic.Int64
TraceDepth atomic.Int64
// Flushing state // Flushing state
flushRequestChan chan chan struct{} // Channel to request a flush flushRequestChan chan chan struct{} // Channel to request a flush
flushMutex sync.Mutex // Protect concurrent Flush calls flushMutex sync.Mutex // Protect concurrent Flush calls
@@ -30,6 +36,8 @@ type State struct {
// Log state // Log state
ActiveLogChannel atomic.Value // stores chan logRecord ActiveLogChannel atomic.Value // stores chan logRecord
ProcStop atomic.Value // stores chan struct{}, closed to signal exit
ProcDone atomic.Value // stores chan struct{}, closed by the processor
DroppedLogs atomic.Uint64 // Counter for logs dropped since last heartbeat DroppedLogs atomic.Uint64 // Counter for logs dropped since last heartbeat
TotalDroppedLogs atomic.Uint64 // Counter for total logs dropped since logger start TotalDroppedLogs atomic.Uint64 // Counter for total logs dropped since logger start
+56 -42
View File
@@ -5,8 +5,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"syscall"
"time" "time"
) )
@@ -25,10 +23,14 @@ func (l *Logger) performSync() {
if err := currentLogFile.Sync(); err != nil { if err := currentLogFile.Sync(); err != nil {
// Log sync error // Log sync error
syncErrRecord := logRecord{ syncErrRecord := logRecord{
Flags: FlagDefault, Flags: FlagDefault | FlagKV,
TimeStamp: time.Now(), TimeStamp: time.Now(),
Level: LevelWarn, Level: LevelWarn,
Args: []any{"Log file sync failed", "file", currentLogFile.Name(), "error", err.Error()}, Args: []any{
"msg", "log file sync failed",
"file", currentLogFile.Name(),
"error", err.Error(),
},
} }
l.sendLogRecord(syncErrRecord) l.sendLogRecord(syncErrRecord)
} }
@@ -106,8 +108,11 @@ func (l *Logger) performDiskCheck(forceCleanup bool) bool {
if err := l.cleanOldLogs(spaceToFree); err != nil { if err := l.cleanOldLogs(spaceToFree); err != nil {
if !l.state.DiskFullLogged.Swap(true) { if !l.state.DiskFullLogged.Swap(true) {
diskFullRecord := logRecord{ diskFullRecord := logRecord{
Flags: FlagDefault, TimeStamp: time.Now(), Level: LevelError, Flags: FlagDefault | FlagKV, TimeStamp: time.Now(), Level: LevelError,
Args: []any{"Log directory full or disk space low, cleanup failed", "error", err.Error()}, Args: []any{
"msg", "log directory full or disk space low, cleanup failed",
"error", err.Error(),
},
} }
l.sendLogRecord(diskFullRecord) l.sendLogRecord(diskFullRecord)
} }
@@ -135,27 +140,27 @@ func (l *Logger) performDiskCheck(forceCleanup bool) bool {
} }
} }
// getDiskFreeSpace retrieves available disk space for the given path // // getDiskFreeSpace retrieves available disk space for the given path
func (l *Logger) getDiskFreeSpace(path string) (int64, error) { // func (l *Logger) getDiskFreeSpace(path string) (int64, error) {
var stat syscall.Statfs_t // var stat syscall.Statfs_t
info, err := os.Stat(path) // info, err := os.Stat(path)
if err != nil { // if err != nil {
if os.IsNotExist(err) { // if os.IsNotExist(err) {
return 0, fmtErrorf("log directory '%s' does not exist for disk check: %w", path, err) // return 0, fmtErrorf("log directory '%s' does not exist for disk check: %w", path, err)
} // }
return 0, fmtErrorf("failed to stat log directory '%s': %w", path, err) // return 0, fmtErrorf("failed to stat log directory '%s': %w", path, err)
} // }
if !info.IsDir() { // if !info.IsDir() {
path = filepath.Dir(path) // path = filepath.Dir(path)
} // }
//
if err := syscall.Statfs(path, &stat); err != nil { // if err := syscall.Statfs(path, &stat); err != nil {
return 0, fmtErrorf("failed to get disk stats for '%s': %w", path, err) // return 0, fmtErrorf("failed to get disk stats for '%s': %w", path, err)
} // }
// Explicit cast to int64 to satisfy both Linux and FreebSD // // Explicit cast to int64 to satisfy both Linux and FreebSD
availableBytes := int64(stat.Bavail) * int64(stat.Bsize) // availableBytes := int64(stat.Bavail) * int64(stat.Bsize)
return availableBytes, nil // return availableBytes, nil
} // }
// getLogDirSize calculates total size of log files matching the current extension // getLogDirSize calculates total size of log files matching the current extension
func (l *Logger) getLogDirSize(dir, ext string) (int64, error) { func (l *Logger) getLogDirSize(dir, ext string) (int64, error) {
@@ -254,7 +259,9 @@ func (l *Logger) cleanOldLogs(required int64) error {
return nil return nil
} }
// updateEarliestFileTime scans the log directory for the oldest log file // updateEarliestFileTime scans the log directory for the oldest log file.
// Matches by extension only: a name-prefix filter would hide files written by
// earlier runs when the active name carries a per-run timestamp.
func (l *Logger) updateEarliestFileTime() { func (l *Logger) updateEarliestFileTime() {
c := l.getConfig() c := l.getConfig()
dir := c.Directory dir := c.Directory
@@ -275,17 +282,15 @@ func (l *Logger) updateEarliestFileTime() {
} }
targetExt := "." + ext targetExt := "." + ext
prefix := name + "_"
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
continue continue
} }
fname := entry.Name() fname := entry.Name()
// Skip the active log file
if fname == staticLogName { if fname == staticLogName {
continue continue // Skip the active log file
} }
if !strings.HasPrefix(fname, prefix) || (ext != "" && filepath.Ext(fname) != targetExt) { if ext != "" && filepath.Ext(fname) != targetExt {
continue continue
} }
info, errInfo := entry.Info() info, errInfo := entry.Info()
@@ -371,19 +376,28 @@ func (l *Logger) getStaticLogFilePath() string {
return filepath.Join(dir, filename) return filepath.Join(dir, filename)
} }
// generateArchiveLogFileName creates a timestamped filename for archived logs during rotation // fileExists reports whether path names an existing regular file
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// generateArchiveLogFileName creates a second-resolution name for a rotated
// log, disambiguating with a counter when that name is already taken
func (l *Logger) generateArchiveLogFileName(timestamp time.Time) string { func (l *Logger) generateArchiveLogFileName(timestamp time.Time) string {
c := l.getConfig() c := l.getConfig()
ext := c.Extension
name := c.Name
tsFormat := timestamp.Format("060102_150405") suffix := ""
nano := timestamp.Nanosecond() if c.Extension != "" {
suffix = "." + c.Extension
if ext != "" {
return fmt.Sprintf("%s_%s_%d.%s", name, tsFormat, nano, ext)
} }
return fmt.Sprintf("%s_%s_%d", name, tsFormat, nano) base := fmt.Sprintf("%s_%s", c.Name, timestamp.Format("060102_150405"))
name := base + suffix
for i := 1; i < 1000 && fileExists(filepath.Join(c.Directory, name)); i++ {
name = fmt.Sprintf("%s_%d%s", base, i, suffix)
}
return name
} }
// createNewLogFile generates a unique name and opens a new log file // createNewLogFile generates a unique name and opens a new log file
@@ -449,6 +463,7 @@ func (l *Logger) rotateLogFile() error {
l.internalLog("failed to rename log file from '%s' to '%s': %v. file logging disabled.", l.internalLog("failed to rename log file from '%s' to '%s': %v. file logging disabled.",
currentPath, archivePath, err) currentPath, archivePath, err)
l.state.LoggerDisabled.Store(true) l.state.LoggerDisabled.Store(true)
l.refreshLevelGate()
return fmtErrorf("failed to rotate log file, logging is disabled: %w", err) return fmtErrorf("failed to rotate log file, logging is disabled: %w", err)
} }
@@ -492,4 +507,3 @@ func (l *Logger) getLogFileCount(dir, ext string) (int, error) {
} }
return count, nil return count, nil
} }
+11
View File
@@ -0,0 +1,11 @@
//go:build !unix
package log
import "math"
// getDiskFreeSpace reports unlimited space where statfs is unavailable.
// Total-size limits still apply; only the free-space check is inert.
func (l *Logger) getDiskFreeSpace(path string) (int64, error) {
return math.MaxInt64, nil
}
+14 -8
View File
@@ -42,7 +42,7 @@ func TestLogRotation(t *testing.T) {
switch { switch {
case name == "log.log": case name == "log.log":
hasActive = true hasActive = true
// Archive pattern: log_YYMMDD_HHMMSS_<nano>.log // Archive pattern: log_YYMMDD_HHMMSS[_N].log
case strings.HasPrefix(name, "log_") && strings.HasSuffix(name, ".log"): case strings.HasPrefix(name, "log_") && strings.HasSuffix(name, ".log"):
archives++ archives++
default: default:
@@ -195,7 +195,8 @@ func TestLogDirAccounting(t *testing.T) {
equal(t, count, 0, "count of missing dir") equal(t, count, 0, "count of missing dir")
} }
// TestArchiveNaming verifies archive names are unique and carry the base name. // TestArchiveNaming verifies archive names carry the base name and never
// collide with a file already present in the directory.
func TestArchiveNaming(t *testing.T) { func TestArchiveNaming(t *testing.T) {
logger, tmpDir := newTestLogger(t) logger, tmpDir := newTestLogger(t)
@@ -203,12 +204,17 @@ func TestArchiveNaming(t *testing.T) {
ts := time.Now() ts := time.Now()
first := logger.generateArchiveLogFileName(ts) first := logger.generateArchiveLogFileName(ts)
second := logger.generateArchiveLogFileName(ts.Add(time.Nanosecond))
isTrue(t, strings.HasPrefix(first, "log_"), "archive prefix") isTrue(t, strings.HasPrefix(first, "log_"), "archive prefix")
isTrue(t, strings.HasSuffix(first, ".log"), "archive extension") isTrue(t, strings.HasSuffix(first, ".log"), "archive extension")
if first == second {
t.Errorf("archive names must be unique at nanosecond resolution: %s", first)
}
}
// Names are second-resolution; the counter disambiguates against files
// already on disk, not against clock resolution
mustNoErr(t, os.WriteFile(filepath.Join(tmpDir, first), []byte("archived"), 0644), "WriteFile archive")
second := logger.generateArchiveLogFileName(ts)
if second == first {
t.Errorf("archive name collided with existing file: %s", second)
}
isTrue(t, strings.HasPrefix(second, "log_"), "archive prefix")
isTrue(t, strings.HasSuffix(second, ".log"), "archive extension")
}
+30
View File
@@ -0,0 +1,30 @@
//go:build unix
package log
import (
"os"
"path/filepath"
"syscall"
)
// getDiskFreeSpace retrieves available disk space for the given path
func (l *Logger) getDiskFreeSpace(path string) (int64, error) {
var stat syscall.Statfs_t
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return 0, fmtErrorf("log directory '%s' does not exist for disk check: %w", path, err)
}
return 0, fmtErrorf("failed to stat log directory '%s': %w", path, err)
}
if !info.IsDir() {
path = filepath.Dir(path)
}
if err := syscall.Statfs(path, &stat); err != nil {
return 0, fmtErrorf("failed to get disk stats for '%s': %w", path, err)
}
// Explicit cast to int64 to satisfy both Linux and FreeBSD
return int64(stat.Bavail) * int64(stat.Bsize), nil
}
+11 -2
View File
@@ -3,15 +3,24 @@ package log
import ( import (
"io" "io"
"time" "time"
"github.com/lixenwraith/log/formatter"
) )
// Context carries caller-stamped values emitted with a record
type Context = formatter.Context
// ContextSlots is the number of correlation values a Context carries
const ContextSlots = formatter.ContextSlots
// logRecord represents a single log entry // logRecord represents a single log entry
type logRecord struct { type logRecord struct {
Flags int64
TimeStamp time.Time TimeStamp time.Time
Level int64
Trace string Trace string
Args []any Args []any
Ctx Context
Flags int64
Level int64
} }
// TimerSet holds all timers used in processLogs // TimerSet holds all timers used in processLogs