Skip to content

Instantly share code, notes, and snippets.

@dlsniper
Last active June 6, 2023 08:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dlsniper/014d1e3aecc4288e56cf6dbfe522a990 to your computer and use it in GitHub Desktop.
Save dlsniper/014d1e3aecc4288e56cf6dbfe522a990 to your computer and use it in GitHub Desktop.
Custom logger based on slog
// The code in this file is partially taken from the
// golang.org/x/exp/slog@v0.0.0-20230522175609-2e198f4a06a1
//
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package logger
import (
"context"
"encoding"
"fmt"
"io"
"reflect"
"runtime"
"strconv"
"sync"
"time"
"unicode"
"unicode/utf8"
"golang.org/x/exp/slices"
"golang.org/x/exp/slog"
"golang.org/x/exp/slog/internal/buffer"
)
// tabDelimitedHandler is a Handler derived from the slog.TextHandler implementation.
type tabDelimitedHandler struct {
*commonHandler
}
// NewTextHandler creates a TextHandler that writes to w,
// using the given options.
// If opts is nil, the default options are used.
func newTabDelimited(w io.Writer, opts *slog.HandlerOptions) *tabDelimitedHandler {
if opts == nil {
opts = &slog.HandlerOptions{}
}
return &tabDelimitedHandler{
&commonHandler{
w: w,
opts: *opts,
},
}
}
// Enabled reports whether the handler handles records at the given level.
// The handler ignores records whose level is lower.
func (h *tabDelimitedHandler) Enabled(_ context.Context, level slog.Level) bool {
minLevel := slog.LevelInfo
if h.opts.Level != nil {
minLevel = h.opts.Level.Level()
}
return level >= minLevel
}
// WithAttrs returns a new TextHandler whose attributes consists
// of h's attributes followed by attrs.
func (h *tabDelimitedHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &tabDelimitedHandler{commonHandler: h.commonHandler.withAttrs(attrs)}
}
func (h *tabDelimitedHandler) WithGroup(name string) slog.Handler {
return &tabDelimitedHandler{commonHandler: h.commonHandler.withGroup(name)}
}
// Handle formats its argument Record as a single line of space-separated
// key=value items.
func (h *tabDelimitedHandler) Handle(_ context.Context, r slog.Record) error {
return h.commonHandler.handle(r)
}
type commonHandler struct {
opts slog.HandlerOptions
preformattedAttrs []byte
groupPrefix string // for text: prefix of groups opened in preformatting
groups []string // all groups started from WithGroup
nOpenGroups int // the number of groups opened in preformattedAttrs
mu sync.Mutex
w io.Writer
}
func (h *commonHandler) clone() *commonHandler {
// We can't use assignment because we can't copy the mutex.
return &commonHandler{
opts: h.opts,
preformattedAttrs: slices.Clip(h.preformattedAttrs),
groupPrefix: h.groupPrefix,
groups: slices.Clip(h.groups),
nOpenGroups: h.nOpenGroups,
w: h.w,
}
}
// enabled reports whether l is greater than or equal to the
// minimum level.
func (h *commonHandler) enabled(l slog.Level) bool {
minLevel := slog.LevelInfo
if h.opts.Level != nil {
minLevel = h.opts.Level.Level()
}
return l >= minLevel
}
func (h *commonHandler) withAttrs(as []slog.Attr) *commonHandler {
h2 := h.clone()
// Pre-format the attributes as an optimization.
prefix := buffer.New()
defer prefix.Free()
prefix.WriteString(h.groupPrefix)
state := h2.newHandleState((*buffer.Buffer)(&h2.preformattedAttrs), false, "", prefix)
defer state.free()
if len(h2.preformattedAttrs) > 0 {
state.sep = h.attrSep()
}
state.openGroups()
for _, a := range as {
state.appendAttr(a)
}
// Remember the new prefix for later keys.
h2.groupPrefix = state.prefix.String()
// Remember how many opened groups are in preformattedAttrs,
// so we don't open them again when we handle a Record.
h2.nOpenGroups = len(h2.groups)
return h2
}
func (h *commonHandler) withGroup(name string) *commonHandler {
if name == "" {
return h
}
h2 := h.clone()
h2.groups = append(h2.groups, name)
return h2
}
func (h *commonHandler) handle(r slog.Record) error {
state := h.newHandleState(buffer.New(), true, "", nil)
defer state.free()
// Built-in attributes. They are not in a group.
stateGroups := state.groups
state.groups = nil // So ReplaceAttrs sees no groups instead of the pre groups.
rep := h.opts.ReplaceAttr
// time
if !r.Time.IsZero() {
key := slog.TimeKey
val := r.Time.Round(0) // strip monotonic to match Attr behavior
if rep == nil {
state.appendKey(key)
state.appendTime(val)
} else {
state.appendAttr(slog.Time(key, val))
}
}
// level
key := slog.LevelKey
val := r.Level
if rep == nil {
state.appendKey(key)
state.appendString(val.String())
} else {
state.appendAttr(slog.Any(key, val))
}
// source
if h.opts.AddSource {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
src := &slog.Source{
Function: f.Function,
File: f.File,
Line: f.Line,
}
state.appendAttr(slog.Any(slog.SourceKey, src))
}
key = slog.MessageKey
msg := r.Message
if rep == nil {
state.appendKey(key)
state.appendString(msg)
} else {
state.appendAttr(slog.String(key, msg))
}
state.groups = stateGroups // Restore groups passed to ReplaceAttrs.
state.appendNonBuiltIns(r)
state.buf.WriteByte('\n')
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.w.Write(*state.buf)
return err
}
func (s *handleState) appendNonBuiltIns(r slog.Record) {
// preformatted Attrs
if len(s.h.preformattedAttrs) > 0 {
s.buf.WriteString(s.sep)
s.buf.Write(s.h.preformattedAttrs)
s.sep = s.h.attrSep()
}
// Attrs in Record -- unlike the built-in ones, they are in groups started
// from WithGroup.
s.prefix = buffer.New()
defer s.prefix.Free()
s.prefix.WriteString(s.h.groupPrefix)
s.openGroups()
r.Attrs(func(a slog.Attr) bool {
s.appendAttr(a)
return true
})
}
// attrSep returns the separator between attributes.
func (h *commonHandler) attrSep() string {
return " "
}
// handleState holds state for a single call to commonHandler.handle.
// The initial value of sep determines whether to emit a separator
// before the next key, after which it stays true.
type handleState struct {
h *commonHandler
buf *buffer.Buffer
freeBuf bool // should buf be freed?
sep string // separator to write before next key
prefix *buffer.Buffer // for text: key prefix
groups *[]string // pool-allocated slice of active groups, for ReplaceAttr
}
var groupPool = sync.Pool{New: func() any {
s := make([]string, 0, 10)
return &s
}}
func (h *commonHandler) newHandleState(buf *buffer.Buffer, freeBuf bool, sep string, prefix *buffer.Buffer) handleState {
s := handleState{
h: h,
buf: buf,
freeBuf: freeBuf,
sep: sep,
prefix: prefix,
}
if h.opts.ReplaceAttr != nil {
s.groups = groupPool.Get().(*[]string)
*s.groups = append(*s.groups, h.groups[:h.nOpenGroups]...)
}
return s
}
func (s *handleState) free() {
if s.freeBuf {
s.buf.Free()
}
if gs := s.groups; gs != nil {
*gs = (*gs)[:0]
groupPool.Put(gs)
}
}
func (s *handleState) openGroups() {
for _, n := range s.h.groups[s.h.nOpenGroups:] {
s.openGroup(n)
}
}
// Separator for group names and keys.
const keyComponentSep = '.'
// openGroup starts a new group of attributes
// with the given name.
func (s *handleState) openGroup(name string) {
s.prefix.WriteString(name)
s.prefix.WriteByte(keyComponentSep)
// Collect group names for ReplaceAttr.
if s.groups != nil {
*s.groups = append(*s.groups, name)
}
}
// closeGroup ends the group with the given name.
func (s *handleState) closeGroup(name string) {
(*s.prefix) = (*s.prefix)[:len(*s.prefix)-len(name)-1 /* for keyComponentSep */]
s.sep = s.h.attrSep()
if s.groups != nil {
*s.groups = (*s.groups)[:len(*s.groups)-1]
}
}
// appendAttr appends the Attr's key and value using app.
// It handles replacement and checking for an empty key.
// after replacement).
func (s *handleState) appendAttr(a slog.Attr) {
if rep := s.h.opts.ReplaceAttr; rep != nil && a.Value.Kind() != slog.KindGroup {
var gs []string
if s.groups != nil {
gs = *s.groups
}
// Resolve before calling ReplaceAttr, so the user doesn't have to.
a.Value = a.Value.Resolve()
a = rep(gs, a)
}
a.Value = a.Value.Resolve()
// Elide empty Attrs.
if a.Equal(slog.Attr{}) {
return
}
// Special case: Source.
if v := a.Value; v.Kind() == slog.KindAny {
if src, ok := v.Any().(*slog.Source); ok {
a.Value = slog.StringValue(fmt.Sprintf("%s:%d", src.File, src.Line))
}
}
if a.Value.Kind() == slog.KindGroup {
attrs := a.Value.Group()
// Output only non-empty groups.
if len(attrs) > 0 {
// Inline a group with an empty key.
if a.Key != "" {
s.openGroup(a.Key)
}
for _, aa := range attrs {
s.appendAttr(aa)
}
if a.Key != "" {
s.closeGroup(a.Key)
}
}
} else {
s.appendKey(a.Key)
s.appendValue(a.Value)
}
}
func (s *handleState) appendError(err error) {
s.appendString(fmt.Sprintf("!ERROR:%v", err))
}
func (s *handleState) appendKey(key string) {
s.buf.WriteString(s.sep)
if s.prefix != nil {
// TODO: optimize by avoiding allocation.
s.appendString(string(*s.prefix) + key)
} else {
s.appendString(key)
}
s.buf.WriteByte('=')
s.sep = s.h.attrSep()
}
func (s *handleState) appendString(str string) {
if needsQuoting(str) {
*s.buf = strconv.AppendQuote(*s.buf, str)
} else {
s.buf.WriteString(str)
}
}
func (s *handleState) appendValue(v slog.Value) {
err := appendTextValue(s, v)
if err != nil {
s.appendError(err)
}
}
func (s *handleState) appendTime(t time.Time) {
writeTimeRFC3339Millis(s.buf, t)
}
// This takes half the time of Time.AppendFormat.
func writeTimeRFC3339Millis(buf *buffer.Buffer, t time.Time) {
year, month, day := t.Date()
buf.WritePosIntWidth(year, 4)
buf.WriteByte('-')
buf.WritePosIntWidth(int(month), 2)
buf.WriteByte('-')
buf.WritePosIntWidth(day, 2)
buf.WriteByte('T')
hour, min, sec := t.Clock()
buf.WritePosIntWidth(hour, 2)
buf.WriteByte(':')
buf.WritePosIntWidth(min, 2)
buf.WriteByte(':')
buf.WritePosIntWidth(sec, 2)
ns := t.Nanosecond()
buf.WriteByte('.')
buf.WritePosIntWidth(ns/1e6, 3)
_, offsetSeconds := t.Zone()
if offsetSeconds == 0 {
buf.WriteByte('Z')
} else {
offsetMinutes := offsetSeconds / 60
if offsetMinutes < 0 {
buf.WriteByte('-')
offsetMinutes = -offsetMinutes
} else {
buf.WriteByte('+')
}
buf.WritePosIntWidth(offsetMinutes/60, 2)
buf.WriteByte(':')
buf.WritePosIntWidth(offsetMinutes%60, 2)
}
}
func appendTextValue(s *handleState, v slog.Value) error {
switch v.Kind() {
case slog.KindString:
s.appendString(v.String())
case slog.KindTime:
s.appendTime(v.Time())
case slog.KindAny:
if tm, ok := v.Any().(encoding.TextMarshaler); ok {
data, err := tm.MarshalText()
if err != nil {
return err
}
// TODO: avoid the conversion to string.
s.appendString(string(data))
return nil
}
if bs, ok := byteSlice(v.Any()); ok {
// As of Go 1.19, this only allocates for strings longer than 32 bytes.
s.buf.WriteString(strconv.Quote(string(bs)))
return nil
}
s.appendString(fmt.Sprintf("%+v", v.Any()))
default:
*s.buf = myAppend(v, *s.buf)
}
return nil
}
// append appends a text representation of v to dst.
// v is formatted as with fmt.Sprint.
func myAppend(v slog.Value, dst []byte) []byte {
switch v.Kind() {
case slog.KindString:
return append(dst, []byte(v.String())...)
case slog.KindInt64:
return strconv.AppendInt(dst, v.Int64(), 10)
case slog.KindUint64:
return strconv.AppendUint(dst, v.Uint64(), 10)
case slog.KindFloat64:
return strconv.AppendFloat(dst, v.Float64(), 'g', -1, 64)
case slog.KindBool:
return strconv.AppendBool(dst, v.Bool())
case slog.KindDuration:
return append(dst, v.Duration().String()...)
case slog.KindTime:
return append(dst, v.Time().String()...)
case slog.KindGroup:
return fmt.Append(dst, v.Group())
case slog.KindAny, slog.KindLogValuer:
return fmt.Append(dst, v.Any())
default:
panic(fmt.Sprintf("bad kind: %s", v.Kind()))
}
}
// byteSlice returns its argument as a []byte if the argument's
// underlying type is []byte, along with a second return value of true.
// Otherwise it returns nil, false.
func byteSlice(a any) ([]byte, bool) {
if bs, ok := a.([]byte); ok {
return bs, true
}
// Like Printf's %s, we allow both the slice type and the byte element type to be named.
t := reflect.TypeOf(a)
if t != nil && t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
return reflect.ValueOf(a).Bytes(), true
}
return nil, false
}
func needsQuoting(s string) bool {
if len(s) == 0 {
return true
}
for i := 0; i < len(s); {
b := s[i]
if b < utf8.RuneSelf {
// Quote anything except a backslash that would need quoting in a
// JSON string, as well as space and '='
if b != '\\' && (b == ' ' || b == '=' || !safeSet[b]) {
return true
}
i++
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError || unicode.IsSpace(r) || !unicode.IsPrint(r) {
return true
}
i += size
}
return false
}
// Copied from encoding/json/tables.go.
//
// safeSet holds the value true if the ASCII character with the given array
// position can be represented inside a JSON string without any further
// escaping.
//
// All values are true except for the ASCII control characters (0-31), the
// double quote ("), and the backslash character ("\").
var safeSet = [utf8.RuneSelf]bool{
' ': true,
'!': true,
'"': false,
'#': true,
'$': true,
'%': true,
'&': true,
'\'': true,
'(': true,
')': true,
'*': true,
'+': true,
',': true,
'-': true,
'.': true,
'/': true,
'0': true,
'1': true,
'2': true,
'3': true,
'4': true,
'5': true,
'6': true,
'7': true,
'8': true,
'9': true,
':': true,
';': true,
'<': true,
'=': true,
'>': true,
'?': true,
'@': true,
'A': true,
'B': true,
'C': true,
'D': true,
'E': true,
'F': true,
'G': true,
'H': true,
'I': true,
'J': true,
'K': true,
'L': true,
'M': true,
'N': true,
'O': true,
'P': true,
'Q': true,
'R': true,
'S': true,
'T': true,
'U': true,
'V': true,
'W': true,
'X': true,
'Y': true,
'Z': true,
'[': true,
'\\': false,
']': true,
'^': true,
'_': true,
'`': true,
'a': true,
'b': true,
'c': true,
'd': true,
'e': true,
'f': true,
'g': true,
'h': true,
'i': true,
'j': true,
'k': true,
'l': true,
'm': true,
'n': true,
'o': true,
'p': true,
'q': true,
'r': true,
's': true,
't': true,
'u': true,
'v': true,
'w': true,
'x': true,
'y': true,
'z': true,
'{': true,
'|': true,
'}': true,
'~': true,
'\u007f': true,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment