From 9268e222ab817945dcffab143bd71ea4de1ae7f7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 20 Apr 2026 18:00:11 +0300 Subject: [PATCH] feat: add message replacement for log records Add Logger.AddReplacer to mask or normalize message text before records reach writers. Document replacement behavior in README and package GoDoc, and update the example. Also fix JSON writer edge cases: - avoid panic on empty message lists - preserve newline semantics when the last message already ends with n - keep original message argument types for custom writers when no replacers are configured Update dependencies and add release notes for the next release. --- README.md | 24 +++++++++++++++- README_ru.md | 23 +++++++++++++++- RELEASE_NOTES.md | 31 +++++++++++++++++++++ doc.go | 7 +++-- examples/main.go | 6 +++- go.mod | 6 ++-- go.sum | 6 ++++ io.go | 14 ++++++++-- logger.go | 72 +++++++++++++++++++++++++++++++++++++++++++----- logger_test.go | 58 +++++++++++++++++++++++++++++++++++++- writers.go | 8 ++---- 11 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 RELEASE_NOTES.md diff --git a/README.md b/README.md index d68afa3..c4fa4d7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Russian version: [README_ru.md](README_ru.md) - `stdout`, files, and arbitrary external `io.Writer` values. - Optional timestamps for text output. - Compact traceback metadata for text writers and full traceback slices for JSON. +- Message replacement for masking secrets or normalizing output. - Explicit ownership rules for writer closing. ## Installation @@ -35,7 +36,8 @@ func main() { Prefix("API"). Level(slog.DEBUG). PrintTraceback(true). - JsonPretty(true) + JsonPretty(true). + AddReplacer("SOME_SECRET", "") text := logger.CreateTextStdoutWriter() jsonFile, err := logger.CreateJsonFileWriter("logs/app.json") @@ -49,6 +51,7 @@ func main() { logger.Warnln("cache miss") logger.Errorln("request failed") logger.Debugln("debug details") + logger.Infoln("token", "SOME_SECRET") if err := logger.Close(); err != nil { log.Fatal(err) @@ -116,12 +119,31 @@ JSON writers emit objects with this shape: When `JsonPretty(true)` is enabled, JSON is indented. +## Message replacement + +`AddReplacer(old, new)` replaces matching text in every message before the +record reaches any writer. Replacement rules are applied in the order they are +added. + +```go +logger := slog.CreateLogger(). + Level(slog.DEBUG). + AddReplacer("SOME_SECRET", ""). + AddReplacer("user@example.com", "") + +logger.Infoln("login token:", "SOME_SECRET") +``` + +This writes `` instead of `SOME_SECRET` in both text and JSON output. +An empty `old` value is ignored. + ## API summary - `Info`, `Warn`, `Error`, `Debug`, and `Fatal` accept a list of values. - `Infof`, `Warnf`, `Errorf`, `Debugf`, and `Fatalf` use `fmt.Sprintf`. - The `*ln` methods preserve newline semantics, which is useful for `stdout`, Docker, and line-based collectors. - `Fatal`, `Fatalf`, and `Fatalln` call `os.Exit(1)` after writing the message. +- `AddReplacer` masks or rewrites message text before records are sent to writers. ## Traceback behavior diff --git a/README_ru.md b/README_ru.md index 7692425..9c44b67 100644 --- a/README_ru.md +++ b/README_ru.md @@ -11,6 +11,7 @@ English version: [README.md](README.md) - `stdout`, файлы и любые внешние `io.Writer`. - Опциональные timestamp'ы для текстового вывода. - Компактный traceback для текстовых writer'ов и полный traceback для JSON. +- Замена текста в сообщениях для маскирования секретов или нормализации вывода. - Явные правила владения writer'ами при `Close()`. ## Установка @@ -35,7 +36,8 @@ func main() { Prefix("API"). Level(slog.DEBUG). PrintTraceback(true). - JsonPretty(true) + JsonPretty(true). + AddReplacer("SOME_SECRET", "") text := logger.CreateTextStdoutWriter() jsonFile, err := logger.CreateJsonFileWriter("logs/app.json") @@ -49,6 +51,7 @@ func main() { logger.Warnln("cache miss") logger.Errorln("request failed") logger.Debugln("debug details") + logger.Infoln("token", "SOME_SECRET") if err := logger.Close(); err != nil { log.Fatal(err) @@ -116,12 +119,30 @@ JSON writer записывает объект со следующими поля Если включен `JsonPretty(true)`, JSON выводится с отступами. +## Замена сообщений + +`AddReplacer(old, new)` заменяет найденный текст в каждом сообщении до того, +как запись попадет в writer'ы. Правила замены применяются в порядке добавления. + +```go +logger := slog.CreateLogger(). + Level(slog.DEBUG). + AddReplacer("SOME_SECRET", ""). + AddReplacer("user@example.com", "") + +logger.Infoln("login token:", "SOME_SECRET") +``` + +В текстовом и JSON-выводе вместо `SOME_SECRET` будет записано ``. +Пустое значение `old` игнорируется. + ## API кратко - `Info`, `Warn`, `Error`, `Debug`, `Fatal` принимают список значений. - `Infof`, `Warnf`, `Errorf`, `Debugf`, `Fatalf` используют `fmt.Sprintf`. - Методы `*ln` добавляют семантику перевода строки, что удобно для `stdout`, Docker и line-based collectors. - `Fatal`, `Fatalf` и `Fatalln` вызывают `os.Exit(1)` после записи сообщения. +- `AddReplacer` маскирует или переписывает текст сообщений перед отправкой в writer'ы. ## Поведение traceback diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..7161a91 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,31 @@ +# v1.2.0 + +## Highlights + +- Added message replacement with `Logger.AddReplacer(old, new)` for masking secrets or normalizing log output. +- Preserved the existing `LoggerWriter.Print(...any)` contract, so custom writers do not need a signature update. +- Improved JSON writer newline handling for messages that already end with `\n`. + +## Added + +- `Logger.AddReplacer(old, new)` appends a replacement rule that is applied before records are sent to writers. +- README and package GoDoc now document message replacement and include usage examples. +- Tests covering message type preservation for custom writers, JSON empty messages, and JSON trailing newline behavior. + +## Fixed + +- `LoggerJsonWriter.Print` no longer panics when called with no message arguments. +- `LoggerJsonWriter.Print` now treats a trailing newline at the end of the last message as newline semantics and does not include that newline in the JSON `message` field. +- Log messages keep their original argument types for custom writers when no replacement rules are configured. + +## Changed + +- Updated the example program to demonstrate `AddReplacer`. +- Updated dependencies: + - `github.com/fatih/color` from `v1.18.0` to `v1.19.0` + - `github.com/mattn/go-isatty` from `v0.0.20` to `v0.0.21` + - `golang.org/x/sys` from `v0.42.0` to `v0.43.0` + +## Compatibility + +No breaking API changes are intended in this release. The public `LoggerWriter` interface continues to accept `messages ...any`. diff --git a/doc.go b/doc.go index 474fce5..b4cfaa7 100644 --- a/doc.go +++ b/doc.go @@ -13,6 +13,8 @@ // - pretty JSON: disabled // // Call Level(DEBUG) to enable all records, including debug messages. +// AddReplacer can be used to mask or normalize message text before records are +// passed to writers. // // Basic usage: // @@ -20,7 +22,8 @@ // Prefix("API"). // Level(slog.DEBUG). // PrintTraceback(true). -// JsonPretty(true) +// JsonPretty(true). +// AddReplacer("secret-token", "") // // text := logger.CreateTextStdoutWriter() // jsonFile, err := logger.CreateJsonFileWriter("logs/app.json") @@ -29,7 +32,7 @@ // } // // logger.AddWriters(text, jsonFile) -// logger.Infoln("service started") +// logger.Infoln("service started", "secret-token") // // if err := logger.Close(); err != nil { // panic(err) diff --git a/examples/main.go b/examples/main.go index 4806170..202df2b 100644 --- a/examples/main.go +++ b/examples/main.go @@ -11,18 +11,21 @@ func main() { logger := slog.CreateLogger(). Prefix("EXAMPLE"). Level(slog.DEBUG). - JsonPretty(true) + JsonPretty(true). + AddReplacer("SOME_SECRET", "") textStdout := logger.CreateTextStdoutWriter() jsonStdout := logger.CreateJsonStdoutWriter() textFile, err := logger.CreateTextFileWriter("logs/text.log") if err != nil { + logger.Close() panic(err) } jsonFile, err := logger.CreateJsonFileWriter("logs/json.log") if err != nil { + logger.Close() panic(err) } @@ -43,6 +46,7 @@ func main() { logger.Warnln("cache miss") logger.Errorln("request failed") logger.Debugln("debug details") + logger.Infoln("sensitive info, SOME_SECRET") if err := logger.Close(); err != nil { panic(err) diff --git a/go.mod b/go.mod index 0af7a7d..5055eb8 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,10 @@ module git.nix13.pw/scuroneko/slog go 1.26 -require github.com/fatih/color v1.18.0 +require github.com/fatih/color v1.19.0 require ( github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index 9b96122..4423c2e 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,16 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/io.go b/io.go index bc19419..1c80984 100644 --- a/io.go +++ b/io.go @@ -9,10 +9,12 @@ import ( func (l *Logger) Infof(format string, args ...any) { l.print(INFO, fmt.Sprintf(format, args...)) } + // Info logs an info message. func (l *Logger) Info(m ...any) { l.print(INFO, m...) } + // Infoln logs an info message and appends a newline semantic for writers that need it. func (l *Logger) Infoln(m ...any) { l.println(INFO, m...) @@ -22,10 +24,12 @@ func (l *Logger) Infoln(m ...any) { func (l *Logger) Warnf(format string, args ...any) { l.print(WARN, fmt.Sprintf(format, args...)) } + // Warn logs a warning message. func (l *Logger) Warn(m ...any) { l.print(WARN, m...) } + // Warnln logs a warning message with newline semantic. func (l *Logger) Warnln(m ...any) { l.println(WARN, m...) @@ -35,10 +39,12 @@ func (l *Logger) Warnln(m ...any) { func (l *Logger) Errorf(format string, args ...any) { l.print(ERROR, fmt.Sprintf(format, args...)) } + // Error logs an error message. func (l *Logger) Error(m ...any) { l.print(ERROR, m...) } + // Errorln logs an error message with newline semantic. func (l *Logger) Errorln(m ...any) { l.println(ERROR, m...) @@ -49,11 +55,13 @@ func (l *Logger) Fatalf(format string, args ...any) { l.print(FATAL, fmt.Sprintf(format, args...)) os.Exit(1) } + // Fatal logs a fatal message and exits the process with code 1. func (l *Logger) Fatal(m ...any) { l.print(FATAL, m...) os.Exit(1) } + // Fatalln logs a fatal message with newline semantic and exits the process with code 1. func (l *Logger) Fatalln(m ...any) { l.println(FATAL, m...) @@ -64,10 +72,12 @@ func (l *Logger) Fatalln(m ...any) { func (l *Logger) Debugf(format string, args ...any) { l.print(DEBUG, fmt.Sprintf(format, args...)) } + // Debug logs a debug message. func (l *Logger) Debug(m ...any) { l.print(DEBUG, m...) } + // Debugln logs a debug message with newline semantic. func (l *Logger) Debugln(m ...any) { l.println(DEBUG, m...) @@ -88,7 +98,7 @@ func (l *Logger) print(level LogLevel, m ...any) { if writer == nil { continue } - err := writer.Print(level, l.prefix, tb, m...) + err := writer.Print(level, l.prefix, tb, l.replaceAll(m...)...) if err != nil { l.reportWriterError(err) } @@ -111,7 +121,7 @@ func (l *Logger) println(level LogLevel, m ...any) { if writer == nil { continue } - err := writer.Print(level, l.prefix, tb, messages...) + err := writer.Print(level, l.prefix, tb, l.replaceAll(messages...)...) if err != nil { l.reportWriterError(err) } diff --git a/logger.go b/logger.go index d008f07..5ccad83 100644 --- a/logger.go +++ b/logger.go @@ -10,11 +10,21 @@ import ( "github.com/fatih/color" ) +type replacer struct { + old string + new string +} + +func (r replacer) replace(s string) string { + return strings.ReplaceAll(s, r.old, r.new) +} + // Logger routes log records to one or more configured writers. type Logger struct { - prefix string - level LogLevel - writers []LoggerWriter + prefix string + level LogLevel + writers []LoggerWriter + replacers []replacer printTraceback bool printTime bool @@ -66,36 +76,57 @@ func (l *Logger) Prefix(prefix string) *Logger { l.prefix = prefix return l } + // Level sets the maximum enabled level and returns the logger for chaining. func (l *Logger) Level(level LogLevel) *Logger { l.level = level return l } + // PrintTraceback enables traceback output for text writers. func (l *Logger) PrintTraceback(b bool) *Logger { l.printTraceback = b return l } + // PrintTime enables timestamps for text writers. func (l *Logger) PrintTime(b bool) *Logger { l.printTime = b return l } + // JsonPretty enables indented JSON output for JSON writers. func (l *Logger) JsonPretty(b bool) *Logger { l.jsonPretty = b return l } + // AddWriters appends multiple writers to the logger. func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger { l.writers = append(l.writers, writers...) return l } + // AddWriter appends a single writer to the logger. func (l *Logger) AddWriter(writer LoggerWriter) *Logger { l.writers = append(l.writers, writer) return l } + +// AddReplacer appends a message replacer and returns the logger for chaining. +// +// Replacement rules are applied before records are passed to writers. Empty old +// values are ignored because replacing an empty string would insert the +// replacement between every UTF-8 sequence. +func (l *Logger) AddReplacer(old, new string) *Logger { + if old == "" { + return l + } + r := replacer{old: old, new: new} + l.replacers = append(l.replacers, r) + return l +} + // Close closes all owned writers and returns a joined error, if any. func (l *Logger) Close() error { var errs []error @@ -116,35 +147,61 @@ func (l *Logger) Close() error { func (l *Logger) CreateTextWriter(w io.Writer) *LoggerTextWriter { return CreateTextWriter(w, l.printTraceback, l.printTime) } + // CreateTextStdoutWriter creates a non-owning text writer for os.Stdout. func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter { return CreateTextStdoutWriter(l.printTraceback, l.printTime) } + // CreateTextFileWriter creates an owning text writer for a file. func (l *Logger) CreateTextFileWriter(filename string) (*LoggerTextWriter, error) { return CreateTextFileWriter(filename, l.printTraceback, l.printTime) } + // CreateJsonWriter wraps an external writer with the logger JSON settings. func (l *Logger) CreateJsonWriter(w io.Writer) *LoggerJsonWriter { return CreateJsonWriter(w, l.jsonPretty) } + // CreateJsonStdoutWriter creates a non-owning JSON writer for os.Stdout. func (l *Logger) CreateJsonStdoutWriter() *LoggerJsonWriter { return CreateJsonStdoutWriter(l.jsonPretty) } + // CreateJsonFileWriter creates an owning JSON writer for a file. func (l *Logger) CreateJsonFileWriter(filename string) (*LoggerJsonWriter, error) { return CreateJsonFileWriter(filename, l.jsonPretty) } +func (l *Logger) replace(s string) string { + out := s + for _, repl := range l.replacers { + out = repl.replace(out) + } + return out +} + +func (l *Logger) replaceAll(messages ...any) []any { + if len(l.replacers) == 0 { + return messages + } + out := make([]any, len(messages)) + for i, msg := range messages { + out[i] = l.replace(fmt.Sprint(msg)) + } + return out +} + // FormatTime converts time to the package text log timestamp format. func FormatTime(t time.Time) string { return fmt.Sprintf("%02d.%02d.%02d %02d:%02d:%02d", t.Day(), t.Month(), t.Year(), t.Hour(), t.Minute(), t.Second()) } + // FormatTraceback converts a traceback frame to a compact string. func FormatTraceback(mt *MethodTraceback) string { return fmt.Sprintf("%s:%s:%d", mt.Filename, mt.Method, mt.Line) } + // FormatFullTraceback joins multiple traceback frames into one string. func FormatFullTraceback(tracebacks []*MethodTraceback) string { formatted := make([]string, 0) @@ -153,8 +210,9 @@ func FormatFullTraceback(tracebacks []*MethodTraceback) string { } return strings.Join(formatted, "->") } + // BuildString renders a text log record using the provided settings. -func BuildString(level LogLevel, prefix string, printTime, printTraceback bool, m ...any) string { +func BuildString(level LogLevel, prefix string, printTime, printTraceback bool, messages ...any) string { args := []string{ fmt.Sprintf("[%s]", prefix), fmt.Sprintf("[%s]", strings.ToUpper(level.t)), @@ -168,9 +226,9 @@ func BuildString(level LogLevel, prefix string, printTime, printTraceback bool, args = append(args, fmt.Sprintf("[%s]", FormatTime(time.Now()))) } - msg := Map(m, func(el any) string { - return fmt.Sprintf("%v", el) + m := Map(messages, func(t any) string { + return fmt.Sprint(t) }) - s := fmt.Sprintf("%s %s", strings.Join(args, " "), strings.Join(msg, " ")) + s := fmt.Sprintf("%s %s", strings.Join(args, " "), strings.Join(m, " ")) return s } diff --git a/logger_test.go b/logger_test.go index ff19b4b..06bc46f 100644 --- a/logger_test.go +++ b/logger_test.go @@ -2,6 +2,7 @@ package slog import ( "bytes" + "encoding/json" "errors" "os" "strings" @@ -13,6 +14,7 @@ type stubLoggerWriter struct { closeErr error printCalls int closeCalls int + messages []any } func (w *stubLoggerWriter) Close() error { @@ -24,8 +26,9 @@ func (w *stubLoggerWriter) Write(p []byte) (int, error) { return len(p), nil } -func (w *stubLoggerWriter) Print(_ LogLevel, _ string, _ []*MethodTraceback, _ ...any) error { +func (w *stubLoggerWriter) Print(_ LogLevel, _ string, _ []*MethodTraceback, messages ...any) error { w.printCalls++ + w.messages = append([]any(nil), messages...) return w.printErr } @@ -80,6 +83,20 @@ func TestLoggerPrintDoesNotRecurseOnWriterError(t *testing.T) { } } +func TestLoggerPreservesMessageTypesWithoutReplacers(t *testing.T) { + writer := &stubLoggerWriter{} + logger := CreateLogger().AddWriter(writer) + + logger.Error("status", 500) + + if len(writer.messages) != 2 { + t.Fatalf("writer should receive two messages, got %d", len(writer.messages)) + } + if _, ok := writer.messages[1].(int); !ok { + t.Fatalf("writer should receive original int message type, got %T", writer.messages[1]) + } +} + func TestCreateTextWriterCloseOnNonCloserIsNoOp(t *testing.T) { writer := CreateTextWriter(&bytes.Buffer{}, false, false) if err := writer.Close(); err != nil { @@ -130,6 +147,45 @@ func TestCreateJsonWriterDoesNotCloseExternalCloser(t *testing.T) { } } +func TestJsonWriterPrintAllowsEmptyMessages(t *testing.T) { + var buf bytes.Buffer + writer := CreateJsonWriter(&buf, false) + + if err := writer.Print(INFO, "TEST", nil); err != nil { + t.Fatalf("Print() error = %v", err) + } + + var message LoggerJsonMessage + if err := json.Unmarshal(buf.Bytes(), &message); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if message.Message != "" { + t.Fatalf("message should be empty, got %q", message.Message) + } +} + +func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) { + var buf bytes.Buffer + writer := CreateJsonWriter(&buf, false) + + if err := writer.Print(INFO, "TEST", nil, "hello\n"); err != nil { + t.Fatalf("Print() error = %v", err) + } + + data := buf.Bytes() + if !bytes.HasSuffix(data, []byte("\n")) { + t.Fatalf("JSON output should end with newline, got %q", data) + } + + var message LoggerJsonMessage + if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &message); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if message.Message != "hello" { + t.Fatalf("message should not include trailing newline, got %q", message.Message) + } +} + func TestCreateTextStdoutWriterDoesNotCloseStdout(t *testing.T) { stdoutFile := swapStdout(t) diff --git a/writers.go b/writers.go index fe082d5..e96d37d 100644 --- a/writers.go +++ b/writers.go @@ -70,14 +70,12 @@ func (w *LoggerJsonWriter) Write(data []byte) (int, error) { // Print encodes the provided record as JSON and writes it to the underlying writer. func (w *LoggerJsonWriter) Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error { - msg := Map(messages, func(el any) string { - return fmt.Sprintf("%v", el) - }) + msg := Map(messages, func(el any) string { return fmt.Sprint(el) }) newline := false - if msg[len(msg)-1] == "\n" { + if len(msg) > 0 && strings.HasSuffix(msg[len(msg)-1], "\n") { newline = true - msg = msg[:len(msg)-1] + msg[len(msg)-1] = strings.TrimSuffix(msg[len(msg)-1], "\n") } m := LoggerJsonMessage{