diff --git a/config.go b/config.go index d40c256..f76a689 100644 --- a/config.go +++ b/config.go @@ -2,7 +2,6 @@ package log import ( - "strings" "time" ) @@ -107,59 +106,44 @@ func DefaultConfig() *Config { // validate performs basic sanity checks on the configuration values. func (c *Config) validate() error { - if strings.TrimSpace(c.Name) == "" { - return fmtErrorf("log name cannot be empty") + // Individual field validations + fields := map[string]any{ + "name": c.Name, + "format": c.Format, + "extension": c.Extension, + "timestamp_format": c.TimestampFormat, + "buffer_size": c.BufferSize, + "max_size_mb": c.MaxSizeMB, + "max_total_size_mb": c.MaxTotalSizeMB, + "min_disk_free_mb": c.MinDiskFreeMB, + "flush_interval_ms": c.FlushIntervalMs, + "disk_check_interval_ms": c.DiskCheckIntervalMs, + "min_check_interval_ms": c.MinCheckIntervalMs, + "max_check_interval_ms": c.MaxCheckIntervalMs, + "trace_depth": c.TraceDepth, + "retention_period_hrs": c.RetentionPeriodHrs, + "retention_check_mins": c.RetentionCheckMins, + "heartbeat_level": c.HeartbeatLevel, + "heartbeat_interval_s": c.HeartbeatIntervalS, + "stdout_target": c.StdoutTarget, + "level": c.Level, } - if c.Format != "txt" && c.Format != "json" { - return fmtErrorf("invalid format: '%s' (use txt or json)", c.Format) - } - 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) - } - if c.MaxSizeMB < 0 { - return fmtErrorf("max_size_mb cannot be negative: %d", c.MaxSizeMB) - } - if c.MaxTotalSizeMB < 0 { - return fmtErrorf("max_total_size_mb cannot be negative: %d", c.MaxTotalSizeMB) - } - if c.MinDiskFreeMB < 0 { - return fmtErrorf("min_disk_free_mb cannot be negative: %d", c.MinDiskFreeMB) - } - if c.FlushIntervalMs <= 0 { - return fmtErrorf("flush_interval_ms must be positive milliseconds: %d", c.FlushIntervalMs) - } - if c.DiskCheckIntervalMs <= 0 { - return fmtErrorf("disk_check_interval_ms must be positive milliseconds: %d", c.DiskCheckIntervalMs) - } - if c.MinCheckIntervalMs <= 0 { - return fmtErrorf("min_check_interval_ms must be positive milliseconds: %d", c.MinCheckIntervalMs) - } - if c.MaxCheckIntervalMs <= 0 { - return fmtErrorf("max_check_interval_ms must be positive milliseconds: %d", c.MaxCheckIntervalMs) + + for key, value := range fields { + if err := validateConfigValue(key, value); err != nil { + return err + } } + + // Cross-field validations if c.MinCheckIntervalMs > c.MaxCheckIntervalMs { - return fmtErrorf("min_check_interval_ms (%d) cannot be greater than max_check_interval_ms (%d)", c.MinCheckIntervalMs, c.MaxCheckIntervalMs) - } - if c.TraceDepth < 0 || c.TraceDepth > 10 { - return fmtErrorf("trace_depth must be between 0 and 10: %d", c.TraceDepth) - } - if c.RetentionPeriodHrs < 0 { - return fmtErrorf("retention_period_hrs cannot be negative: %f", c.RetentionPeriodHrs) - } - if c.RetentionCheckMins < 0 { - return fmtErrorf("retention_check_mins cannot be negative: %f", c.RetentionCheckMins) - } - if c.HeartbeatLevel < 0 || c.HeartbeatLevel > 3 { - return fmtErrorf("heartbeat_level must be between 0 and 3: %d", c.HeartbeatLevel) + return fmtErrorf("min_check_interval_ms (%d) cannot be greater than max_check_interval_ms (%d)", + c.MinCheckIntervalMs, c.MaxCheckIntervalMs) } + 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 fmtErrorf("heartbeat_interval_s must be positive when heartbeat is enabled: %d", + c.HeartbeatIntervalS) } return nil diff --git a/example/raw/main.go b/example/raw/main.go new file mode 100644 index 0000000..3a16604 --- /dev/null +++ b/example/raw/main.go @@ -0,0 +1,72 @@ +// FILE: example/raw/main.go +package main + +import ( + "fmt" + "time" + + "github.com/lixenwraith/log" +) + +// TestPayload defines a struct for testing complex type serialization. +type TestPayload struct { + RequestID uint64 + User string + Metrics map[string]float64 +} + +func main() { + fmt.Println("--- Logger Raw Format Test ---") + + // --- 1. Define the records to be tested --- + // Record 1: A byte slice with special characters (newline, tab, null). + byteRecord := []byte("binary\ndata\twith\x00null") + + // Record 2: A struct containing a uint64, a string, and a map. + structRecord := TestPayload{ + RequestID: 9223372036854775807, // A large uint64 + User: "test_user", + Metrics: map[string]float64{ + "latency_ms": 15.7, + "cpu_percent": 88.2, + }, + } + + // --- 2. Test on-demand raw logging using Logger.Write() --- + // This method produces raw output regardless of the global format setting. + fmt.Println("\n[1] Testing on-demand raw output via Logger.Write()") + logger1 := log.NewLogger() + // Use default config, but enable stdout and disable file output for this test. + err := logger1.InitWithDefaults("enable_stdout=true", "disable_file=false") + if err != nil { + fmt.Printf("Failed to initialize logger: %v\n", err) + return + } + + logger1.Write("Byte Record ->", byteRecord) + logger1.Write("Struct Record ->", structRecord) + // Wait briefly for the async processor to handle the logs. + time.Sleep(100 * time.Millisecond) + logger1.Shutdown() + + // --- 3. Test instance-wide raw logging using format="raw" --- + // Here, standard methods like Info() will produce raw output. + fmt.Println("\n[2] Testing instance-wide raw output via format=\"raw\"") + logger2 := log.NewLogger() + err = logger2.InitWithDefaults( + "enable_stdout=true", + "disable_file=false", + "format=raw", + ) + if err != nil { + fmt.Printf("Failed to initialize logger: %v\n", err) + return + } + + logger2.Info("Byte Record ->", byteRecord) + logger2.Info("Struct Record ->", structRecord) + time.Sleep(100 * time.Millisecond) + logger2.Shutdown() + + fmt.Println("\n--- Test Complete ---") +} diff --git a/format.go b/format.go index decbbc9..92932fa 100644 --- a/format.go +++ b/format.go @@ -2,10 +2,15 @@ package log import ( + "bytes" + "encoding/hex" "fmt" + "reflect" "strconv" "strings" "time" + + "github.com/davecgh/go-spew/spew" ) // serializer manages the buffered writing of log entries. @@ -27,16 +32,176 @@ func (s *serializer) reset() { s.buf = s.buf[:0] } -// serialize converts log entries to the configured format, JSON or (default) text. +// serialize converts log entries to the configured format, JSON, raw, or (default) text. func (s *serializer) serialize(format string, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte { s.reset() + // 1. Prioritize the on-demand flag from Write() + if flags&FlagRaw != 0 { + return s.serializeRaw(args) + } + + // 2. Handle the instance-wide configuration setting + if format == "raw" { + return s.serializeRaw(args) + } + if format == "json" { return s.serializeJSON(flags, timestamp, level, trace, args) } return s.serializeText(flags, timestamp, level, trace, args) } +// serializeRaw formats args as space-separated strings without metadata or newline. +// This is used for both format="raw" configuration and Logger.Write() calls. +func (s *serializer) serializeRaw(args []any) []byte { + needsSpace := false + + for _, arg := range args { + if needsSpace { + s.buf = append(s.buf, ' ') + } + s.writeRawValue(arg) + needsSpace = true + } + + // No newline appended for raw format + return s.buf +} + +// writeRawValue converts any value to its raw string representation. +// fallback to go-spew/spew with data structure information for types that are not explicitly supported. +func (s *serializer) writeRawValue(v any) { + switch val := v.(type) { + case string: + s.buf = append(s.buf, val...) + case int: + s.buf = strconv.AppendInt(s.buf, int64(val), 10) + case int64: + s.buf = strconv.AppendInt(s.buf, val, 10) + case uint: + s.buf = strconv.AppendUint(s.buf, uint64(val), 10) + case uint64: + s.buf = strconv.AppendUint(s.buf, val, 10) + case float32: + s.buf = strconv.AppendFloat(s.buf, float64(val), 'f', -1, 32) + case float64: + s.buf = strconv.AppendFloat(s.buf, val, 'f', -1, 64) + case bool: + s.buf = strconv.AppendBool(s.buf, val) + case nil: + s.buf = append(s.buf, "nil"...) + case time.Time: + s.buf = val.AppendFormat(s.buf, s.timestampFormat) + case error: + s.buf = append(s.buf, val.Error()...) + case fmt.Stringer: + s.buf = append(s.buf, val.String()...) + case []byte: + s.buf = hex.AppendEncode(s.buf, val) // prevent special character corruption + default: + // For all other types (structs, maps, pointers, arrays, etc.), delegate to spew. + // It is not the intended use of raw logging. + // The output of such cases are structured and have type and size information set by spew. + // Converting to string similar to non-raw logs is not used to avoid binary log corruption. + var b bytes.Buffer + + // Use a custom dumper for log-friendly, compact output. + dumper := &spew.ConfigState{ + Indent: " ", + MaxDepth: 10, + DisablePointerAddresses: true, // Cleaner for logs + DisableCapacities: true, // Less noise + SortKeys: true, // Consistent map output + } + + dumper.Fdump(&b, val) + + // Trim trailing new line added by spew + s.buf = append(s.buf, bytes.TrimSpace(b.Bytes())...) + } +} + +// This is the safe, dependency-free replacement for fmt.Sprintf. +func (s *serializer) reflectValue(v reflect.Value) { + // Safely handle invalid, nil pointer, or nil interface values. + if !v.IsValid() { + s.buf = append(s.buf, "nil"...) + return + } + // Dereference pointers and interfaces to get the concrete value. + // Recurse to handle multiple levels of pointers. + kind := v.Kind() + if kind == reflect.Ptr || kind == reflect.Interface { + if v.IsNil() { + s.buf = append(s.buf, "nil"...) + return + } + s.reflectValue(v.Elem()) + return + } + + switch kind { + case reflect.String: + s.buf = append(s.buf, v.String()...) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + s.buf = strconv.AppendInt(s.buf, v.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + s.buf = strconv.AppendUint(s.buf, v.Uint(), 10) + case reflect.Float32, reflect.Float64: + s.buf = strconv.AppendFloat(s.buf, v.Float(), 'f', -1, 64) + case reflect.Bool: + s.buf = strconv.AppendBool(s.buf, v.Bool()) + + case reflect.Slice, reflect.Array: + // Check if it's a byte slice ([]uint8) and hex-encode it for safety. + if v.Type().Elem().Kind() == reflect.Uint8 { + s.buf = append(s.buf, "0x"...) + s.buf = hex.AppendEncode(s.buf, v.Bytes()) + return + } + s.buf = append(s.buf, '[') + for i := 0; i < v.Len(); i++ { + if i > 0 { + s.buf = append(s.buf, ' ') + } + s.reflectValue(v.Index(i)) + } + s.buf = append(s.buf, ']') + + case reflect.Struct: + s.buf = append(s.buf, '{') + for i := 0; i < v.NumField(); i++ { + if !v.Type().Field(i).IsExported() { + continue // Skip unexported fields + } + if i > 0 { + s.buf = append(s.buf, ' ') + } + s.buf = append(s.buf, v.Type().Field(i).Name...) + s.buf = append(s.buf, ':') + s.reflectValue(v.Field(i)) + } + s.buf = append(s.buf, '}') + + case reflect.Map: + s.buf = append(s.buf, '{') + for i, key := range v.MapKeys() { + if i > 0 { + s.buf = append(s.buf, ' ') + } + s.reflectValue(key) + s.buf = append(s.buf, ':') + s.reflectValue(v.MapIndex(key)) + } + s.buf = append(s.buf, '}') + + default: + // As a final fallback, use fmt, but this should rarely be hit. + s.buf = append(s.buf, fmt.Sprint(v.Interface())...) + } +} + // serializeJSON formats log entries as JSON (time, level, trace, fields). func (s *serializer) serializeJSON(flags int64, timestamp time.Time, level int64, trace string, args []any) []byte { s.buf = append(s.buf, '{') diff --git a/go.mod b/go.mod index 5a78d04..ea44a02 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/lixenwraith/log go 1.24.5 require ( + github.com/davecgh/go-spew v1.1.1 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 acf3114..2d0a412 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 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= diff --git a/interface.go b/interface.go index 50d1bc1..47a34b5 100644 --- a/interface.go +++ b/interface.go @@ -22,8 +22,9 @@ const ( // Record flags for controlling output structure const ( - FlagShowTimestamp int64 = 0b01 - FlagShowLevel int64 = 0b10 + FlagShowTimestamp int64 = 0b001 + FlagShowLevel int64 = 0b010 + FlagRaw int64 = 0b100 FlagDefault = FlagShowTimestamp | FlagShowLevel ) @@ -104,4 +105,11 @@ func (l *Logger) Message(args ...any) { // LogTrace writes a timestamp record with call trace but no level info. func (l *Logger) LogTrace(depth int, args ...any) { l.log(FlagShowTimestamp, LevelInfo, int64(depth), args...) +} + +// Write outputs raw, unformatted data regardless of configured format. +// This method bypasses all formatting (timestamps, levels, JSON structure) +// and writes args as space-separated strings without a trailing newline. +func (l *Logger) Write(args ...any) { + l.log(FlagRaw, LevelInfo, 0, args...) } \ No newline at end of file diff --git a/utility.go b/utility.go index dd8fe75..d93f2b4 100644 --- a/utility.go +++ b/utility.go @@ -92,51 +92,6 @@ func parseKeyValue(arg string) (string, string, error) { return key, value, nil } -// validateConfigValue checks ranges and specific constraints for parsed config values. -func validateConfigValue(key string, value interface{}) error { - keyLower := strings.ToLower(key) - - switch keyLower { - case "name": - if v, ok := value.(string); ok && strings.TrimSpace(v) == "" { - return fmtErrorf("log name cannot be empty") - } - case "format": - if v, ok := value.(string); ok && v != "txt" && v != "json" { - return fmtErrorf("invalid format: '%s' (use txt or json)", v) - } - case "extension": - 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) - } - case "max_size_mb", "max_total_size_mb", "min_disk_free_mb": - if v, ok := value.(int64); ok && v < 0 { - return fmtErrorf("%s cannot be negative: %d", key, v) - } - case "flush_timer", "disk_check_interval_ms", "min_check_interval_ms", "max_check_interval_ms": - if v, ok := value.(int64); ok && v <= 0 { - return fmtErrorf("%s must be positive milliseconds: %d", key, v) - } - case "trace_depth": - if v, ok := value.(int64); ok && (v < 0 || v > 10) { - return fmtErrorf("trace_depth must be between 0 and 10: %d", v) - } - case "retention_period", "retention_check_interval": - if v, ok := value.(float64); ok && v < 0 { - return fmtErrorf("%s cannot be negative: %f", key, v) - } - } - return nil -} - // Level converts level string to numeric constant. func Level(levelStr string) (int64, error) { switch strings.ToLower(strings.TrimSpace(levelStr)) { @@ -157,4 +112,135 @@ func Level(levelStr string) (int64, error) { default: return 0, fmtErrorf("invalid level string: '%s' (use debug, info, warn, error, proc, disk, sys)", levelStr) } +} + +// validateConfigValue validates a single configuration field +func validateConfigValue(key string, value any) error { + keyLower := strings.ToLower(key) + switch keyLower { + case "name": + v, ok := value.(string) + if !ok { + return fmtErrorf("name must be string, got %T", value) + } + if strings.TrimSpace(v) == "" { + return fmtErrorf("log name cannot be empty") + } + + case "format": + v, ok := value.(string) + if !ok { + return fmtErrorf("format must be string, got %T", value) + } + if v != "txt" && v != "json" && v != "raw" { + return fmtErrorf("invalid format: '%s' (use txt, json, or raw)", v) + } + + case "extension": + v, ok := value.(string) + if !ok { + return fmtErrorf("extension must be string, got %T", value) + } + if strings.HasPrefix(v, ".") { + return fmtErrorf("extension should not start with dot: %s", v) + } + + case "timestamp_format": + v, ok := value.(string) + if !ok { + return fmtErrorf("timestamp_format must be string, got %T", value) + } + if strings.TrimSpace(v) == "" { + return fmtErrorf("timestamp_format cannot be empty") + } + + case "buffer_size": + v, ok := value.(int64) + if !ok { + return fmtErrorf("buffer_size must be int64, got %T", value) + } + if v <= 0 { + return fmtErrorf("buffer_size must be positive: %d", v) + } + + case "max_size_mb", "max_total_size_mb", "min_disk_free_mb": + v, ok := value.(int64) + if !ok { + return fmtErrorf("%s must be int64, got %T", key, value) + } + if v < 0 { + return fmtErrorf("%s cannot be negative: %d", key, v) + } + + case "flush_interval_ms", "disk_check_interval_ms", "min_check_interval_ms", "max_check_interval_ms": + v, ok := value.(int64) + if !ok { + return fmtErrorf("%s must be int64, got %T", key, value) + } + if v <= 0 { + return fmtErrorf("%s must be positive milliseconds: %d", key, v) + } + + case "trace_depth": + v, ok := value.(int64) + if !ok { + return fmtErrorf("trace_depth must be int64, got %T", value) + } + if v < 0 || v > 10 { + return fmtErrorf("trace_depth must be between 0 and 10: %d", v) + } + + case "retention_period_hrs", "retention_check_mins": + v, ok := value.(float64) + if !ok { + return fmtErrorf("%s must be float64, got %T", key, value) + } + if v < 0 { + return fmtErrorf("%s cannot be negative: %f", key, v) + } + + case "heartbeat_level": + v, ok := value.(int64) + if !ok { + return fmtErrorf("heartbeat_level must be int64, got %T", value) + } + if v < 0 || v > 3 { + return fmtErrorf("heartbeat_level must be between 0 and 3: %d", v) + } + + case "heartbeat_interval_s": + _, ok := value.(int64) + if !ok { + return fmtErrorf("heartbeat_interval_s must be int64, got %T", value) + } + // Note: only validate positive if heartbeat is enabled (cross-field validation) + + case "stdout_target": + v, ok := value.(string) + if !ok { + return fmtErrorf("stdout_target must be string, got %T", value) + } + if v != "stdout" && v != "stderr" { + return fmtErrorf("invalid stdout_target: '%s' (use stdout or stderr)", v) + } + + case "level": + // Level validation if needed + _, ok := value.(int64) + if !ok { + return fmtErrorf("level must be int64, got %T", value) + } + + // Fields that don't need validation beyond type + case "directory", "show_timestamp", "show_level", "enable_adaptive_interval", + "enable_periodic_sync", "enable_stdout", "disable_file", "internal_errors_to_stderr": + // Type checking handled by config system + return nil + + default: + // Unknown field - let config system handle it + return nil + } + + return nil } \ No newline at end of file