e1.8.0 Raw (unformatted) logging support added.

This commit is contained in:
2025-07-14 18:19:10 -04:00
parent 2e410be060
commit 146141a38e
7 changed files with 413 additions and 99 deletions

View File

@ -2,7 +2,6 @@
package log package log
import ( import (
"strings"
"time" "time"
) )
@ -107,59 +106,44 @@ func DefaultConfig() *Config {
// validate performs basic sanity checks on the configuration values. // validate performs basic sanity checks on the configuration values.
func (c *Config) validate() error { func (c *Config) validate() error {
if strings.TrimSpace(c.Name) == "" { // Individual field validations
return fmtErrorf("log name cannot be empty") 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) for key, value := range fields {
if err := validateConfigValue(key, value); err != nil {
return err
} }
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)
} }
// Cross-field validations
if c.MinCheckIntervalMs > c.MaxCheckIntervalMs { 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) 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)
} }
if c.HeartbeatLevel > 0 && c.HeartbeatIntervalS <= 0 { if c.HeartbeatLevel > 0 && c.HeartbeatIntervalS <= 0 {
return fmtErrorf("heartbeat_interval_s must be positive when heartbeat is enabled: %d", c.HeartbeatIntervalS) return fmtErrorf("heartbeat_interval_s must be positive when heartbeat is enabled: %d",
} c.HeartbeatIntervalS)
if c.StdoutTarget != "stdout" && c.StdoutTarget != "stderr" {
return fmtErrorf("invalid stdout_target: '%s' (use stdout or stderr)", c.StdoutTarget)
} }
return nil return nil

72
example/raw/main.go Normal file
View File

@ -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 ---")
}

167
format.go
View File

@ -2,10 +2,15 @@
package log package log
import ( import (
"bytes"
"encoding/hex"
"fmt" "fmt"
"reflect"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/davecgh/go-spew/spew"
) )
// serializer manages the buffered writing of log entries. // serializer manages the buffered writing of log entries.
@ -27,16 +32,176 @@ func (s *serializer) reset() {
s.buf = s.buf[:0] 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 { func (s *serializer) serialize(format string, flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
s.reset() 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" { if format == "json" {
return s.serializeJSON(flags, timestamp, level, trace, args) return s.serializeJSON(flags, timestamp, level, trace, args)
} }
return s.serializeText(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). // 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 { func (s *serializer) serializeJSON(flags int64, timestamp time.Time, level int64, trace string, args []any) []byte {
s.buf = append(s.buf, '{') s.buf = append(s.buf, '{')

1
go.mod
View File

@ -3,6 +3,7 @@ module github.com/lixenwraith/log
go 1.24.5 go 1.24.5
require ( require (
github.com/davecgh/go-spew v1.1.1
github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497 github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497
github.com/panjf2000/gnet/v2 v2.9.1 github.com/panjf2000/gnet/v2 v2.9.1
github.com/valyala/fasthttp v1.63.0 github.com/valyala/fasthttp v1.63.0

2
go.sum
View File

@ -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/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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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 h1:ixTIdJSd945n/IhMRwGwQVmQnQ1nUr5z1wn31jXq9FU=
github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497/go.mod h1:y7kgDrWIFROWJJ6ASM/SPTRRAj27FjRGWh2SDLcdQ68= 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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=

View File

@ -22,8 +22,9 @@ const (
// Record flags for controlling output structure // Record flags for controlling output structure
const ( const (
FlagShowTimestamp int64 = 0b01 FlagShowTimestamp int64 = 0b001
FlagShowLevel int64 = 0b10 FlagShowLevel int64 = 0b010
FlagRaw int64 = 0b100
FlagDefault = FlagShowTimestamp | FlagShowLevel FlagDefault = FlagShowTimestamp | FlagShowLevel
) )
@ -105,3 +106,10 @@ func (l *Logger) Message(args ...any) {
func (l *Logger) LogTrace(depth int, args ...any) { func (l *Logger) LogTrace(depth int, args ...any) {
l.log(FlagShowTimestamp, LevelInfo, int64(depth), args...) 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...)
}

View File

@ -92,51 +92,6 @@ func parseKeyValue(arg string) (string, string, error) {
return key, value, nil 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. // Level converts level string to numeric constant.
func Level(levelStr string) (int64, error) { func Level(levelStr string) (int64, error) {
switch strings.ToLower(strings.TrimSpace(levelStr)) { switch strings.ToLower(strings.TrimSpace(levelStr)) {
@ -158,3 +113,134 @@ func Level(levelStr string) (int64, error) {
return 0, fmtErrorf("invalid level string: '%s' (use debug, info, warn, error, proc, disk, sys)", levelStr) 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
}