diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 302613f33..4204cc192 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "sync" @@ -45,6 +46,9 @@ func init() { consoleWriter := zerolog.ConsoleWriter{ Out: os.Stdout, TimeFormat: "15:04:05", // TODO: make it configurable??? + + // Custom formatter to handle multiline strings and JSON objects + FormatFieldValue: formatFieldValue, } logger = zerolog.New(consoleWriter).With().Timestamp().Logger() @@ -52,6 +56,37 @@ func init() { }) } +func formatFieldValue(i any) string { + var s string + + switch val := i.(type) { + case string: + s = val + case []byte: + s = string(val) + default: + return fmt.Sprintf("%v", i) + } + + if unquoted, err := strconv.Unquote(s); err == nil { + s = unquoted + } + + if strings.Contains(s, "\n") { + return fmt.Sprintf("\n%s", s) + } + + if strings.Contains(s, " ") { + if (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) || + (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) { + return s + } + return fmt.Sprintf("%q", s) + } + + return s +} + func SetLevel(level LogLevel) { mu.Lock() defer mu.Unlock() @@ -163,10 +198,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str event.Str("caller", fmt.Sprintf(" %s:%d (%s)", callerFile, callerLine, callerFunc)) } - for k, v := range fields { - event.Interface(k, v) - } - + appendFields(event, fields) event.Msg(message) // Also log to file if enabled @@ -176,9 +208,8 @@ func logMessage(level LogLevel, component string, message string, fields map[str if component != "" { fileEvent.Str("component", component) } - for k, v := range fields { - fileEvent.Interface(k, v) - } + + appendFields(event, fields) fileEvent.Msg(message) } @@ -187,6 +218,26 @@ func logMessage(level LogLevel, component string, message string, fields map[str } } +func appendFields(event *zerolog.Event, fields map[string]any) { + for k, v := range fields { + // Type switch to avoid double JSON serialization of strings + switch val := v.(type) { + case string: + event.Str(k, val) + case int: + event.Int(k, val) + case int64: + event.Int64(k, val) + case float64: + event.Float64(k, val) + case bool: + event.Bool(k, val) + default: + event.Interface(k, v) // Fallback for struct, slice and maps + } + } +} + func Debug(message string) { logMessage(DEBUG, "", message, nil) } diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 8170a618b..31b40484c 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -141,3 +141,114 @@ func TestLoggerHelperFunctions(t *testing.T) { Debugf("test from %v", "Debugf") WarnF("Warning with fields", map[string]any{"key": "value"}) } + +func TestFormatFieldValue(t *testing.T) { + tests := []struct { + name string + input any + expected string + }{ + // Basic types test (default case of the switch) + { + name: "Integer Type", + input: 42, + expected: "42", + }, + { + name: "Boolean Type", + input: true, + expected: "true", + }, + { + name: "Unsupported Struct Type", + input: struct{ A int }{A: 1}, + expected: "{1}", + }, + + // Simple strings and byte slices test + { + name: "Simple string without spaces", + input: "simple_value", + expected: "simple_value", + }, + { + name: "Simple byte slice", + input: []byte("byte_value"), + expected: "byte_value", + }, + + // Unquoting test (strconv.Unquote) + { + name: "Quoted string", + input: `"quoted_value"`, + expected: "quoted_value", + }, + + // Strings with newline (\n) test + { + name: "String with newline", + input: "line1\nline2", + expected: "\nline1\nline2", + }, + { + name: "Quoted string with newline (Unquote -> newline)", + input: `"line1\nline2"`, // Escaped \n that Unquote will resolve + expected: "\nline1\nline2", + }, + + // Strings with spaces test (which should be quoted) + { + name: "String with spaces", + input: "hello world", + expected: `"hello world"`, + }, + { + name: "Quoted string with spaces (Unquote -> has spaces -> Re-quote)", + input: `"hello world"`, + expected: `"hello world"`, + }, + + // JSON formats test (strings with spaces that start/end with brackets) + { + name: "Valid JSON object", + input: `{"key": "value"}`, + expected: `{"key": "value"}`, + }, + { + name: "Valid JSON array", + input: `[1, 2, "three"]`, + expected: `[1, 2, "three"]`, + }, + { + name: "Fake JSON (starts with { but doesn't end with })", + input: `{"key": "value"`, // Missing closing bracket, has spaces + expected: `"{\"key\": \"value\""`, + }, + { + name: "Empty JSON (object)", + input: `{ }`, + expected: `{ }`, + }, + + // 7. Edge Cases + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Whitespace only string", + input: " ", + expected: `" "`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := formatFieldValue(tt.input) + if actual != tt.expected { + t.Errorf("formatFieldValue() = %q, expected %q", actual, tt.expected) + } + }) + } +}