REPOSITORY / ScuroNeko/SNekLog

Compare commits

DIFF REPOSITORY
3 Commits
Author SHA1 Message Date
ScuroNeko 14b787710c Merge branch 'main' of scuroneko.dev:ScuroNeko/slog 2026-04-20 18:11:30 +03:00
ScuroNeko 9268e222ab 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.
2026-04-20 18:00:11 +03:00
ScuroNeko 6456d4d95d update package name to new domain 2026-03-30 00:02:39 +03:00
11 changed files with 239 additions and 31 deletions
+25 -3
View File
@@ -11,12 +11,13 @@ Russian version: [README_ru.md](README_ru.md)
- `stdout`, files, and arbitrary external `io.Writer` values. - `stdout`, files, and arbitrary external `io.Writer` values.
- Optional timestamps for text output. - Optional timestamps for text output.
- Compact traceback metadata for text writers and full traceback slices for JSON. - 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. - Explicit ownership rules for writer closing.
## Installation ## Installation
```bash ```bash
go get git.nix13.pw/scuroneko/slog go get git.scuroneko.dev/scuroneko/slog
``` ```
## Quick start ## Quick start
@@ -27,7 +28,7 @@ package main
import ( import (
"log" "log"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func main() { func main() {
@@ -35,7 +36,8 @@ func main() {
Prefix("API"). Prefix("API").
Level(slog.DEBUG). Level(slog.DEBUG).
PrintTraceback(true). PrintTraceback(true).
JsonPretty(true) JsonPretty(true).
AddReplacer("SOME_SECRET", "<redacted>")
text := logger.CreateTextStdoutWriter() text := logger.CreateTextStdoutWriter()
jsonFile, err := logger.CreateJsonFileWriter("logs/app.json") jsonFile, err := logger.CreateJsonFileWriter("logs/app.json")
@@ -49,6 +51,7 @@ func main() {
logger.Warnln("cache miss") logger.Warnln("cache miss")
logger.Errorln("request failed") logger.Errorln("request failed")
logger.Debugln("debug details") logger.Debugln("debug details")
logger.Infoln("token", "SOME_SECRET")
if err := logger.Close(); err != nil { if err := logger.Close(); err != nil {
log.Fatal(err) log.Fatal(err)
@@ -116,12 +119,31 @@ JSON writers emit objects with this shape:
When `JsonPretty(true)` is enabled, JSON is indented. 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", "<redacted>").
AddReplacer("user@example.com", "<email>")
logger.Infoln("login token:", "SOME_SECRET")
```
This writes `<redacted>` instead of `SOME_SECRET` in both text and JSON output.
An empty `old` value is ignored.
## API summary ## API summary
- `Info`, `Warn`, `Error`, `Debug`, and `Fatal` accept a list of values. - `Info`, `Warn`, `Error`, `Debug`, and `Fatal` accept a list of values.
- `Infof`, `Warnf`, `Errorf`, `Debugf`, and `Fatalf` use `fmt.Sprintf`. - `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. - 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. - `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 ## Traceback behavior
+24 -3
View File
@@ -11,12 +11,13 @@ English version: [README.md](README.md)
- `stdout`, файлы и любые внешние `io.Writer`. - `stdout`, файлы и любые внешние `io.Writer`.
- Опциональные timestamp'ы для текстового вывода. - Опциональные timestamp'ы для текстового вывода.
- Компактный traceback для текстовых writer'ов и полный traceback для JSON. - Компактный traceback для текстовых writer'ов и полный traceback для JSON.
- Замена текста в сообщениях для маскирования секретов или нормализации вывода.
- Явные правила владения writer'ами при `Close()`. - Явные правила владения writer'ами при `Close()`.
## Установка ## Установка
```bash ```bash
go get git.nix13.pw/scuroneko/slog go get git.scuroneko.dev/scuroneko/slog
``` ```
## Быстрый старт ## Быстрый старт
@@ -27,7 +28,7 @@ package main
import ( import (
"log" "log"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func main() { func main() {
@@ -35,7 +36,8 @@ func main() {
Prefix("API"). Prefix("API").
Level(slog.DEBUG). Level(slog.DEBUG).
PrintTraceback(true). PrintTraceback(true).
JsonPretty(true) JsonPretty(true).
AddReplacer("SOME_SECRET", "<redacted>")
text := logger.CreateTextStdoutWriter() text := logger.CreateTextStdoutWriter()
jsonFile, err := logger.CreateJsonFileWriter("logs/app.json") jsonFile, err := logger.CreateJsonFileWriter("logs/app.json")
@@ -49,6 +51,7 @@ func main() {
logger.Warnln("cache miss") logger.Warnln("cache miss")
logger.Errorln("request failed") logger.Errorln("request failed")
logger.Debugln("debug details") logger.Debugln("debug details")
logger.Infoln("token", "SOME_SECRET")
if err := logger.Close(); err != nil { if err := logger.Close(); err != nil {
log.Fatal(err) log.Fatal(err)
@@ -116,12 +119,30 @@ JSON writer записывает объект со следующими поля
Если включен `JsonPretty(true)`, JSON выводится с отступами. Если включен `JsonPretty(true)`, JSON выводится с отступами.
## Замена сообщений
`AddReplacer(old, new)` заменяет найденный текст в каждом сообщении до того,
как запись попадет в writer'ы. Правила замены применяются в порядке добавления.
```go
logger := slog.CreateLogger().
Level(slog.DEBUG).
AddReplacer("SOME_SECRET", "<redacted>").
AddReplacer("user@example.com", "<email>")
logger.Infoln("login token:", "SOME_SECRET")
```
В текстовом и JSON-выводе вместо `SOME_SECRET` будет записано `<redacted>`.
Пустое значение `old` игнорируется.
## API кратко ## API кратко
- `Info`, `Warn`, `Error`, `Debug`, `Fatal` принимают список значений. - `Info`, `Warn`, `Error`, `Debug`, `Fatal` принимают список значений.
- `Infof`, `Warnf`, `Errorf`, `Debugf`, `Fatalf` используют `fmt.Sprintf`. - `Infof`, `Warnf`, `Errorf`, `Debugf`, `Fatalf` используют `fmt.Sprintf`.
- Методы `*ln` добавляют семантику перевода строки, что удобно для `stdout`, Docker и line-based collectors. - Методы `*ln` добавляют семантику перевода строки, что удобно для `stdout`, Docker и line-based collectors.
- `Fatal`, `Fatalf` и `Fatalln` вызывают `os.Exit(1)` после записи сообщения. - `Fatal`, `Fatalf` и `Fatalln` вызывают `os.Exit(1)` после записи сообщения.
- `AddReplacer` маскирует или переписывает текст сообщений перед отправкой в writer'ы.
## Поведение traceback ## Поведение traceback
+31
View File
@@ -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`.
+5 -2
View File
@@ -13,6 +13,8 @@
// - pretty JSON: disabled // - pretty JSON: disabled
// //
// Call Level(DEBUG) to enable all records, including debug messages. // 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: // Basic usage:
// //
@@ -20,7 +22,8 @@
// Prefix("API"). // Prefix("API").
// Level(slog.DEBUG). // Level(slog.DEBUG).
// PrintTraceback(true). // PrintTraceback(true).
// JsonPretty(true) // JsonPretty(true).
// AddReplacer("secret-token", "<redacted>")
// //
// text := logger.CreateTextStdoutWriter() // text := logger.CreateTextStdoutWriter()
// jsonFile, err := logger.CreateJsonFileWriter("logs/app.json") // jsonFile, err := logger.CreateJsonFileWriter("logs/app.json")
@@ -29,7 +32,7 @@
// } // }
// //
// logger.AddWriters(text, jsonFile) // logger.AddWriters(text, jsonFile)
// logger.Infoln("service started") // logger.Infoln("service started", "secret-token")
// //
// if err := logger.Close(); err != nil { // if err := logger.Close(); err != nil {
// panic(err) // panic(err)
+6 -2
View File
@@ -4,25 +4,28 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func main() { func main() {
logger := slog.CreateLogger(). logger := slog.CreateLogger().
Prefix("EXAMPLE"). Prefix("EXAMPLE").
Level(slog.DEBUG). Level(slog.DEBUG).
JsonPretty(true) JsonPretty(true).
AddReplacer("SOME_SECRET", "<Secret>")
textStdout := logger.CreateTextStdoutWriter() textStdout := logger.CreateTextStdoutWriter()
jsonStdout := logger.CreateJsonStdoutWriter() jsonStdout := logger.CreateJsonStdoutWriter()
textFile, err := logger.CreateTextFileWriter("logs/text.log") textFile, err := logger.CreateTextFileWriter("logs/text.log")
if err != nil { if err != nil {
logger.Close()
panic(err) panic(err)
} }
jsonFile, err := logger.CreateJsonFileWriter("logs/json.log") jsonFile, err := logger.CreateJsonFileWriter("logs/json.log")
if err != nil { if err != nil {
logger.Close()
panic(err) panic(err)
} }
@@ -43,6 +46,7 @@ func main() {
logger.Warnln("cache miss") logger.Warnln("cache miss")
logger.Errorln("request failed") logger.Errorln("request failed")
logger.Debugln("debug details") logger.Debugln("debug details")
logger.Infoln("sensitive info, SOME_SECRET")
if err := logger.Close(); err != nil { if err := logger.Close(); err != nil {
panic(err) panic(err)
+4 -4
View File
@@ -1,11 +1,11 @@
module git.nix13.pw/scuroneko/slog module git.scuroneko.dev/scuroneko/slog
go 1.26 go 1.26
require github.com/fatih/color v1.18.0 require github.com/fatih/color v1.19.0
require ( require (
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.43.0 // indirect
) )
+7 -2
View File
@@ -1,10 +1,15 @@
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 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.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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
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.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=
+12 -2
View File
@@ -9,10 +9,12 @@ import (
func (l *Logger) Infof(format string, args ...any) { func (l *Logger) Infof(format string, args ...any) {
l.print(INFO, fmt.Sprintf(format, args...)) l.print(INFO, fmt.Sprintf(format, args...))
} }
// Info logs an info message. // Info logs an info message.
func (l *Logger) Info(m ...any) { func (l *Logger) Info(m ...any) {
l.print(INFO, m...) l.print(INFO, m...)
} }
// Infoln logs an info message and appends a newline semantic for writers that need it. // Infoln logs an info message and appends a newline semantic for writers that need it.
func (l *Logger) Infoln(m ...any) { func (l *Logger) Infoln(m ...any) {
l.println(INFO, m...) l.println(INFO, m...)
@@ -22,10 +24,12 @@ func (l *Logger) Infoln(m ...any) {
func (l *Logger) Warnf(format string, args ...any) { func (l *Logger) Warnf(format string, args ...any) {
l.print(WARN, fmt.Sprintf(format, args...)) l.print(WARN, fmt.Sprintf(format, args...))
} }
// Warn logs a warning message. // Warn logs a warning message.
func (l *Logger) Warn(m ...any) { func (l *Logger) Warn(m ...any) {
l.print(WARN, m...) l.print(WARN, m...)
} }
// Warnln logs a warning message with newline semantic. // Warnln logs a warning message with newline semantic.
func (l *Logger) Warnln(m ...any) { func (l *Logger) Warnln(m ...any) {
l.println(WARN, m...) l.println(WARN, m...)
@@ -35,10 +39,12 @@ func (l *Logger) Warnln(m ...any) {
func (l *Logger) Errorf(format string, args ...any) { func (l *Logger) Errorf(format string, args ...any) {
l.print(ERROR, fmt.Sprintf(format, args...)) l.print(ERROR, fmt.Sprintf(format, args...))
} }
// Error logs an error message. // Error logs an error message.
func (l *Logger) Error(m ...any) { func (l *Logger) Error(m ...any) {
l.print(ERROR, m...) l.print(ERROR, m...)
} }
// Errorln logs an error message with newline semantic. // Errorln logs an error message with newline semantic.
func (l *Logger) Errorln(m ...any) { func (l *Logger) Errorln(m ...any) {
l.println(ERROR, m...) l.println(ERROR, m...)
@@ -49,11 +55,13 @@ func (l *Logger) Fatalf(format string, args ...any) {
l.print(FATAL, fmt.Sprintf(format, args...)) l.print(FATAL, fmt.Sprintf(format, args...))
os.Exit(1) os.Exit(1)
} }
// Fatal logs a fatal message and exits the process with code 1. // Fatal logs a fatal message and exits the process with code 1.
func (l *Logger) Fatal(m ...any) { func (l *Logger) Fatal(m ...any) {
l.print(FATAL, m...) l.print(FATAL, m...)
os.Exit(1) os.Exit(1)
} }
// Fatalln logs a fatal message with newline semantic and exits the process with code 1. // Fatalln logs a fatal message with newline semantic and exits the process with code 1.
func (l *Logger) Fatalln(m ...any) { func (l *Logger) Fatalln(m ...any) {
l.println(FATAL, m...) l.println(FATAL, m...)
@@ -64,10 +72,12 @@ func (l *Logger) Fatalln(m ...any) {
func (l *Logger) Debugf(format string, args ...any) { func (l *Logger) Debugf(format string, args ...any) {
l.print(DEBUG, fmt.Sprintf(format, args...)) l.print(DEBUG, fmt.Sprintf(format, args...))
} }
// Debug logs a debug message. // Debug logs a debug message.
func (l *Logger) Debug(m ...any) { func (l *Logger) Debug(m ...any) {
l.print(DEBUG, m...) l.print(DEBUG, m...)
} }
// Debugln logs a debug message with newline semantic. // Debugln logs a debug message with newline semantic.
func (l *Logger) Debugln(m ...any) { func (l *Logger) Debugln(m ...any) {
l.println(DEBUG, m...) l.println(DEBUG, m...)
@@ -88,7 +98,7 @@ func (l *Logger) print(level LogLevel, m ...any) {
if writer == nil { if writer == nil {
continue continue
} }
err := writer.Print(level, l.prefix, tb, m...) err := writer.Print(level, l.prefix, tb, l.replaceAll(m...)...)
if err != nil { if err != nil {
l.reportWriterError(err) l.reportWriterError(err)
} }
@@ -111,7 +121,7 @@ func (l *Logger) println(level LogLevel, m ...any) {
if writer == nil { if writer == nil {
continue continue
} }
err := writer.Print(level, l.prefix, tb, messages...) err := writer.Print(level, l.prefix, tb, l.replaceAll(messages...)...)
if err != nil { if err != nil {
l.reportWriterError(err) l.reportWriterError(err)
} }
+65 -7
View File
@@ -10,11 +10,21 @@ import (
"github.com/fatih/color" "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. // Logger routes log records to one or more configured writers.
type Logger struct { type Logger struct {
prefix string prefix string
level LogLevel level LogLevel
writers []LoggerWriter writers []LoggerWriter
replacers []replacer
printTraceback bool printTraceback bool
printTime bool printTime bool
@@ -66,36 +76,57 @@ func (l *Logger) Prefix(prefix string) *Logger {
l.prefix = prefix l.prefix = prefix
return l return l
} }
// Level sets the maximum enabled level and returns the logger for chaining. // Level sets the maximum enabled level and returns the logger for chaining.
func (l *Logger) Level(level LogLevel) *Logger { func (l *Logger) Level(level LogLevel) *Logger {
l.level = level l.level = level
return l return l
} }
// PrintTraceback enables traceback output for text writers. // PrintTraceback enables traceback output for text writers.
func (l *Logger) PrintTraceback(b bool) *Logger { func (l *Logger) PrintTraceback(b bool) *Logger {
l.printTraceback = b l.printTraceback = b
return l return l
} }
// PrintTime enables timestamps for text writers. // PrintTime enables timestamps for text writers.
func (l *Logger) PrintTime(b bool) *Logger { func (l *Logger) PrintTime(b bool) *Logger {
l.printTime = b l.printTime = b
return l return l
} }
// JsonPretty enables indented JSON output for JSON writers. // JsonPretty enables indented JSON output for JSON writers.
func (l *Logger) JsonPretty(b bool) *Logger { func (l *Logger) JsonPretty(b bool) *Logger {
l.jsonPretty = b l.jsonPretty = b
return l return l
} }
// AddWriters appends multiple writers to the logger. // AddWriters appends multiple writers to the logger.
func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger { func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger {
l.writers = append(l.writers, writers...) l.writers = append(l.writers, writers...)
return l return l
} }
// AddWriter appends a single writer to the logger. // AddWriter appends a single writer to the logger.
func (l *Logger) AddWriter(writer LoggerWriter) *Logger { func (l *Logger) AddWriter(writer LoggerWriter) *Logger {
l.writers = append(l.writers, writer) l.writers = append(l.writers, writer)
return l 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. // Close closes all owned writers and returns a joined error, if any.
func (l *Logger) Close() error { func (l *Logger) Close() error {
var errs []error var errs []error
@@ -116,35 +147,61 @@ func (l *Logger) Close() error {
func (l *Logger) CreateTextWriter(w io.Writer) *LoggerTextWriter { func (l *Logger) CreateTextWriter(w io.Writer) *LoggerTextWriter {
return CreateTextWriter(w, l.printTraceback, l.printTime) return CreateTextWriter(w, l.printTraceback, l.printTime)
} }
// CreateTextStdoutWriter creates a non-owning text writer for os.Stdout. // CreateTextStdoutWriter creates a non-owning text writer for os.Stdout.
func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter { func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter {
return CreateTextStdoutWriter(l.printTraceback, l.printTime) return CreateTextStdoutWriter(l.printTraceback, l.printTime)
} }
// CreateTextFileWriter creates an owning text writer for a file. // CreateTextFileWriter creates an owning text writer for a file.
func (l *Logger) CreateTextFileWriter(filename string) (*LoggerTextWriter, error) { func (l *Logger) CreateTextFileWriter(filename string) (*LoggerTextWriter, error) {
return CreateTextFileWriter(filename, l.printTraceback, l.printTime) return CreateTextFileWriter(filename, l.printTraceback, l.printTime)
} }
// CreateJsonWriter wraps an external writer with the logger JSON settings. // CreateJsonWriter wraps an external writer with the logger JSON settings.
func (l *Logger) CreateJsonWriter(w io.Writer) *LoggerJsonWriter { func (l *Logger) CreateJsonWriter(w io.Writer) *LoggerJsonWriter {
return CreateJsonWriter(w, l.jsonPretty) return CreateJsonWriter(w, l.jsonPretty)
} }
// CreateJsonStdoutWriter creates a non-owning JSON writer for os.Stdout. // CreateJsonStdoutWriter creates a non-owning JSON writer for os.Stdout.
func (l *Logger) CreateJsonStdoutWriter() *LoggerJsonWriter { func (l *Logger) CreateJsonStdoutWriter() *LoggerJsonWriter {
return CreateJsonStdoutWriter(l.jsonPretty) return CreateJsonStdoutWriter(l.jsonPretty)
} }
// CreateJsonFileWriter creates an owning JSON writer for a file. // CreateJsonFileWriter creates an owning JSON writer for a file.
func (l *Logger) CreateJsonFileWriter(filename string) (*LoggerJsonWriter, error) { func (l *Logger) CreateJsonFileWriter(filename string) (*LoggerJsonWriter, error) {
return CreateJsonFileWriter(filename, l.jsonPretty) 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. // FormatTime converts time to the package text log timestamp format.
func FormatTime(t time.Time) string { 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()) 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. // FormatTraceback converts a traceback frame to a compact string.
func FormatTraceback(mt *MethodTraceback) string { func FormatTraceback(mt *MethodTraceback) string {
return fmt.Sprintf("%s:%s:%d", mt.Filename, mt.Method, mt.Line) return fmt.Sprintf("%s:%s:%d", mt.Filename, mt.Method, mt.Line)
} }
// FormatFullTraceback joins multiple traceback frames into one string. // FormatFullTraceback joins multiple traceback frames into one string.
func FormatFullTraceback(tracebacks []*MethodTraceback) string { func FormatFullTraceback(tracebacks []*MethodTraceback) string {
formatted := make([]string, 0) formatted := make([]string, 0)
@@ -153,8 +210,9 @@ func FormatFullTraceback(tracebacks []*MethodTraceback) string {
} }
return strings.Join(formatted, "->") return strings.Join(formatted, "->")
} }
// BuildString renders a text log record using the provided settings. // 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{ args := []string{
fmt.Sprintf("[%s]", prefix), fmt.Sprintf("[%s]", prefix),
fmt.Sprintf("[%s]", strings.ToUpper(level.t)), 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()))) args = append(args, fmt.Sprintf("[%s]", FormatTime(time.Now())))
} }
msg := Map(m, func(el any) string { m := Map(messages, func(t any) string {
return fmt.Sprintf("%v", el) 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 return s
} }
+57 -1
View File
@@ -2,6 +2,7 @@ package slog
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"os" "os"
"strings" "strings"
@@ -13,6 +14,7 @@ type stubLoggerWriter struct {
closeErr error closeErr error
printCalls int printCalls int
closeCalls int closeCalls int
messages []any
} }
func (w *stubLoggerWriter) Close() error { func (w *stubLoggerWriter) Close() error {
@@ -24,8 +26,9 @@ func (w *stubLoggerWriter) Write(p []byte) (int, error) {
return len(p), nil 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.printCalls++
w.messages = append([]any(nil), messages...)
return w.printErr 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) { func TestCreateTextWriterCloseOnNonCloserIsNoOp(t *testing.T) {
writer := CreateTextWriter(&bytes.Buffer{}, false, false) writer := CreateTextWriter(&bytes.Buffer{}, false, false)
if err := writer.Close(); err != nil { 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) { func TestCreateTextStdoutWriterDoesNotCloseStdout(t *testing.T) {
stdoutFile := swapStdout(t) stdoutFile := swapStdout(t)
+3 -5
View File
@@ -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. // 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 { func (w *LoggerJsonWriter) Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error {
msg := Map(messages, func(el any) string { msg := Map(messages, func(el any) string { return fmt.Sprint(el) })
return fmt.Sprintf("%v", el)
})
newline := false newline := false
if msg[len(msg)-1] == "\n" { if len(msg) > 0 && strings.HasSuffix(msg[len(msg)-1], "\n") {
newline = true newline = true
msg = msg[:len(msg)-1] msg[len(msg)-1] = strings.TrimSuffix(msg[len(msg)-1], "\n")
} }
m := LoggerJsonMessage{ m := LoggerJsonMessage{