Skip to content

Commit 5a251b4

Browse files
authored
Merge pull request #1442 from afjcjsbx/feat/logger-stdout-formatting
feat(logger): Custom console formatter for JSON and multiline strings
2 parents 5fb4b3b + 78c9b86 commit 5a251b4

File tree

2 files changed

+169
-7
lines changed

2 files changed

+169
-7
lines changed

pkg/logger/logger.go

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"path/filepath"
77
"runtime"
8+
"strconv"
89
"strings"
910
"sync"
1011

@@ -45,13 +46,47 @@ func init() {
4546
consoleWriter := zerolog.ConsoleWriter{
4647
Out: os.Stdout,
4748
TimeFormat: "15:04:05", // TODO: make it configurable???
49+
50+
// Custom formatter to handle multiline strings and JSON objects
51+
FormatFieldValue: formatFieldValue,
4852
}
4953

5054
logger = zerolog.New(consoleWriter).With().Timestamp().Logger()
5155
fileLogger = zerolog.Logger{}
5256
})
5357
}
5458

59+
func formatFieldValue(i any) string {
60+
var s string
61+
62+
switch val := i.(type) {
63+
case string:
64+
s = val
65+
case []byte:
66+
s = string(val)
67+
default:
68+
return fmt.Sprintf("%v", i)
69+
}
70+
71+
if unquoted, err := strconv.Unquote(s); err == nil {
72+
s = unquoted
73+
}
74+
75+
if strings.Contains(s, "\n") {
76+
return fmt.Sprintf("\n%s", s)
77+
}
78+
79+
if strings.Contains(s, " ") {
80+
if (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) ||
81+
(strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) {
82+
return s
83+
}
84+
return fmt.Sprintf("%q", s)
85+
}
86+
87+
return s
88+
}
89+
5590
func SetLevel(level LogLevel) {
5691
mu.Lock()
5792
defer mu.Unlock()
@@ -163,10 +198,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str
163198
event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
164199
}
165200

166-
for k, v := range fields {
167-
event.Interface(k, v)
168-
}
169-
201+
appendFields(event, fields)
170202
event.Msg(message)
171203

172204
// Also log to file if enabled
@@ -176,9 +208,8 @@ func logMessage(level LogLevel, component string, message string, fields map[str
176208
if component != "" {
177209
fileEvent.Str("component", component)
178210
}
179-
for k, v := range fields {
180-
fileEvent.Interface(k, v)
181-
}
211+
212+
appendFields(event, fields)
182213
fileEvent.Msg(message)
183214
}
184215

@@ -187,6 +218,26 @@ func logMessage(level LogLevel, component string, message string, fields map[str
187218
}
188219
}
189220

221+
func appendFields(event *zerolog.Event, fields map[string]any) {
222+
for k, v := range fields {
223+
// Type switch to avoid double JSON serialization of strings
224+
switch val := v.(type) {
225+
case string:
226+
event.Str(k, val)
227+
case int:
228+
event.Int(k, val)
229+
case int64:
230+
event.Int64(k, val)
231+
case float64:
232+
event.Float64(k, val)
233+
case bool:
234+
event.Bool(k, val)
235+
default:
236+
event.Interface(k, v) // Fallback for struct, slice and maps
237+
}
238+
}
239+
}
240+
190241
func Debug(message string) {
191242
logMessage(DEBUG, "", message, nil)
192243
}

pkg/logger/logger_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,114 @@ func TestLoggerHelperFunctions(t *testing.T) {
141141
Debugf("test from %v", "Debugf")
142142
WarnF("Warning with fields", map[string]any{"key": "value"})
143143
}
144+
145+
func TestFormatFieldValue(t *testing.T) {
146+
tests := []struct {
147+
name string
148+
input any
149+
expected string
150+
}{
151+
// Basic types test (default case of the switch)
152+
{
153+
name: "Integer Type",
154+
input: 42,
155+
expected: "42",
156+
},
157+
{
158+
name: "Boolean Type",
159+
input: true,
160+
expected: "true",
161+
},
162+
{
163+
name: "Unsupported Struct Type",
164+
input: struct{ A int }{A: 1},
165+
expected: "{1}",
166+
},
167+
168+
// Simple strings and byte slices test
169+
{
170+
name: "Simple string without spaces",
171+
input: "simple_value",
172+
expected: "simple_value",
173+
},
174+
{
175+
name: "Simple byte slice",
176+
input: []byte("byte_value"),
177+
expected: "byte_value",
178+
},
179+
180+
// Unquoting test (strconv.Unquote)
181+
{
182+
name: "Quoted string",
183+
input: `"quoted_value"`,
184+
expected: "quoted_value",
185+
},
186+
187+
// Strings with newline (\n) test
188+
{
189+
name: "String with newline",
190+
input: "line1\nline2",
191+
expected: "\nline1\nline2",
192+
},
193+
{
194+
name: "Quoted string with newline (Unquote -> newline)",
195+
input: `"line1\nline2"`, // Escaped \n that Unquote will resolve
196+
expected: "\nline1\nline2",
197+
},
198+
199+
// Strings with spaces test (which should be quoted)
200+
{
201+
name: "String with spaces",
202+
input: "hello world",
203+
expected: `"hello world"`,
204+
},
205+
{
206+
name: "Quoted string with spaces (Unquote -> has spaces -> Re-quote)",
207+
input: `"hello world"`,
208+
expected: `"hello world"`,
209+
},
210+
211+
// JSON formats test (strings with spaces that start/end with brackets)
212+
{
213+
name: "Valid JSON object",
214+
input: `{"key": "value"}`,
215+
expected: `{"key": "value"}`,
216+
},
217+
{
218+
name: "Valid JSON array",
219+
input: `[1, 2, "three"]`,
220+
expected: `[1, 2, "three"]`,
221+
},
222+
{
223+
name: "Fake JSON (starts with { but doesn't end with })",
224+
input: `{"key": "value"`, // Missing closing bracket, has spaces
225+
expected: `"{\"key\": \"value\""`,
226+
},
227+
{
228+
name: "Empty JSON (object)",
229+
input: `{ }`,
230+
expected: `{ }`,
231+
},
232+
233+
// 7. Edge Cases
234+
{
235+
name: "Empty string",
236+
input: "",
237+
expected: "",
238+
},
239+
{
240+
name: "Whitespace only string",
241+
input: " ",
242+
expected: `" "`,
243+
},
244+
}
245+
246+
for _, tt := range tests {
247+
t.Run(tt.name, func(t *testing.T) {
248+
actual := formatFieldValue(tt.input)
249+
if actual != tt.expected {
250+
t.Errorf("formatFieldValue() = %q, expected %q", actual, tt.expected)
251+
}
252+
})
253+
}
254+
}

0 commit comments

Comments
 (0)