tint v1.0.0

This commit is contained in:
Toastie 2025-03-22 19:34:13 +13:00
parent 3f83708b0a
commit c79174f8e5
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
5 changed files with 1165 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/.idea/

46
buffer.go Normal file
View 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
View file

@ -0,0 +1,3 @@
module toastielab.dev/toastie-stuff/tint
go 1.22

534
handler.go Normal file
View 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
View 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")
)