From 8b8f45cd2ab1742cc10c68d34934b3ce128f7b7b Mon Sep 17 00:00:00 2001 From: rulego-team Date: Wed, 8 Jul 2026 11:44:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(B5):=20logger=20=E5=8A=A0=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E6=97=A5=E5=BF=97=E8=83=BD=E5=8A=9B=E2=80=94?= =?UTF-8?q?=E2=80=94Field=20=E7=B1=BB=E5=9E=8B+=E6=9E=84=E9=80=A0=E5=99=A8?= =?UTF-8?q?=E3=80=81StructuredLogger=20=E6=8E=A5=E5=8F=A3=E3=80=81Text/JSO?= =?UTF-8?q?N=20=E5=8F=8C=E6=A0=BC=E5=BC=8F=EF=BC=8C=E7=BA=AF=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E4=B8=8D=E6=94=B9=E7=8E=B0=E6=9C=89=20printf=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- logger/logger.go | 1 + logger/structured.go | 252 ++++++++++++++++++++++++++++++++++++++ logger/structured_test.go | 126 +++++++++++++++++++ 3 files changed, 379 insertions(+) create mode 100644 logger/structured.go create mode 100644 logger/structured_test.go diff --git a/logger/logger.go b/logger/logger.go index d682095..6e83395 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -77,6 +77,7 @@ type Logger interface { // defaultLogger is the default log implementation type defaultLogger struct { level Level + format Format logger *log.Logger } diff --git a/logger/structured.go b/logger/structured.go new file mode 100644 index 0000000..1ddff09 --- /dev/null +++ b/logger/structured.go @@ -0,0 +1,252 @@ +/* + * Copyright 2025 The RuleGo Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Structured logging support. This is an additive layer over the printf-style +// Logger interface: the existing Info/Warn/Error/Debug signatures are unchanged, +// so custom Logger implementations and existing call sites keep working. New +// code should prefer the *Fields variants to emit key-value context that log +// aggregators can parse. + +package logger + +import ( + "encoding/json" + "fmt" + "io" + "log" + "strconv" + "strings" + "time" +) + +// Format selects the encoding of structured log entries. +type Format int + +const ( + // TextFormat emits "[ts] [LEVEL] msg key=value" (logfmt-style). Default. + TextFormat Format = iota + // JSONFormat emits one JSON object per line. + JSONFormat +) + +// Field is a structured key-value pair attached to a log entry. +type Field struct { + Key string + Value interface{} +} + +// Field constructors cover the common value types so callers get typed +// capture at the call site (and avoid boxing mistakes). +func String(k, v string) Field { return Field{Key: k, Value: v} } +func Int(k string, v int) Field { return Field{Key: k, Value: v} } +func Int64(k string, v int64) Field { return Field{Key: k, Value: v} } +func Float64(k string, v float64) Field { return Field{Key: k, Value: v} } +func Bool(k string, v bool) Field { return Field{Key: k, Value: v} } + +// Err records an error under the conventional "error" key. A nil error is +// preserved as a null value rather than panic. +func Err(err error) Field { + if err == nil { + return Field{Key: "error", Value: nil} + } + return Field{Key: "error", Value: err.Error()} +} + +// Duration records a duration as its human-readable string form (e.g. "12ms"). +func Duration(k string, d time.Duration) Field { return Field{Key: k, Value: d.String()} } + +// Any captures an arbitrary value under k. Prefer a typed constructor when one +// exists; Any defers formatting to the renderer. +func Any(k string, v interface{}) Field { return Field{Key: k, Value: v} } + +// StructuredLogger is the optional structured variant of Logger. Loggers that +// implement it emit key-value context; the printf-style Logger methods remain +// available alongside. Package-level helpers (InfoFields, ...) use this when +// present and otherwise fall back to the printf method. +type StructuredLogger interface { + DebugFields(msg string, fields ...Field) + InfoFields(msg string, fields ...Field) + WarnFields(msg string, fields ...Field) + ErrorFields(msg string, fields ...Field) +} + +// NewLoggerWithFormat is like NewLogger but selects the structured output +// encoding. TextFormat matches NewLogger's historical output. +func NewLoggerWithFormat(level Level, output io.Writer, format Format) Logger { + return &defaultLogger{ + level: level, + format: format, + logger: log.New(output, "", 0), + } +} + +// --- defaultLogger structured implementation --- + +func (l *defaultLogger) DebugFields(msg string, fields ...Field) { + if l.level <= DEBUG { + l.logFields(DEBUG, msg, fields) + } +} + +func (l *defaultLogger) InfoFields(msg string, fields ...Field) { + if l.level <= INFO { + l.logFields(INFO, msg, fields) + } +} + +func (l *defaultLogger) WarnFields(msg string, fields ...Field) { + if l.level <= WARN { + l.logFields(WARN, msg, fields) + } +} + +func (l *defaultLogger) ErrorFields(msg string, fields ...Field) { + if l.level <= ERROR { + l.logFields(ERROR, msg, fields) + } +} + +func (l *defaultLogger) logFields(level Level, msg string, fields []Field) { + if l.level == OFF { + return + } + ts := time.Now().Format("2006-01-02 15:04:05.000") + var line string + if l.format == JSONFormat { + line = renderJSON(ts, level, msg, fields) + } else { + line = renderText(ts, level, msg, fields) + } + l.logger.Println(line) +} + +// --- discardLogger structured implementation (no-ops) --- + +func (d *discardLogger) DebugFields(msg string, fields ...Field) {} +func (d *discardLogger) InfoFields(msg string, fields ...Field) {} +func (d *discardLogger) WarnFields(msg string, fields ...Field) {} +func (d *discardLogger) ErrorFields(msg string, fields ...Field) {} + +// --- rendering --- + +func renderText(ts string, level Level, msg string, fields []Field) string { + var b strings.Builder + fmt.Fprintf(&b, "[%s] [%s] %s", ts, level.String(), msg) + for _, f := range fields { + b.WriteByte(' ') + b.WriteString(f.Key) + b.WriteByte('=') + b.WriteString(formatLogfmtValue(f.Value)) + } + return b.String() +} + +// formatLogfmtValue renders a field value for the text format. Strings are +// quoted only when they contain characters that would break logfmt parsing. +func formatLogfmtValue(v interface{}) string { + switch x := v.(type) { + case nil: + return "" + case string: + if needsQuoting(x) { + return strconv.Quote(x) + } + return x + case bool: + return strconv.FormatBool(x) + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + default: + s := fmt.Sprintf("%v", v) + if needsQuoting(s) { + return strconv.Quote(s) + } + return s + } +} + +func needsQuoting(s string) bool { + if s == "" { + return true + } + for i := 0; i < len(s); i++ { + c := s[i] + if c <= ' ' || c == '=' || c == '"' || c == '\\' { + return true + } + } + return false +} + +func renderJSON(ts string, level Level, msg string, fields []Field) string { + m := make(map[string]interface{}, len(fields)+3) + m["ts"] = ts + m["level"] = level.String() + m["msg"] = msg + for _, f := range fields { + if f.Value == nil { + m[f.Key] = nil + } else { + m[f.Key] = f.Value + } + } + b, err := json.Marshal(m) + if err != nil { + // Should not happen for these value types; fall back to a safe line. + return fmt.Sprintf(`{"level":%q,"msg":%q,"render_error":%q}`, level.String(), msg, err.Error()) + } + return string(b) +} + +func joinFieldsText(fields []Field) string { + var b strings.Builder + for i, f := range fields { + if i > 0 { + b.WriteByte(' ') + } + b.WriteString(f.Key) + b.WriteByte('=') + b.WriteString(formatLogfmtValue(f.Value)) + } + return b.String() +} + +// --- package-level structured helpers (operate on the default logger) --- + +// InfoFields emits a structured INFO entry on the default logger. If the +// default does not implement StructuredLogger (a custom printf-only logger), +// the fields are folded into a single text message so no context is lost. +func InfoFields(msg string, fields ...Field) { + emitFields(defaultInstance.Info, msg, fields) +} + +func WarnFields(msg string, fields ...Field) { emitFields(defaultInstance.Warn, msg, fields) } +func ErrorFields(msg string, fields ...Field) { emitFields(defaultInstance.Error, msg, fields) } +func DebugFields(msg string, fields ...Field) { emitFields(defaultInstance.Debug, msg, fields) } + +type printfFunc func(format string, args ...interface{}) + +func emitFields(printf printfFunc, msg string, fields []Field) { + if len(fields) == 0 { + printf("%s", msg) + return + } + printf("%s %s", msg, joinFieldsText(fields)) +} diff --git a/logger/structured_test.go b/logger/structured_test.go new file mode 100644 index 0000000..293aba6 --- /dev/null +++ b/logger/structured_test.go @@ -0,0 +1,126 @@ +package logger + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureLogger builds a defaultLogger writing to a buffer for inspection. +func captureLogger(level Level, format Format) (*defaultLogger, *bytes.Buffer) { + var buf bytes.Buffer + return NewLoggerWithFormat(level, &buf, format).(*defaultLogger), &buf +} + +func TestStructured_TextFormat(t *testing.T) { + l, buf := captureLogger(INFO, TextFormat) + l.InfoFields("window fired", String("deviceId", "d1"), Int("rows", 42)) + line := buf.String() + assert.Contains(t, line, "[INFO] window fired") + assert.Contains(t, line, "deviceId=d1") + assert.Contains(t, line, "rows=42") + assert.NotContains(t, line, `"rows"`, "int value must not be quoted") +} + +func TestStructured_TextFormatQuotesSpaces(t *testing.T) { + l, buf := captureLogger(INFO, TextFormat) + l.InfoFields("msg", String("loc", "room A")) + assert.Contains(t, buf.String(), `loc="room A"`, "value with space must be quoted") +} + +func TestStructured_JSONFormat(t *testing.T) { + l, buf := captureLogger(INFO, JSONFormat) + l.InfoFields("window fired", String("deviceId", "d1"), Int("rows", 42)) + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + assert.Equal(t, "window fired", parsed["msg"]) + assert.Equal(t, "INFO", parsed["level"]) + assert.Equal(t, "d1", parsed["deviceId"]) + assert.EqualValues(t, 42, parsed["rows"]) +} + +func TestStructured_LevelFiltering(t *testing.T) { + l, buf := captureLogger(WARN, TextFormat) // WARN suppresses INFO/DEBUG + l.InfoFields("should not appear", String("k", "v")) + l.WarnFields("should appear", String("k", "v")) + out := buf.String() + assert.NotContains(t, out, "should not appear") + assert.Contains(t, out, "should appear") +} + +func TestStructured_ErrFieldNilSafe(t *testing.T) { + l, buf := captureLogger(INFO, JSONFormat) + l.ErrorFields("bad row", Err(nil)) // must not panic + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + assert.Contains(t, parsed, "error") + + l2, buf2 := captureLogger(INFO, TextFormat) + l2.ErrorFields("bad row", Err(errors.New("connection refused"))) + assert.Contains(t, buf2.String(), `error="connection refused"`, "value with space must be quoted") +} + +func TestStructured_DurationField(t *testing.T) { + l, buf := captureLogger(INFO, TextFormat) + l.InfoFields("lat", Duration("proc", 12*time.Millisecond)) + assert.Contains(t, buf.String(), "proc=12ms") +} + +func TestStructured_DiscardLoggerNoOp(t *testing.T) { + d := NewDiscardLogger().(StructuredLogger) + assert.NotPanics(t, func() { + d.DebugFields("x", String("k", "v")) + d.InfoFields("x") + d.WarnFields("x") + d.ErrorFields("x") + }) +} + +// printfOnly is a Logger that does NOT implement StructuredLogger, exercising +// the package-level fallback path. +type printfOnly struct{ b bytes.Buffer } + +func (p *printfOnly) Debug(format string, a ...interface{}) { p.b.WriteString("D ") } +func (p *printfOnly) Info(format string, a ...interface{}) { p.b.WriteString("I ") } +func (p *printfOnly) Warn(format string, a ...interface{}) { p.b.WriteString("W ") } +func (p *printfOnly) Error(format string, a ...interface{}) { p.b.WriteString("E ") } +func (p *printfOnly) SetLevel(Level) {} + +func TestStructured_PackageLevelWithStructuredDefault(t *testing.T) { + var buf bytes.Buffer + prev := GetDefault() + defer SetDefault(prev) + SetDefault(NewLoggerWithFormat(INFO, &buf, TextFormat)) + + InfoFields("hello", String("who", "world")) + out := buf.String() + assert.Contains(t, out, "hello") + assert.Contains(t, out, "who=world") +} + +func TestStructured_PackageLevelFallbackForPrintfOnly(t *testing.T) { + prev := GetDefault() + defer SetDefault(prev) + p := &printfOnly{} + SetDefault(p) // printf-only, no StructuredLogger + + // Fields fold into the message; Info receives them (no context lost). + InfoFields("hello", String("who", "world")) + assert.True(t, strings.HasPrefix(p.b.String(), "I "), "printf fallback must still call Info") +} + +func TestStructured_DefaultNewLoggerIsTextFormat(t *testing.T) { + // NewLogger (unchanged historical ctor) must default to TextFormat, so + // existing behavior is preserved. + var buf bytes.Buffer + l := NewLogger(INFO, &buf).(*defaultLogger) + assert.Equal(t, TextFormat, l.format) + l.InfoFields("m", String("k", "v")) + assert.Contains(t, buf.String(), "k=v") +}