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{