3 Commits

Author SHA1 Message Date
5d96c6fbae version bump 2026-03-17 15:41:00 +03:00
badee2c598 docs: add godoc and bilingual README 2026-03-17 15:33:11 +03:00
6112a707c7 fix: prevent logger close recursion and unsafe writer closing 2026-03-17 14:43:28 +03:00
12 changed files with 773 additions and 82 deletions

138
README.md
View File

@@ -1,2 +1,138 @@
# 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.
- Explicit ownership rules for writer closing.
## Installation
```bash
go get git.nix13.pw/scuroneko/slog
```
## Quick start
```go
package main
import (
"log"
"git.nix13.pw/scuroneko/slog"
)
func main() {
logger := slog.CreateLogger().
Prefix("API").
Level(slog.DEBUG).
PrintTraceback(true).
JsonPretty(true)
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")
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.
## 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.
## 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).

138
README_ru.md Normal file
View File

@@ -0,0 +1,138 @@
# 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.nix13.pw/scuroneko/slog
```
## Быстрый старт
```go
package main
import (
"log"
"git.nix13.pw/scuroneko/slog"
)
func main() {
logger := slog.CreateLogger().
Prefix("API").
Level(slog.DEBUG).
PrintTraceback(true).
JsonPretty(true)
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")
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 выводится с отступами.
## 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)` после записи сообщения.
## Поведение traceback
- Текстовые writer'ы используют ближайший пользовательский stack frame.
- JSON writer'ы получают полный traceback.
- Внутренние frame'ы `slog` и `runtime` фильтруются из traceback.
## Пример из репозитория
См. [examples/main.go](examples/main.go).
## Лицензия
Проект распространяется под GNU GPLv3. См. [LICENSE](LICENSE).

42
doc.go Normal file
View File

@@ -0,0 +1,42 @@
// 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.
//
// Basic usage:
//
// logger := slog.CreateLogger().
// Prefix("API").
// Level(slog.DEBUG).
// PrintTraceback(true).
// JsonPretty(true)
//
// text := logger.CreateTextStdoutWriter()
// jsonFile, err := logger.CreateJsonFileWriter("logs/app.json")
// if err != nil {
// panic(err)
// }
//
// logger.AddWriters(text, jsonFile)
// logger.Infoln("service started")
//
// 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

View File

@@ -1,21 +1,53 @@
package main
import "git.nix13.pw/scuroneko/slog"
import (
"bytes"
"fmt"
"git.nix13.pw/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)
textStdout := logger.CreateTextStdoutWriter()
jsonStdout := logger.CreateJsonStdoutWriter()
textFile, err := logger.CreateTextFileWriter("logs/text.log")
if err != nil {
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 {
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")
if err := logger.Close(); err != nil {
panic(err)
}
fmt.Println("external buffer contents:")
fmt.Println(externalBuffer.String())
}

4
go.mod
View File

@@ -1,11 +1,11 @@
module git.nix13.pw/scuroneko/slog
go 1.25
go 1.26
require github.com/fatih/color v1.18.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
golang.org/x/sys v0.42.0 // indirect
)

1
go.sum
View File

@@ -7,3 +7,4 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
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=

43
io.go
View File

@@ -5,55 +5,70 @@ 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...)
}
@@ -64,12 +79,18 @@ func (l *Logger) print(level LogLevel, m ...any) {
if l.level.n < level.n {
return
}
if len(l.writers) == 0 {
return
}
tb := getFullTraceback(0)
for _, writer := range l.writers {
if writer == nil {
continue
}
err := writer.Print(level, l.prefix, tb, m...)
if err != nil {
l.Error(err)
l.reportWriterError(err)
}
}
}
@@ -80,12 +101,28 @@ func (l *Logger) println(level LogLevel, m ...any) {
if l.level.n < level.n {
return
}
if len(l.writers) == 0 {
return
}
tb := getFullTraceback(0)
messages := append(append(make([]any, 0, len(m)+1), m...), "\n")
for _, writer := range l.writers {
err := writer.Print(level, l.prefix, tb, append(m, "\n")...)
if writer == nil {
continue
}
err := writer.Print(level, l.prefix, tb, messages...)
if err != nil {
l.Error(err)
l.reportWriterError(err)
}
}
}
// reportWriterError writes internal writer failures directly to stderr to avoid
// re-entering the same logger path that just failed.
func (l *Logger) reportWriterError(err error) {
if err == nil {
return
}
_, _ = fmt.Fprintf(os.Stderr, "[%s] logger writer error: %v\n", l.prefix, err)
}

