Merge pull request #1442 from afjcjsbx/feat/logger-stdout-formatting
feat(logger): Custom console formatter for JSON and multiline strings
This commit is contained in:
commit
5a251b46af
2 changed files with 169 additions and 7 deletions
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
|
@ -45,6 +46,9 @@ func init() {
|
||||||
consoleWriter := zerolog.ConsoleWriter{
|
consoleWriter := zerolog.ConsoleWriter{
|
||||||
Out: os.Stdout,
|
Out: os.Stdout,
|
||||||
TimeFormat: "15:04:05", // TODO: make it configurable???
|
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()
|
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) {
|
func SetLevel(level LogLevel) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
@ -163,10 +198,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
|
event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range fields {
|
appendFields(event, fields)
|
||||||
event.Interface(k, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
event.Msg(message)
|
event.Msg(message)
|
||||||
|
|
||||||
// Also log to file if enabled
|
// Also log to file if enabled
|
||||||
|
|
@ -176,9 +208,8 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
if component != "" {
|
if component != "" {
|
||||||
fileEvent.Str("component", component)
|
fileEvent.Str("component", component)
|
||||||
}
|
}
|
||||||
for k, v := range fields {
|
|
||||||
fileEvent.Interface(k, v)
|
appendFields(event, fields)
|
||||||
}
|
|
||||||
fileEvent.Msg(message)
|
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) {
|
func Debug(message string) {
|
||||||
logMessage(DEBUG, "", message, nil)
|
logMessage(DEBUG, "", message, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,3 +141,114 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
||||||
Debugf("test from %v", "Debugf")
|
Debugf("test from %v", "Debugf")
|
||||||
WarnF("Warning with fields", map[string]any{"key": "value"})
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue