tint v1.0.0
This commit is contained in:
parent
3f83708b0a
commit
c79174f8e5
5 changed files with 1165 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/.idea/
|
46
buffer.go
Normal file
46
buffer.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package tint
|
||||
|
||||
import "sync"
|
||||
|
||||
type buffer []byte
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
b := make(buffer, 0, 1024)
|
||||
return (*buffer)(&b)
|
||||
},
|
||||
}
|
||||
|
||||
func newBuffer() *buffer {
|
||||
return bufPool.Get().(*buffer)
|
||||
}
|
||||
|
||||
func (b *buffer) Free() {
|
||||
// To reduce peak allocation, return only smaller buffers to the pool.
|
||||
const maxBufferSize = 16 << 10
|
||||
if cap(*b) <= maxBufferSize {
|
||||
*b = (*b)[:0]
|
||||
bufPool.Put(b)
|
||||
}
|
||||
}
|
||||
func (b *buffer) Write(bytes []byte) (int, error) {
|
||||
*b = append(*b, bytes...)
|
||||
return len(bytes), nil
|
||||
}
|
||||
|
||||
func (b *buffer) WriteByte(char byte) error {
|
||||
*b = append(*b, char)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *buffer) WriteString(str string) (int, error) {
|
||||
*b = append(*b, str...)
|
||||
return len(str), nil
|
||||
}
|
||||
|
||||
func (b *buffer) WriteStringIf(ok bool, str string) (int, error) {
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
return b.WriteString(str)
|
||||
}
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module toastielab.dev/toastie-stuff/tint
|
||||
|
||||
go 1.22
|
534
handler.go
Normal file
534
handler.go
Normal file
|
@ -0,0 +1,534 @@
|
|||
/*
|
||||
Package tint implements a zero-dependency [slog.Handler] that writes tinted
|
||||
(colorized) logs. The output format is inspired by the [zerolog.ConsoleWriter]
|
||||
and [slog.TextHandler].
|
||||
|
||||
The output format can be customized using [Options], which is a drop-in
|
||||
replacement for [slog.HandlerOptions].
|
||||
|
||||
# Customizing Colors
|
||||
|
||||
Colors can be customized using the `Options.LevelColors` and `Options.Colors` attributes.
|
||||
See [tint.Options] for details.
|
||||
|
||||
// ANSI escape codes: https://en.wikipedia.org/wiki/ANSI_escape_code
|
||||
const (
|
||||
ansiFaint = "\033[2m"
|
||||
ansiBrightRed = "\033[91m"
|
||||
ansiBrightRedFaint = "\033[91;2m"
|
||||
ansiBrightGreen = "\033[92m"
|
||||
ansiBrightYellow = "\033[93m"
|
||||
ansiBrightYellowBold = "\033[93;1m"
|
||||
ansiBrightBlueBold = "\033[94;1m"
|
||||
ansiBrightMagenta = "\033[95m"
|
||||
)
|
||||
|
||||
// create a new logger with custom colors
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(w, &tint.Options{
|
||||
LevelColors: map[slog.Level]string{
|
||||
slog.LevelDebug: ansiBrightMagenta,
|
||||
slog.LevelInfo: ansiBrightGreen,
|
||||
slog.LevelWarn: ansiBrightYellow,
|
||||
slog.LevelError: ansiBrightRed,
|
||||
},
|
||||
Colors: map[tint.Kind]string{
|
||||
tint.KindTime: ansiBrightYellowBold,
|
||||
tint.KindSourceFile: ansiFaint,
|
||||
tint.KindSourceSeparator: ansiFaint,
|
||||
tint.KindSourceLine: ansiFaint,
|
||||
tint.KindMessage: ansiBrightBlueBold,
|
||||
tint.KindKey: ansiFaint,
|
||||
tint.KindSeparator: ansiFaint,
|
||||
tint.KindValue: ansiBrightBlueBold,
|
||||
tint.KindErrorKey: ansiBrightRedFaint,
|
||||
tint.KindErrorSeparator: ansiBrightRedFaint,
|
||||
tint.KindErrorValue: ansiBrightRed,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
# Customize Attributes
|
||||
|
||||
Options.ReplaceAttr can be used to alter or drop attributes. If set, it is
|
||||
called on each non-group attribute before it is logged.
|
||||
See [slog.HandlerOptions] for details.
|
||||
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(w, &tint.Options{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey && len(groups) == 0 {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
# Automatically Enable Colors
|
||||
|
||||
Colors are enabled by default and can be disabled using the Options.NoColor
|
||||
attribute. To automatically enable colors based on the terminal capabilities,
|
||||
use e.g. the [go-isatty] package.
|
||||
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(w, &tint.Options{
|
||||
NoColor: !isatty.IsTerminal(w.Fd()),
|
||||
}),
|
||||
)
|
||||
|
||||
# Windows Support
|
||||
|
||||
Color support on Windows can be added by using e.g. the [go-colorable] package.
|
||||
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(colorable.NewColorable(w), nil),
|
||||
)
|
||||
|
||||
[zerolog.ConsoleWriter]: https://pkg.go.dev/github.com/rs/zerolog#ConsoleWriter
|
||||
[go-isatty]: https://pkg.go.dev/github.com/mattn/go-isatty
|
||||
[go-colorable]: https://pkg.go.dev/github.com/mattn/go-colorable
|
||||
*/
|
||||
package tint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ANSI modes
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiFaint = "\033[2m"
|
||||
ansiBrightRed = "\033[91m"
|
||||
ansiBrightGreen = "\033[92m"
|
||||
ansiBrightYellow = "\033[93m"
|
||||
ansiBrightRedFaint = "\033[91;2m"
|
||||
)
|
||||
|
||||
const errKey = "err"
|
||||
|
||||
var (
|
||||
defaultLevel = slog.LevelInfo
|
||||
defaultTimeFormat = time.StampMilli
|
||||
defaultLevelColors = map[slog.Level]string{
|
||||
slog.LevelDebug: ansiFaint,
|
||||
slog.LevelInfo: ansiBrightGreen,
|
||||
slog.LevelWarn: ansiBrightYellow,
|
||||
slog.LevelError: ansiBrightRed,
|
||||
}
|
||||
defaultColors = map[Kind]string{
|
||||
KindTime: ansiFaint,
|
||||
KindSourceFile: ansiFaint,
|
||||
KindSourceSeparator: ansiFaint,
|
||||
KindSourceLine: ansiFaint,
|
||||
KindMessage: "",
|
||||
KindKey: ansiFaint,
|
||||
KindSeparator: ansiFaint,
|
||||
KindValue: "",
|
||||
KindErrorKey: ansiBrightRedFaint,
|
||||
KindErrorSeparator: ansiBrightRedFaint,
|
||||
KindErrorValue: ansiBrightRed,
|
||||
}
|
||||
)
|
||||
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindTime Kind = iota
|
||||
KindSourceFile
|
||||
KindSourceSeparator
|
||||
KindSourceLine
|
||||
KindMessage
|
||||
KindKey
|
||||
KindSeparator
|
||||
KindValue
|
||||
KindErrorKey
|
||||
KindErrorSeparator
|
||||
KindErrorValue
|
||||
)
|
||||
|
||||
// Options for a slog.Handler that writes tinted logs. A zero Options consists
|
||||
// entirely of default values.
|
||||
//
|
||||
// Options can be used as a drop-in replacement for [slog.HandlerOptions].
|
||||
type Options struct {
|
||||
// Enable source code location (Default: false)
|
||||
AddSource bool
|
||||
|
||||
// Minimum level to log (Default: slog.LevelInfo)
|
||||
Level slog.Leveler
|
||||
|
||||
// ReplaceAttr is called to rewrite each non-group attribute before it is logged.
|
||||
// See https://pkg.go.dev/log/slog#HandlerOptions for details.
|
||||
ReplaceAttr func(groups []string, attr slog.Attr) slog.Attr
|
||||
|
||||
// Time format (Default: time.StampMilli)
|
||||
TimeFormat string
|
||||
|
||||
// Disable color (Default: false)
|
||||
NoColor bool
|
||||
|
||||
// Level colors (Default: debug: faint, info: green, warn: yellow, error: red)
|
||||
LevelColors map[slog.Level]string
|
||||
|
||||
// Colors of certain parts of the log message
|
||||
Colors map[Kind]string
|
||||
}
|
||||
|
||||
// NewHandler creates a [slog.Handler] that writes tinted logs to Writer w,
|
||||
// using the default options. If opts is nil, the default options are used.
|
||||
func NewHandler(w io.Writer, opts *Options) slog.Handler {
|
||||
h := &handler{
|
||||
w: w,
|
||||
level: defaultLevel,
|
||||
timeFormat: defaultTimeFormat,
|
||||
levelColors: defaultLevelColors,
|
||||
colors: defaultColors,
|
||||
}
|
||||
if opts == nil {
|
||||
return h
|
||||
}
|
||||
|
||||
h.addSource = opts.AddSource
|
||||
if opts.Level != nil {
|
||||
h.level = opts.Level
|
||||
}
|
||||
h.replaceAttr = opts.ReplaceAttr
|
||||
if opts.TimeFormat != "" {
|
||||
h.timeFormat = opts.TimeFormat
|
||||
}
|
||||
h.noColor = opts.NoColor
|
||||
if opts.LevelColors != nil {
|
||||
h.levelColors = opts.LevelColors
|
||||
}
|
||||
if opts.Colors != nil {
|
||||
h.colors = opts.Colors
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// handler implements a [slog.Handler].
|
||||
type handler struct {
|
||||
attrsPrefix string
|
||||
groupPrefix string
|
||||
groups []string
|
||||
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
|
||||
addSource bool
|
||||
level slog.Leveler
|
||||
replaceAttr func([]string, slog.Attr) slog.Attr
|
||||
timeFormat string
|
||||
noColor bool
|
||||
levelColors map[slog.Level]string
|
||||
colors map[Kind]string
|
||||
}
|
||||
|
||||
func (h *handler) clone() *handler {
|
||||
return &handler{
|
||||
attrsPrefix: h.attrsPrefix,
|
||||
groupPrefix: h.groupPrefix,
|
||||
groups: h.groups,
|
||||
w: h.w,
|
||||
addSource: h.addSource,
|
||||
level: h.level,
|
||||
replaceAttr: h.replaceAttr,
|
||||
timeFormat: h.timeFormat,
|
||||
noColor: h.noColor,
|
||||
levelColors: h.levelColors,
|
||||
colors: h.colors,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level.Level()
|
||||
}
|
||||
|
||||
func (h *handler) Handle(_ context.Context, r slog.Record) error {
|
||||
// get a buffer from the sync pool
|
||||
buf := newBuffer()
|
||||
defer buf.Free()
|
||||
|
||||
rep := h.replaceAttr
|
||||
|
||||
// write time
|
||||
if !r.Time.IsZero() {
|
||||
val := r.Time.Round(0) // strip monotonic to match Attr behavior
|
||||
if rep == nil {
|
||||
h.appendTime(buf, r.Time)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.Time(slog.TimeKey, val)); a.Key != "" {
|
||||
if a.Value.Kind() == slog.KindTime {
|
||||
h.appendTime(buf, a.Value.Time())
|
||||
} else {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
}
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// write level
|
||||
if rep == nil {
|
||||
h.appendLevel(buf, r.Level)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.Any(slog.LevelKey, r.Level)); a.Key != "" {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
|
||||
// write source
|
||||
if h.addSource {
|
||||
fs := runtime.CallersFrames([]uintptr{r.PC})
|
||||
f, _ := fs.Next()
|
||||
if f.File != "" {
|
||||
src := &slog.Source{
|
||||
Function: f.Function,
|
||||
File: f.File,
|
||||
Line: f.Line,
|
||||
}
|
||||
|
||||
if rep == nil {
|
||||
h.appendSource(buf, src)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.Any(slog.SourceKey, src)); a.Key != "" {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// write message
|
||||
if rep == nil {
|
||||
buf.WriteStringIf(!h.noColor, h.colors[KindMessage])
|
||||
buf.WriteString(r.Message)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.String(slog.MessageKey, r.Message)); a.Key != "" {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
|
||||
// write handler attributes
|
||||
if len(h.attrsPrefix) > 0 {
|
||||
buf.WriteString(h.attrsPrefix)
|
||||
}
|
||||
|
||||
// write attributes
|
||||
r.Attrs(func(attr slog.Attr) bool {
|
||||
h.appendAttr(buf, attr, h.groupPrefix, h.groups)
|
||||
return true
|
||||
})
|
||||
|
||||
if len(*buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
(*buf)[len(*buf)-1] = '\n' // replace last space with newline
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
_, err := h.w.Write(*buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
if len(attrs) == 0 {
|
||||
return h
|
||||
}
|
||||
h2 := h.clone()
|
||||
|
||||
buf := newBuffer()
|
||||
defer buf.Free()
|
||||
|
||||
// write attributes to buffer
|
||||
for _, attr := range attrs {
|
||||
h.appendAttr(buf, attr, h.groupPrefix, h.groups)
|
||||
}
|
||||
h2.attrsPrefix = h.attrsPrefix + string(*buf)
|
||||
return h2
|
||||
}
|
||||
|
||||
func (h *handler) WithGroup(name string) slog.Handler {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
h2 := h.clone()
|
||||
h2.groupPrefix += name + "."
|
||||
h2.groups = append(h2.groups, name)
|
||||
return h2
|
||||
}
|
||||
|
||||
func (h *handler) appendTime(buf *buffer, t time.Time) {
|
||||
buf.WriteStringIf(!h.noColor, h.colors[KindTime])
|
||||
*buf = t.AppendFormat(*buf, h.timeFormat)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
func (h *handler) appendLevel(buf *buffer, level slog.Level) {
|
||||
switch {
|
||||
case level < slog.LevelInfo:
|
||||
buf.WriteStringIf(!h.noColor, h.levelColors[level])
|
||||
buf.WriteString("DBG")
|
||||
appendLevelDelta(buf, level-slog.LevelDebug)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
case level < slog.LevelWarn:
|
||||
buf.WriteStringIf(!h.noColor, h.levelColors[level])
|
||||
buf.WriteString("INF")
|
||||
appendLevelDelta(buf, level-slog.LevelInfo)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
case level < slog.LevelError:
|
||||
buf.WriteStringIf(!h.noColor, h.levelColors[level])
|
||||
buf.WriteString("WRN")
|
||||
appendLevelDelta(buf, level-slog.LevelWarn)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
default:
|
||||
buf.WriteStringIf(!h.noColor, h.levelColors[level])
|
||||
buf.WriteString("ERR")
|
||||
appendLevelDelta(buf, level-slog.LevelError)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
}
|
||||
|
||||
func appendLevelDelta(buf *buffer, delta slog.Level) {
|
||||
if delta == 0 {
|
||||
return
|
||||
} else if delta > 0 {
|
||||
buf.WriteByte('+')
|
||||
}
|
||||
*buf = strconv.AppendInt(*buf, int64(delta), 10)
|
||||
}
|
||||
|
||||
func (h *handler) appendSource(buf *buffer, src *slog.Source) {
|
||||
dir, file := filepath.Split(src.File)
|
||||
|
||||
buf.WriteStringIf(!h.noColor, h.colors[KindSourceFile])
|
||||
buf.WriteString(filepath.Join(filepath.Base(dir), file))
|
||||
buf.WriteStringIf(!h.noColor, ansiReset+h.colors[KindSourceSeparator])
|
||||
buf.WriteByte(':')
|
||||
buf.WriteStringIf(!h.noColor, ansiReset+h.colors[KindSourceLine])
|
||||
buf.WriteString(strconv.Itoa(src.Line))
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
func (h *handler) appendAttr(buf *buffer, attr slog.Attr, groupsPrefix string, groups []string) {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if rep := h.replaceAttr; rep != nil && attr.Value.Kind() != slog.KindGroup {
|
||||
attr = rep(groups, attr)
|
||||
attr.Value = attr.Value.Resolve()
|
||||
}
|
||||
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
return
|
||||
}
|
||||
|
||||
if attr.Value.Kind() == slog.KindGroup {
|
||||
if attr.Key != "" {
|
||||
groupsPrefix += attr.Key + "."
|
||||
groups = append(groups, attr.Key)
|
||||
}
|
||||
for _, groupAttr := range attr.Value.Group() {
|
||||
h.appendAttr(buf, groupAttr, groupsPrefix, groups)
|
||||
}
|
||||
} else if err, ok := attr.Value.Any().(error); ok && attr.Key == errKey {
|
||||
h.appendError(buf, err, groupsPrefix)
|
||||
buf.WriteByte(' ')
|
||||
} else {
|
||||
h.appendKey(buf, attr.Key, groupsPrefix)
|
||||
h.appendValue(buf, attr.Value, true)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) appendKey(buf *buffer, key, groups string) {
|
||||
buf.WriteStringIf(!h.noColor, h.colors[KindKey])
|
||||
appendString(buf, groups+key, true)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset+h.colors[KindSeparator])
|
||||
buf.WriteByte('=')
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
func (h *handler) appendValue(buf *buffer, v slog.Value, quote bool) {
|
||||
buf.WriteStringIf(!h.noColor, h.colors[KindValue])
|
||||
switch v.Kind() {
|
||||
case slog.KindString:
|
||||
appendString(buf, v.String(), quote)
|
||||
case slog.KindInt64:
|
||||
*buf = strconv.AppendInt(*buf, v.Int64(), 10)
|
||||
case slog.KindUint64:
|
||||
*buf = strconv.AppendUint(*buf, v.Uint64(), 10)
|
||||
case slog.KindFloat64:
|
||||
*buf = strconv.AppendFloat(*buf, v.Float64(), 'g', -1, 64)
|
||||
case slog.KindBool:
|
||||
*buf = strconv.AppendBool(*buf, v.Bool())
|
||||
case slog.KindDuration:
|
||||
appendString(buf, v.Duration().String(), quote)
|
||||
case slog.KindTime:
|
||||
appendString(buf, v.Time().String(), quote)
|
||||
case slog.KindAny:
|
||||
switch cv := v.Any().(type) {
|
||||
case slog.Level:
|
||||
h.appendLevel(buf, cv)
|
||||
case encoding.TextMarshaler:
|
||||
data, err := cv.MarshalText()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
appendString(buf, string(data), quote)
|
||||
case *slog.Source:
|
||||
h.appendSource(buf, cv)
|
||||
default:
|
||||
appendString(buf, fmt.Sprintf("%+v", v.Any()), quote)
|
||||
}
|
||||
}
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
func (h *handler) appendError(buf *buffer, err error, groupsPrefix string) {
|
||||
buf.WriteStringIf(!h.noColor, h.colors[KindErrorKey])
|
||||
appendString(buf, groupsPrefix+errKey, true)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset+h.colors[KindErrorSeparator])
|
||||
buf.WriteByte('=')
|
||||
buf.WriteStringIf(!h.noColor, ansiReset+h.colors[KindErrorValue])
|
||||
appendString(buf, err.Error(), true)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
func appendString(buf *buffer, s string, quote bool) {
|
||||
if quote && needsQuoting(s) {
|
||||
*buf = strconv.AppendQuote(*buf, s)
|
||||
} else {
|
||||
buf.WriteString(s)
|
||||
}
|
||||
}
|
||||
|
||||
func needsQuoting(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, r := range s {
|
||||
if unicode.IsSpace(r) || r == '"' || r == '=' || !unicode.IsPrint(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Err returns slog.Any("err", err)
|
||||
func Err(err error) slog.Attr {
|
||||
return slog.Any(errKey, err)
|
||||
}
|
581
handler_test.go
Normal file
581
handler_test.go
Normal file
|
@ -0,0 +1,581 @@
|
|||
package tint_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"toastielab.dev/toastie-stuff/tint"
|
||||
)
|
||||
|
||||
var faketime = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
|
||||
|
||||
func Example() {
|
||||
slog.SetDefault(slog.New(tint.NewHandler(os.Stderr, &tint.Options{
|
||||
Level: slog.LevelDebug,
|
||||
TimeFormat: time.Kitchen,
|
||||
})))
|
||||
|
||||
slog.Info("Starting server", "addr", ":8080", "env", "production")
|
||||
slog.Debug("Connected to DB", "db", "myapp", "host", "localhost:5432")
|
||||
slog.Warn("Slow request", "method", "GET", "path", "/users", "duration", 497*time.Millisecond)
|
||||
slog.Error("DB connection lost", tint.Err(errors.New("connection reset")), "db", "myapp")
|
||||
// Output:
|
||||
}
|
||||
|
||||
// Run test with "faketime" tag:
|
||||
//
|
||||
// TZ="" go test -tags=faketime
|
||||
func TestHandler(t *testing.T) {
|
||||
if !faketime.Equal(time.Now()) {
|
||||
t.Skip(`skipping test; run with "-tags=faketime"`)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
Opts *tint.Options
|
||||
F func(l *slog.Logger)
|
||||
Want string
|
||||
}{
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test key=val`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Error("test", tint.Err(errors.New("fail")))
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 ERR test err=fail`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", slog.Group("group", slog.String("key", "val"), tint.Err(errors.New("fail"))))
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test group.key=val group.err=fail`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.WithGroup("group").Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test group.key=val`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.With("key", "val").Info("test", "key2", "val2")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test key=val key2=val2`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "k e y", "v a l")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test "k e y"="v a l"`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.WithGroup("g r o u p").Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test "g r o u p.key"=val`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "slice", []string{"a", "b", "c"}, "map", map[string]int{"a": 1, "b": 2, "c": 3})
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test slice="[a b c]" map="map[a:1 b:2 c:3]"`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
AddSource: true,
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF tint/handler_test.go:100 test key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
TimeFormat: time.Kitchen,
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `11:00PM INF test key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: drop(slog.TimeKey),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `INF test key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: drop(slog.LevelKey),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 test key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: drop(slog.MessageKey),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: drop(slog.TimeKey, slog.LevelKey, slog.MessageKey),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: drop("key"),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: drop("key"),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.WithGroup("group").Info("test", "key", "val", "key2", "val2")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test group.key=val group.key2=val2`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == "key" && len(groups) == 1 && groups[0] == "group" {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.WithGroup("group").Info("test", "key", "val", "key2", "val2")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test group.key2=val2`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: replace(slog.IntValue(42), slog.TimeKey),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `42 INF test key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: replace(slog.StringValue("INFO"), slog.LevelKey),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INFO test key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: replace(slog.IntValue(42), slog.MessageKey),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF 42 key=val`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: replace(slog.IntValue(42), "key"),
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.With("key", "val").Info("test", "key2", "val2")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test key=42 key2=val2`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
return slog.Attr{}
|
||||
},
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "val")
|
||||
},
|
||||
Want: ``,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", "")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test key=""`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "", "val")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test ""=val`,
|
||||
},
|
||||
{
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "", "")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test ""=""`,
|
||||
},
|
||||
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/issues/8
|
||||
F: func(l *slog.Logger) {
|
||||
l.Log(context.TODO(), slog.LevelInfo+1, "test")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF+1 test`,
|
||||
},
|
||||
{
|
||||
Opts: &tint.Options{
|
||||
Level: slog.LevelDebug - 1,
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Log(context.TODO(), slog.LevelDebug-1, "test")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 DBG-1 test`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/issues/12
|
||||
F: func(l *slog.Logger) {
|
||||
l.Error("test", slog.Any("error", errors.New("fail")))
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 ERR test error=fail`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/issues/15
|
||||
F: func(l *slog.Logger) {
|
||||
l.Error("test", tint.Err(nil))
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 ERR test err=<nil>`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/pull/26
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey && len(groups) == 0 {
|
||||
return slog.Time(slog.TimeKey, a.Value.Time().Add(24*time.Hour))
|
||||
}
|
||||
return a
|
||||
},
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Error("test")
|
||||
},
|
||||
Want: `Nov 11 23:00:00.000 ERR test`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/pull/27
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "a", "b", slog.Group("", slog.String("c", "d")), "e", "f")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test a=b c=d e=f`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/pull/30
|
||||
// drop built-in attributes in a grouped log
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: drop(slog.TimeKey, slog.LevelKey, slog.MessageKey, slog.SourceKey),
|
||||
AddSource: true,
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.WithGroup("group").Info("test", "key", "val")
|
||||
},
|
||||
Want: `group.key=val`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/issues/36
|
||||
Opts: &tint.Options{
|
||||
ReplaceAttr: func(g []string, a slog.Attr) slog.Attr {
|
||||
if len(g) == 0 && a.Key == slog.LevelKey {
|
||||
_ = a.Value.Any().(slog.Level)
|
||||
}
|
||||
return a
|
||||
},
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/issues/37
|
||||
Opts: &tint.Options{
|
||||
AddSource: true,
|
||||
ReplaceAttr: func(g []string, a slog.Attr) slog.Attr {
|
||||
return a
|
||||
},
|
||||
},
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test")
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF tint/handler_test.go:327 test`,
|
||||
},
|
||||
{ // https://toastielab.dev/toastie-stuff/tint/issues/44
|
||||
F: func(l *slog.Logger) {
|
||||
l = l.WithGroup("group")
|
||||
l.Error("test", tint.Err(errTest))
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 ERR test group.err=fail`,
|
||||
},
|
||||
{ // https://github.com/lmittmann/tint/issues/55
|
||||
F: func(l *slog.Logger) {
|
||||
l.Info("test", "key", struct {
|
||||
A int
|
||||
B *string
|
||||
}{A: 123})
|
||||
},
|
||||
Want: `Nov 10 23:00:00.000 INF test key="{A:123 B:<nil>}"`,
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if test.Opts == nil {
|
||||
test.Opts = &tint.Options{}
|
||||
}
|
||||
test.Opts.NoColor = true
|
||||
l := slog.New(tint.NewHandler(&buf, test.Opts))
|
||||
test.F(l)
|
||||
|
||||
got := strings.TrimRight(buf.String(), "\n")
|
||||
if test.Want != got {
|
||||
t.Fatalf("(-want +got)\n- %s\n+ %s", test.Want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// drop returns a ReplaceAttr that drops the given keys.
|
||||
func drop(keys ...string) func([]string, slog.Attr) slog.Attr {
|
||||
return func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) > 0 {
|
||||
return a
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if a.Key == key {
|
||||
a = slog.Attr{}
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
func replace(new slog.Value, keys ...string) func([]string, slog.Attr) slog.Attr {
|
||||
return func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) > 0 {
|
||||
return a
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if a.Key == key {
|
||||
a.Value = new
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceAttr(t *testing.T) {
|
||||
tests := [][]any{
|
||||
{},
|
||||
{"key", "val"},
|
||||
{"key", "val", slog.Group("group", "key2", "val2")},
|
||||
{"key", "val", slog.Group("group", "key2", "val2", slog.Group("group2", "key3", "val3"))},
|
||||
}
|
||||
|
||||
type replaceAttrParams struct {
|
||||
Groups []string
|
||||
Attr slog.Attr
|
||||
}
|
||||
|
||||
replaceAttrRecorder := func(record *[]replaceAttrParams) func([]string, slog.Attr) slog.Attr {
|
||||
return func(groups []string, a slog.Attr) slog.Attr {
|
||||
*record = append(*record, replaceAttrParams{groups, a})
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
slogRecord := make([]replaceAttrParams, 0)
|
||||
slogLogger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{
|
||||
ReplaceAttr: replaceAttrRecorder(&slogRecord),
|
||||
}))
|
||||
slogLogger.Log(context.TODO(), slog.LevelInfo, "", test...)
|
||||
|
||||
tintRecord := make([]replaceAttrParams, 0)
|
||||
tintLogger := slog.New(tint.NewHandler(io.Discard, &tint.Options{
|
||||
ReplaceAttr: replaceAttrRecorder(&tintRecord),
|
||||
}))
|
||||
tintLogger.Log(context.TODO(), slog.LevelInfo, "", test...)
|
||||
|
||||
if !slices.EqualFunc(slogRecord, tintRecord, func(a, b replaceAttrParams) bool {
|
||||
return slices.Equal(a.Groups, b.Groups) && a.Attr.Equal(b.Attr)
|
||||
}) {
|
||||
t.Fatalf("(-want +got)\n- %v\n+ %v", slogRecord, tintRecord)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// See https://github.com/golang/exp/blob/master/slog/benchmarks/benchmarks_test.go#L25
|
||||
//
|
||||
// Run e.g.:
|
||||
//
|
||||
// go test -bench=. -count=10 | benchstat -col /h /dev/stdin
|
||||
func BenchmarkLogAttrs(b *testing.B) {
|
||||
handler := []struct {
|
||||
Name string
|
||||
H slog.Handler
|
||||
}{
|
||||
{"tint", tint.NewHandler(io.Discard, nil)},
|
||||
{"text", slog.NewTextHandler(io.Discard, nil)},
|
||||
{"json", slog.NewJSONHandler(io.Discard, nil)},
|
||||
{"discard", new(discarder)},
|
||||
}
|
||||
|
||||
benchmarks := []struct {
|
||||
Name string
|
||||
F func(*slog.Logger)
|
||||
}{
|
||||
{
|
||||
"5 args",
|
||||
func(logger *slog.Logger) {
|
||||
logger.LogAttrs(context.TODO(), slog.LevelInfo, testMessage,
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
"5 args custom level",
|
||||
func(logger *slog.Logger) {
|
||||
logger.LogAttrs(context.TODO(), slog.LevelInfo+1, testMessage,
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
"10 args",
|
||||
func(logger *slog.Logger) {
|
||||
logger.LogAttrs(context.TODO(), slog.LevelInfo, testMessage,
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
"40 args",
|
||||
func(logger *slog.Logger) {
|
||||
logger.LogAttrs(context.TODO(), slog.LevelInfo, testMessage,
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
slog.String("string", testString),
|
||||
slog.Int("status", testInt),
|
||||
slog.Duration("duration", testDuration),
|
||||
slog.Time("time", testTime),
|
||||
slog.Any("error", errTest),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, h := range handler {
|
||||
b.Run("h="+h.Name, func(b *testing.B) {
|
||||
for _, bench := range benchmarks {
|
||||
b.Run(bench.Name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
logger := slog.New(h.H)
|
||||
for i := 0; i < b.N; i++ {
|
||||
bench.F(logger)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// discarder is a slog.Handler that discards all records.
|
||||
type discarder struct{}
|
||||
|
||||
func (*discarder) Enabled(context.Context, slog.Level) bool { return true }
|
||||
func (*discarder) Handle(context.Context, slog.Record) error { return nil }
|
||||
func (d *discarder) WithAttrs(attrs []slog.Attr) slog.Handler { return d }
|
||||
func (d *discarder) WithGroup(name string) slog.Handler { return d }
|
||||
|
||||
var (
|
||||
testMessage = "Test logging, but use a somewhat realistic message length."
|
||||
testTime = time.Date(2022, time.May, 1, 0, 0, 0, 0, time.UTC)
|
||||
testString = "7e3b3b2aaeff56a7108fe11e154200dd/7819479873059528190"
|
||||
testInt = 32768
|
||||
testDuration = 23 * time.Second
|
||||
errTest = errors.New("fail")
|
||||
)
|
Loading…
Add table
Reference in a new issue