REPOSITORY / ScuroNeko/SNekLog

Compare commits

DIFF REPOSITORY
5 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
ScuroNeko 5d96c6fbae version bump 2026-03-17 15:41:00 +03:00
ScuroNeko badee2c598 docs: add godoc and bilingual README 2026-03-17 15:33:11 +03:00
13 changed files with 772 additions and 76 deletions
+159 -1
View File
@@ -1,2 +1,160 @@
# ScuroLogger (slog)
# slog
Small structured logger for Go with text and JSON output, multiple writers, and optional traceback metadata.
Russian version: [README_ru.md](README_ru.md)
## Features
- Fan out the same record to multiple destinations.
- Text and JSON writers.
- `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
```bash
go get git.scuroneko.dev/scuroneko/slog
```
## Quick start
```go
package main
import (
"log"
"git.scuroneko.dev/scuroneko/slog"
)
func main() {
logger := slog.CreateLogger().
Prefix("API").
Level(slog.DEBUG).
PrintTraceback(true).
JsonPretty(true).
AddReplacer("SOME_SECRET", "<redacted>")
text := logger.CreateTextStdoutWriter()
jsonFile, err := logger.CreateJsonFileWriter("logs/app.json")
if err != nil {
log.Fatal(err)
}
logger.AddWriters(text, jsonFile)
logger.Infoln("service started")
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)
}
}
```
## Defaults
`CreateLogger()` starts with:
- `Prefix("LOG")`
- `Level(slog.FATAL)`
- `PrintTime(true)`
- `PrintTraceback(false)`
- `JsonPretty(false)`
Important: with the current level ordering, `Level(slog.FATAL)` allows `INFO`, `WARN`, `ERROR`, and `FATAL`, but not `DEBUG`. Use `Level(slog.DEBUG)` to enable every level.
## Writers and ownership
`Logger.Close()` only closes writers created by the logger itself:
- `CreateTextFileWriter(...)`
- `CreateJsonFileWriter(...)`
The following writers remain owned by the caller and are not closed by `Logger.Close()`:
- `CreateTextWriter(existingWriter)`
- `CreateJsonWriter(existingWriter)`
- `CreateTextStdoutWriter()`
- `CreateJsonStdoutWriter()`
This makes it safe to plug in `bytes.Buffer`, network writers, and other externally managed resources.
## Output formats
Text writers render records like:
```text
[API] [INFO] [main.go:main:27] [17.03.26 14:05:09] service started
```
The traceback field is included only when `PrintTraceback(true)` is enabled.
JSON writers emit objects with this shape:
```json
{
"time": "2026-03-17T14:05:09.123456789+03:00",
"level": "info",
"prefix": "API",
"message": "service started",
"traceback": [
{
"method": "main",
"filename": "main.go",
"line": 27,
"signature": "main.main",
"fullPath": "/path/to/main.go"
}
]
}
```
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
- `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
- Text writers use the nearest user stack frame.
- JSON writers receive the full traceback slice.
- Internal `slog` frames and `runtime` frames are filtered out.
## Repository example
See [examples/main.go](examples/main.go).
## License
This project is licensed under GNU GPLv3. See [LICENSE](LICENSE).
+159
View File
@@ -0,0 +1,159 @@
# slog
Небольшой структурированный логгер для Go с текстовым и JSON-выводом, несколькими writer'ами и настраиваемыми traceback-метаданными.
English version: [README.md](README.md)
## Возможности
- Одновременная запись в несколько destinations.
- Текстовый и JSON-форматы.
- `stdout`, файлы и любые внешние `io.Writer`.
- Опциональные timestamp'ы для текстового вывода.
- Компактный traceback для текстовых writer'ов и полный traceback для JSON.
- Замена текста в сообщениях для маскирования секретов или нормализации вывода.
- Явные правила владения writer'ами при `Close()`.
## Установка
```bash
go get git.scuroneko.dev/scuroneko/slog
```
## Быстрый старт
```go
package main
import (
"log"
"git.scuroneko.dev/scuroneko/slog"
)
func main() {
logger := slog.CreateLogger().
Prefix("API").
Level(slog.DEBUG).
PrintTraceback(true).
JsonPretty(true).
AddReplacer("SOME_SECRET", "<redacted>")
text := logger.CreateTextStdoutWriter()
jsonFile, err := logger.CreateJsonFileWriter("logs/app.json")
if err != nil {
log.Fatal(err)
}
logger.AddWriters(text, jsonFile)
logger.Infoln("service started")
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)
}
}
```
## Значения по умолчанию
`CreateLogger()` создает логгер со следующими настройками:
- `Prefix("LOG")`
- `Level(slog.FATAL)`
- `PrintTime(true)`
- `PrintTraceback(false)`
- `JsonPretty(false)`
Важно: в текущей модели уровней `Level(slog.FATAL)` пропускает `INFO`, `WARN`, `ERROR` и `FATAL`, но не `DEBUG`. Чтобы включить все сообщения, используйте `Level(slog.DEBUG)`.
## Writer'ы и владение
`Logger.Close()` закрывает только writer'ы, которые логгер создал сам:
- `CreateTextFileWriter(...)`
- `CreateJsonFileWriter(...)`
Внешние writer'ы не закрываются:
- `CreateTextWriter(existingWriter)`
- `CreateJsonWriter(existingWriter)`
- `CreateTextStdoutWriter()`
- `CreateJsonStdoutWriter()`
Это позволяет безопасно подключать `bytes.Buffer`, сетевые writer'ы и другие уже управляемые ресурсы.
## Форматы вывода
Текстовый writer формирует записи вида:
```text
[API] [INFO] [main.go:main:27] [17.03.26 14:05:09] service started
```
Поле traceback появляется только если включен `PrintTraceback(true)`.
JSON writer записывает объект со следующими полями:
```json
{
"time": "2026-03-17T14:05:09.123456789+03:00",
"level": "info",
"prefix": "API",
"message": "service started",
"traceback": [
{
"method": "main",
"filename": "main.go",
"line": 27,
"signature": "main.main",
"fullPath": "/path/to/main.go"
}
]
}
```
Если включен `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 кратко
- `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
- Текстовые writer'ы используют ближайший пользовательский stack frame.
- JSON writer'ы получают полный traceback.
- Внутренние frame'ы `slog` и `runtime` фильтруются из traceback.
## Пример из репозитория
См. [examples/main.go](examples/main.go).
## Лицензия
Проект распространяется под GNU GPLv3. См. [LICENSE](LICENSE).
+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`.
+45
View File
@@ -0,0 +1,45 @@
// Package slog provides a small structured logger that can write the same
// record to text and JSON outputs.
//
// A Logger can fan out records to stdout, files, or any external io.Writer.
// Text writers can include timestamps and a compact traceback frame. JSON
// writers always emit a structured traceback slice.
//
// CreateLogger uses the following defaults:
// - prefix: "LOG"
// - level: FATAL, which enables info, warn, error, and fatal records
// - text timestamps: enabled
// - text traceback: disabled
// - 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:
//
// logger := slog.CreateLogger().
// Prefix("API").
// Level(slog.DEBUG).
// PrintTraceback(true).
// JsonPretty(true).
// AddReplacer("secret-token", "<redacted>")
//
// text := logger.CreateTextStdoutWriter()
// jsonFile, err := logger.CreateJsonFileWriter("logs/app.json")
// if err != nil {
// panic(err)
// }
//
// logger.AddWriters(text, jsonFile)
// logger.Infoln("service started", "secret-token")
//
// if err := logger.Close(); err != nil {
// panic(err)
// }
//
// Writers created with CreateTextFileWriter or CreateJsonFileWriter are owned by
// the logger and are closed by Logger.Close. Writers created from existing
// io.Writer values through CreateTextWriter or CreateJsonWriter remain owned by
// the caller and are not closed by the logger.
package slog
+49 -13
View File
@@ -1,21 +1,57 @@
package main
import "git.nix13.pw/scuroneko/slog"
import (
"bytes"
"fmt"
"git.scuroneko.dev/scuroneko/slog"
)
func main() {
logger := slog.CreateLogger().JsonPretty(false)
defer logger.Close()
logger.AddWriter(logger.CreateTextStdoutWriter())
f, err := logger.CreateJsonFileWriter("main.log")
logger := slog.CreateLogger().
Prefix("EXAMPLE").
Level(slog.DEBUG).
JsonPretty(true).
AddReplacer("SOME_SECRET", "<Secret>")
textStdout := logger.CreateTextStdoutWriter()
jsonStdout := logger.CreateJsonStdoutWriter()
textFile, err := logger.CreateTextFileWriter("logs/text.log")
if err != nil {
logger.Close()
panic(err)
}
logger.AddWriter(f)
logger.Infoln("Test")
logger.Warnln("Test")
logger.Errorln("Test")
logger.Debugln("Test") // No output
logger.Level(slog.DEBUG)
logger.Debugln("Test") // Now we have output
logger.Fatalln("Test") // Exit code 1
jsonFile, err := logger.CreateJsonFileWriter("logs/json.log")
if err != nil {
logger.Close()
panic(err)
}
var externalBuffer bytes.Buffer
externalText := logger.CreateTextWriter(&externalBuffer)
externalJSON := logger.CreateJsonWriter(&externalBuffer)
logger.AddWriters(
textStdout,
jsonStdout,
textFile,
jsonFile,
externalText,
externalJSON,
)
logger.Infoln("service started")
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)
}
fmt.Println("external buffer contents:")
fmt.Println(externalBuffer.String())
}
+5 -5
View File
@@ -1,11 +1,11 @@
module git.nix13.pw/scuroneko/slog
module git.scuroneko.dev/scuroneko/slog
go 1.25
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.40.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
golang.org/x/sys v0.43.0 // indirect
)
+8 -2
View File
@@ -1,9 +1,15 @@
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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
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=
+27 -2
View File
@@ -5,55 +5,80 @@ import (
"os"
)
// Infof logs a formatted info message.
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...)
}
// Warnf logs a formatted warning message.
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...)
}
// Errorf logs a formatted error message.
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...)
}
// Fatalf logs a formatted fatal message and exits the process with code 1.
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...)
os.Exit(1)
}
// Debugf logs a formatted debug message.
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...)
}
@@ -73,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)
}
@@ -96,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)
}
+86 -4
View File
@@ -10,26 +10,40 @@ 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
replacers []replacer
printTraceback bool
printTime bool
jsonPretty bool
}
// LogLevel describes a logging severity.
type LogLevel struct {
n uint8
t string
c color.Attribute
}
// GetName returns the lowercase textual representation of the level.
func (l *LogLevel) GetName() string {
return l.t
}
// MethodTraceback describes a single stack frame attached to a log entry.
type MethodTraceback struct {
Method string `json:"method"`
Filename string `json:"filename"`
@@ -38,6 +52,7 @@ type MethodTraceback struct {
FullPath string `json:"fullPath"`
}
// Predefined log levels.
var (
INFO = LogLevel{n: 0, t: "info", c: color.FgWhite}
WARN = LogLevel{n: 1, t: "warn", c: color.FgHiYellow}
@@ -46,6 +61,7 @@ var (
DEBUG = LogLevel{n: 4, t: "debug", c: color.FgGreen}
)
// CreateLogger creates a logger with default settings.
func CreateLogger() *Logger {
return &Logger{
prefix: "LOG",
@@ -55,34 +71,63 @@ func CreateLogger() *Logger {
}
}
// Prefix sets the record prefix and returns the logger for chaining.
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
for _, writer := range l.writers {
@@ -98,31 +143,66 @@ func (l *Logger) Close() error {
return errors.Join(errs...)
}
// CreateTextWriter wraps an external writer with the logger text settings.
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)
for _, tb := range tracebacks {
@@ -130,7 +210,9 @@ func FormatFullTraceback(tracebacks []*MethodTraceback) string {
}
return strings.Join(formatted, "->")
}
func BuildString(level LogLevel, prefix string, printTime, printTraceback bool, m ...any) string {
// BuildString renders a text log record using the provided settings.
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)),
@@ -144,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
}
+102 -1
View File
@@ -2,8 +2,10 @@ package slog
import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"testing"
)
@@ -12,6 +14,7 @@ type stubLoggerWriter struct {
closeErr error
printCalls int
closeCalls int
messages []any
}
func (w *stubLoggerWriter) Close() error {
@@ -23,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
}
@@ -79,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 {
@@ -129,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)
@@ -153,6 +210,42 @@ func TestCreateJsonStdoutWriterDoesNotCloseStdout(t *testing.T) {
}
}
func TestGetFullTracebackSkipsRuntimeAndSlogFrames(t *testing.T) {
tracebacks := captureTracebackForTest()
if len(tracebacks) == 0 {
t.Fatal("expected at least one traceback frame")
}
first := tracebacks[0]
if first.Method != "captureTracebackForTest" {
t.Fatalf("first frame should be the nearest user frame, got %s", first.Method)
}
for _, tb := range tracebacks {
if strings.HasPrefix(tb.Signature, "runtime.") {
t.Fatalf("runtime frame should be filtered out, got %s", tb.Signature)
}
if _, ok := internalTracebackMethods[tb.Method]; ok {
t.Fatalf("internal slog frame should be filtered out, got %s", tb.Signature)
}
}
}
func TestGetTracebackReturnsNearestUserFrame(t *testing.T) {
tb := captureSingleTracebackForTest()
if tb == nil {
t.Fatal("expected traceback frame")
}
if tb.Method != "captureSingleTracebackForTest" {
t.Fatalf("expected nearest user frame, got %s", tb.Method)
}
if strings.HasPrefix(tb.Signature, "runtime.") {
t.Fatalf("runtime frame should be filtered out, got %s", tb.Signature)
}
if _, ok := internalTracebackMethods[tb.Method]; ok {
t.Fatalf("internal slog frame should be filtered out, got %s", tb.Signature)
}
}
func swapStdout(t *testing.T) *os.File {
t.Helper()
@@ -168,3 +261,11 @@ func swapStdout(t *testing.T) *os.File {
})
return stdoutFile
}
func captureTracebackForTest() []*MethodTraceback {
return getFullTraceback(0)
}
func captureSingleTracebackForTest() *MethodTraceback {
return getTraceback()
}
+56 -32
View File
@@ -2,59 +2,83 @@ package slog
import (
"runtime"
"sort"
"strings"
)
func getTraceback() *MethodTraceback {
caller, _, _, _ := runtime.Caller(5)
details := runtime.FuncForPC(caller)
signature := details.Name()
path, line := details.FileLine(caller)
splitPath := strings.Split(path, "/")
splitSignature := strings.Split(signature, ".")
method := splitSignature[len(splitSignature)-1]
tb := &MethodTraceback{
Filename: splitPath[len(splitPath)-1],
FullPath: path,
Line: line,
Signature: signature,
Method: method,
}
return tb
var internalTracebackMethods = map[string]struct{}{
"Infof": {},
"Info": {},
"Infoln": {},
"Warnf": {},
"Warn": {},
"Warnln": {},
"Errorf": {},
"Error": {},
"Errorln": {},
"Fatalf": {},
"Fatal": {},
"Fatalln": {},
"Debugf": {},
"Debug": {},
"Debugln": {},
"print": {},
"println": {},
"reportWriterError": {},
"getTraceback": {},
"getFullTraceback": {},
}
func getTraceback() *MethodTraceback {
tracebacks := getFullTraceback(0)
if len(tracebacks) == 0 {
return nil
}
return tracebacks[0]
}
func getFullTraceback(skip int) []*MethodTraceback {
pc := make([]uintptr, 15)
runtime.Callers(skip, pc)
n := runtime.Callers(skip+2, pc)
list := make([]*MethodTraceback, 0)
frames := runtime.CallersFrames(pc)
frames := runtime.CallersFrames(pc[:n])
for {
frame, more := frames.Next()
tb := buildMethodTraceback(frame.PC)
if tb != nil && !isInternalTraceback(tb) {
list = append(list, tb)
}
if !more {
break
}
details := runtime.FuncForPC(frame.PC)
signature := details.Name()
path, line := details.FileLine(frame.PC)
splitPath := strings.Split(path, "/")
}
return list
}
func buildMethodTraceback(pc uintptr) *MethodTraceback {
details := runtime.FuncForPC(pc)
if details == nil {
return nil
}
signature := details.Name()
path, line := details.FileLine(pc)
splitPath := strings.Split(path, "/")
splitSignature := strings.Split(signature, ".")
method := splitSignature[len(splitSignature)-1]
tb := &MethodTraceback{
return &MethodTraceback{
Filename: splitPath[len(splitPath)-1],
FullPath: path,
Line: line,
Signature: signature,
Method: method,
}
list = append(list, tb)
}
func isInternalTraceback(tb *MethodTraceback) bool {
if strings.HasPrefix(tb.Signature, "runtime.") {
return true
}
sort.Slice(list, func(i, j int) bool {
return j < i
})
return list
_, ok := internalTracebackMethods[tb.Method]
return ok
}
+1
View File
@@ -1,5 +1,6 @@
package slog
// Map applies f to each element of s and returns the resulting slice.
func Map[T, R any](s []T, f func(T) R) []R {
out := make([]R, len(s))
for i, el := range s {
+34 -6
View File
@@ -10,12 +10,14 @@ import (
"time"
)
// LoggerWriter is the common sink interface used by Logger.
type LoggerWriter interface {
Close() error
Write(p []byte) (n int, err error)
Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error
}
// LoggerTextWriter writes human-readable log records to an io.Writer.
type LoggerTextWriter struct {
LoggerWriter
writer io.Writer
@@ -24,14 +26,19 @@ type LoggerTextWriter struct {
printTime bool
}
// Write forwards raw bytes to the underlying writer.
func (w *LoggerTextWriter) Write(p []byte) (n int, err error) {
return w.writer.Write(p)
}
// Print formats the provided record as text and writes it to the underlying writer.
func (w *LoggerTextWriter) Print(level LogLevel, prefix string, _ []*MethodTraceback, messages ...any) error {
s := BuildString(level, prefix, w.printTime, w.printTraceback, messages...)
_, err := w.Write([]byte(s))
return err
}
// Close closes the owned writer, if any.
func (w *LoggerTextWriter) Close() error {
if w.closer == nil {
return nil
@@ -39,13 +46,15 @@ func (w *LoggerTextWriter) Close() error {
return w.closer.Close()
}
// LoggerJsonWriter write into writer JSON
// LoggerJsonWriter writes structured log records as JSON.
type LoggerJsonWriter struct {
LoggerWriter
writer io.Writer
closer io.Closer
pretty bool
}
// LoggerJsonMessage is the JSON payload emitted by LoggerJsonWriter.
type LoggerJsonMessage struct {
Time time.Time `json:"time"`
Level string `json:"level"`
@@ -54,18 +63,19 @@ type LoggerJsonMessage struct {
Traceback []*MethodTraceback `json:"traceback"`
}
// Write forwards raw bytes to the underlying writer.
func (w *LoggerJsonWriter) Write(data []byte) (int, error) {
return w.writer.Write(data)
}
// 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{
@@ -91,6 +101,8 @@ func (w *LoggerJsonWriter) Print(level LogLevel, prefix string, traceback []*Met
_, err = w.Write(data)
return err
}
// Close closes the owned writer, if any.
func (w *LoggerJsonWriter) Close() error {
if w.closer == nil {
return nil
@@ -98,12 +110,17 @@ func (w *LoggerJsonWriter) Close() error {
return w.closer.Close()
}
// CreateTextWriter wraps an external writer for text output without taking
// ownership of it.
func CreateTextWriter(w io.Writer, printTraceback, printTime bool) *LoggerTextWriter {
writer := &LoggerTextWriter{
writer: w, printTraceback: printTraceback, printTime: printTime,
}
return writer
}
// CreateTextFileWriter creates a text writer for path, creating parent
// directories as needed. The returned writer owns the opened file.
func CreateTextFileWriter(path string, printTraceback, printTime bool) (*LoggerTextWriter, error) {
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
if err != nil {
@@ -117,23 +134,34 @@ func CreateTextFileWriter(path string, printTraceback, printTime bool) (*LoggerT
writer.closer = file
return writer, nil
}
// CreateTextStdoutWriter creates a text writer for os.Stdout without taking
// ownership of stdout.
func CreateTextStdoutWriter(printTraceback, printTime bool) *LoggerTextWriter {
writer := CreateTextWriter(os.Stdout, printTraceback, printTime)
writer.closer = nil
return writer
}
// CreateJsonWriter wraps an external writer for JSON output without taking
// ownership of it.
func CreateJsonWriter(w io.Writer, pretty bool) *LoggerJsonWriter {
writer := &LoggerJsonWriter{
writer: w, pretty: pretty,
}
return writer
}
// CreateJsonStdoutWriter creates a JSON writer for os.Stdout without taking
// ownership of stdout.
func CreateJsonStdoutWriter(pretty bool) *LoggerJsonWriter {
writer := CreateJsonWriter(os.Stdout, pretty)
writer.closer = nil
return writer
}
// CreateJsonFileWriter creates a JSON writer for path, creating parent
// directories as needed. The returned writer owns the opened file.
func CreateJsonFileWriter(path string, pretty bool) (*LoggerJsonWriter, error) {
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
if err != nil {