View File

@@ -1,6 +1,7 @@
package slog
import (
"errors"
"fmt"
"io"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/fatih/color"
)
// Logger routes log records to one or more configured writers.
type Logger struct {
prefix string
level LogLevel
@@ -19,16 +21,19 @@ type Logger struct {
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"`
@@ -37,6 +42,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}
@@ -45,6 +51,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",
@@ -54,69 +61,91 @@ 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
}
// 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 {
if writer == nil {
continue
}
err := writer.Close()
if err != nil {
l.Error(err)
errs = append(errs, err)
}
}
return nil
l.writers = nil
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)
}
// 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 {
@@ -124,6 +153,7 @@ 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 {
args := []string{
fmt.Sprintf("[%s]", prefix),

215
logger_test.go Normal file
View File

@@ -0,0 +1,215 @@
package slog
import (
"bytes"
"errors"
"os"
"strings"
"testing"
)
type stubLoggerWriter struct {
printErr error
closeErr error
printCalls int
closeCalls int
}
func (w *stubLoggerWriter) Close() error {
w.closeCalls++
return w.closeErr
}
func (w *stubLoggerWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (w *stubLoggerWriter) Print(_ LogLevel, _ string, _ []*MethodTraceback, _ ...any) error {
w.printCalls++
return w.printErr
}
func TestLoggerCloseReturnsJoinedErrorsWithoutRelogging(t *testing.T) {
firstErr := errors.New("first close failed")
secondErr := errors.New("second close failed")
first := &stubLoggerWriter{closeErr: firstErr}
second := &stubLoggerWriter{closeErr: secondErr}
logger := CreateLogger().AddWriters(first, second)
err := logger.Close()
if !errors.Is(err, firstErr) {
t.Fatalf("Close() error should include first writer error, got %v", err)
}
if !errors.Is(err, secondErr) {
t.Fatalf("Close() error should include second writer error, got %v", err)
}
if first.printCalls != 0 || second.printCalls != 0 {
t.Fatalf("Close() should not log through writers again, got print calls first=%d second=%d", first.printCalls, second.printCalls)
}
if first.closeCalls != 1 || second.closeCalls != 1 {
t.Fatalf("Close() should close each writer once, got close calls first=%d second=%d", first.closeCalls, second.closeCalls)
}
if err := logger.Close(); err != nil {
t.Fatalf("second Close() should be a no-op, got %v", err)
}
}
func TestLoggerPrintDoesNotRecurseOnWriterError(t *testing.T) {
oldStderr := os.Stderr
stderrFile, err := os.CreateTemp(t.TempDir(), "stderr")
if err != nil {
t.Fatalf("CreateTemp() error = %v", err)
}
os.Stderr = stderrFile
t.Cleanup(func() {
os.Stderr = oldStderr
_ = stderrFile.Close()
})
bad := &stubLoggerWriter{printErr: errors.New("print failed")}
good := &stubLoggerWriter{}
logger := CreateLogger().AddWriters(bad, good)
logger.Error("boom")
if bad.printCalls != 1 {
t.Fatalf("bad writer should be called once, got %d", bad.printCalls)
}
if good.printCalls != 1 {
t.Fatalf("good writer should be called once, got %d", good.printCalls)
}
}
func TestCreateTextWriterCloseOnNonCloserIsNoOp(t *testing.T) {
writer := CreateTextWriter(&bytes.Buffer{}, false, false)
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestCreateTextWriterDoesNotCloseExternalCloser(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "text-writer")
if err != nil {
t.Fatalf("CreateTemp() error = %v", err)
}
t.Cleanup(func() {
_ = file.Close()
})
writer := CreateTextWriter(file, false, false)
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if _, err := file.WriteString("still open"); err != nil {
t.Fatalf("CreateTextWriter() should not close external writers, got %v", err)
}
}
func TestCreateJsonWriterCloseOnNonCloserIsNoOp(t *testing.T) {
writer := CreateJsonWriter(&bytes.Buffer{}, false)
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestCreateJsonWriterDoesNotCloseExternalCloser(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "json-writer")
if err != nil {
t.Fatalf("CreateTemp() error = %v", err)
}
t.Cleanup(func() {
_ = file.Close()
})
writer := CreateJsonWriter(file, false)
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if _, err := file.WriteString("still open"); err != nil {
t.Fatalf("CreateJsonWriter() should not close external writers, got %v", err)
}
}
func TestCreateTextStdoutWriterDoesNotCloseStdout(t *testing.T) {
stdoutFile := swapStdout(t)
writer := CreateTextStdoutWriter(false, false)
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if _, err := stdoutFile.WriteString("still open"); err != nil {
t.Fatalf("stdout writer Close() should not close stdout, got %v", err)
}
}
func TestCreateJsonStdoutWriterDoesNotCloseStdout(t *testing.T) {
stdoutFile := swapStdout(t)
writer := CreateJsonStdoutWriter(false)
if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if _, err := stdoutFile.WriteString("still open"); err != nil {
t.Fatalf("stdout writer Close() should not close stdout, got %v", err)
}
}
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()
oldStdout := os.Stdout
stdoutFile, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatalf("CreateTemp() error = %v", err)
}
os.Stdout = stdoutFile
t.Cleanup(func() {
os.Stdout = oldStdout
_ = stdoutFile.Close()
})
return stdoutFile
}
func captureTracebackForTest() []*MethodTraceback {
return getFullTraceback(0)
}
func captureSingleTracebackForTest() *MethodTraceback {
return getTraceback()
}

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)
}
sort.Slice(list, func(i, j int) bool {
return j < i
})
return list
}
func isInternalTraceback(tb *MethodTraceback) bool {
if strings.HasPrefix(tb.Signature, "runtime.") {
return true
}
_, ok := internalTracebackMethods[tb.Method]
return ok
}

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 {

View File

@@ -1,7 +1,6 @@
package slog
import (
"bufio"
"encoding/json"
"fmt"
"io"
@@ -11,42 +10,51 @@ 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
closer io.Closer
printTraceback bool
printTime bool
}
// Write forwards raw bytes to the underlying writer.
func (w *LoggerTextWriter) Write(p []byte) (n int, err error) {
n, err = w.writer.Write(p)
if err != nil {
return n, err
}
err = bufio.NewWriter(w.writer).Flush()
return n, err
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 {
return w.writer.(io.Closer).Close()
if w.closer == nil {
return nil
}
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"`
@@ -55,14 +63,12 @@ type LoggerJsonMessage struct {
Traceback []*MethodTraceback `json:"traceback"`
}
// Write forwards raw bytes to the underlying writer.
func (w *LoggerJsonWriter) Write(data []byte) (int, error) {
n, err := w.writer.Write(data)
if err != nil {
return n, err
}
err = bufio.NewWriter(w.writer).Flush()
return n, err
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)
@@ -97,16 +103,26 @@ 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 {
return w.writer.(io.Closer).Close()
if w.closer == nil {
return nil
}
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 {
@@ -116,21 +132,38 @@ func CreateTextFileWriter(path string, printTraceback, printTime bool) (*LoggerT
if err != nil {
return nil, err
}
return CreateTextWriter(file, printTraceback, printTime), nil
}
func CreateTextStdoutWriter(printTraceback, printTime bool) *LoggerTextWriter {
return CreateTextWriter(os.Stdout, printTraceback, printTime)
writer := CreateTextWriter(file, printTraceback, printTime)
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 {
return CreateJsonWriter(os.Stdout, pretty)
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 {
@@ -140,5 +173,7 @@ func CreateJsonFileWriter(path string, pretty bool) (*LoggerJsonWriter, error) {
if err != nil {
return nil, err
}
return CreateJsonWriter(file, pretty), nil
writer := CreateJsonWriter(file, pretty)
writer.closer = file
return writer, nil
}