You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
257 lines
6.4 KiB
257 lines
6.4 KiB
2 months ago
|
package log
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"log/slog"
|
||
|
"runtime"
|
||
|
"slices"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"sync"
|
||
|
"time"
|
||
|
|
||
|
"zestack.dev/color"
|
||
|
)
|
||
|
|
||
|
type TextHandler struct {
|
||
|
opts slog.HandlerOptions
|
||
|
preformatted []byte // data from WithGroup and WithAttrs
|
||
|
groups []string // all groups started from WithGroup
|
||
|
mu *sync.Mutex
|
||
|
out color.Writer
|
||
|
}
|
||
|
|
||
|
func NewTextHandler(out io.Writer, opts *slog.HandlerOptions) *TextHandler {
|
||
|
w, ok := out.(color.Writer)
|
||
|
if !ok {
|
||
|
w = color.NewWriter(out)
|
||
|
}
|
||
|
h := &TextHandler{out: w, mu: &sync.Mutex{}}
|
||
|
if opts != nil {
|
||
|
h.opts = *opts
|
||
|
}
|
||
|
if h.opts.Level == nil {
|
||
|
h.opts.Level = slog.LevelInfo
|
||
|
}
|
||
|
return h
|
||
|
}
|
||
|
|
||
|
func (h *TextHandler) clone() TextHandler {
|
||
|
return TextHandler{
|
||
|
opts: h.opts,
|
||
|
preformatted: h.preformatted[:],
|
||
|
groups: h.groups[:],
|
||
|
mu: h.mu,
|
||
|
out: h.out,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (h *TextHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||
|
minLevel := slog.LevelInfo
|
||
|
if h.opts.Level != nil {
|
||
|
minLevel = h.opts.Level.Level()
|
||
|
}
|
||
|
return level >= minLevel
|
||
|
}
|
||
|
|
||
|
func (h *TextHandler) WithGroup(name string) slog.Handler {
|
||
|
if name == "" {
|
||
|
return h
|
||
|
}
|
||
|
h2 := h.clone()
|
||
|
// Add an unopened group to h2 without modifying h.
|
||
|
h2.groups = make([]string, len(h.groups)+1)
|
||
|
copy(h2.groups, h.groups)
|
||
|
h2.groups[len(h2.groups)-1] = name
|
||
|
return &h2
|
||
|
}
|
||
|
|
||
|
func (h *TextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||
|
if len(attrs) == 0 {
|
||
|
return h
|
||
|
}
|
||
|
h2 := *h
|
||
|
// Force an append to copy the underlying array.
|
||
|
h2.preformatted = slices.Clip(h.preformatted)
|
||
|
h2.groups = slices.Clip(h.groups)
|
||
|
// Pre-format the attributes.
|
||
|
for _, a := range attrs {
|
||
|
h2.preformatted = h2.appendAttr(h2.preformatted, a)
|
||
|
}
|
||
|
return &h2
|
||
|
}
|
||
|
|
||
|
func (h *TextHandler) Handle(_ context.Context, r slog.Record) error {
|
||
|
bufp := allocBuf()
|
||
|
buf := *bufp
|
||
|
defer func() {
|
||
|
*bufp = buf
|
||
|
freeBuf(bufp)
|
||
|
}()
|
||
|
if !r.Time.IsZero() {
|
||
|
buf = h.appendAttr(buf, slog.Time(slog.TimeKey, r.Time))
|
||
|
}
|
||
|
buf = h.appendAttr(buf, slog.Any(slog.LevelKey, r.Level))
|
||
|
buf = h.appendAttr(buf, slog.String(slog.MessageKey, r.Message))
|
||
|
if h.opts.AddSource {
|
||
|
fs := runtime.CallersFrames([]uintptr{r.PC})
|
||
|
f, _ := fs.Next()
|
||
|
// Optimize to minimize allocation.
|
||
|
srcbufp := allocBuf()
|
||
|
defer freeBuf(srcbufp)
|
||
|
*srcbufp = append(*srcbufp, f.File...)
|
||
|
*srcbufp = append(*srcbufp, ':')
|
||
|
*srcbufp = strconv.AppendInt(*srcbufp, int64(f.Line), 10)
|
||
|
if strings.Contains(r.Message, "\n") {
|
||
|
buf = append(buf, ' ')
|
||
|
}
|
||
|
buf = h.appendAttr(buf, slog.String(slog.SourceKey, string(*srcbufp)))
|
||
|
}
|
||
|
if h.opts.AddSource && strings.Contains(r.Message, "\n") {
|
||
|
buf = append(buf, "\n "...)
|
||
|
}
|
||
|
buf = append(buf, sDim...)
|
||
|
// Insert preformatted attributes just after built-in ones.
|
||
|
buf = append(buf, h.preformatted...)
|
||
|
if r.NumAttrs() > 0 {
|
||
|
r.Attrs(func(a slog.Attr) bool {
|
||
|
buf = h.appendAttr(buf, a)
|
||
|
return true
|
||
|
})
|
||
|
}
|
||
|
buf = append(buf, cReset...)
|
||
|
buf = append(buf, "\n"...)
|
||
|
h.mu.Lock()
|
||
|
defer h.mu.Unlock()
|
||
|
_, err := h.out.Write(buf)
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
var (
|
||
|
cHour = color.New(color.FgBlue)
|
||
|
cYear = color.New(color.FgMagenta)
|
||
|
cDim = color.New(color.FgHiBlack)
|
||
|
sDefault = color.Bytes(color.FgHiWhite)
|
||
|
sDim = color.Bytes(color.FgHiBlack)
|
||
|
cReset = color.Bytes(color.Reset)
|
||
|
)
|
||
|
|
||
|
func (h *TextHandler) appendAttr(buf []byte, a slog.Attr) []byte {
|
||
|
// Resolve the Attr's value before doing anything else.
|
||
|
a.Value = a.Value.Resolve()
|
||
|
if rep := h.opts.ReplaceAttr; rep != nil && a.Value.Kind() != slog.KindGroup {
|
||
|
var gs []string
|
||
|
if h.groups != nil {
|
||
|
gs = h.groups[:]
|
||
|
}
|
||
|
// a.Value is resolved before calling ReplaceAttr, so the user doesn't have to.
|
||
|
a = rep(gs, a)
|
||
|
// The ReplaceAttr function may return an unresolved Attr.
|
||
|
a.Value = a.Value.Resolve()
|
||
|
}
|
||
|
// Ignore empty Attrs.
|
||
|
if a.Equal(slog.Attr{}) {
|
||
|
return buf
|
||
|
}
|
||
|
switch a.Key {
|
||
|
case slog.TimeKey:
|
||
|
ts := strings.SplitN(a.Value.Time().Format(time.DateTime), " ", 2)
|
||
|
buf = fmt.Appendf(buf, "%s %s", cYear.Wrap(ts[0]), cHour.Wrap(ts[1]))
|
||
|
buf = append(buf, ' ')
|
||
|
return buf
|
||
|
case slog.LevelKey:
|
||
|
level, prepend := levelToColor(a.Value.Any().(slog.Level))
|
||
|
buf = fmt.Appendf(buf, "%s %s%s %s", cDim.Wrap("|"), prepend, level, cDim.Wrap("|"))
|
||
|
buf = append(buf, ' ')
|
||
|
return buf
|
||
|
case slog.MessageKey:
|
||
|
msgbufp := allocBuf()
|
||
|
defer freeBuf(msgbufp)
|
||
|
var prepend []byte
|
||
|
var lines int
|
||
|
msg := a.Value.String()
|
||
|
buf = append(buf, sDefault...)
|
||
|
for {
|
||
|
if lines == 1 {
|
||
|
buf = fmt.Appendf(buf, "%s\n", cDim.Wrap("↲"))
|
||
|
prepend = append(append(sDim, []byte(" > ")...), cReset...)
|
||
|
*msgbufp = append(prepend, *msgbufp...)
|
||
|
}
|
||
|
*msgbufp = append(*msgbufp, prepend...)
|
||
|
index := strings.IndexByte(msg, '\n')
|
||
|
if index == -1 {
|
||
|
if lines > 1 {
|
||
|
msg = strings.TrimSpace(msg)
|
||
|
}
|
||
|
*msgbufp = append(*msgbufp, msg...)
|
||
|
if lines > 1 {
|
||
|
*msgbufp = append(*msgbufp, '\n')
|
||
|
} else {
|
||
|
*msgbufp = append(*msgbufp, ' ')
|
||
|
}
|
||
|
break
|
||
|
} else {
|
||
|
*msgbufp = append(*msgbufp, strings.TrimSpace(msg[:index])...)
|
||
|
*msgbufp = append(*msgbufp, '\n')
|
||
|
msg = msg[index+1:]
|
||
|
}
|
||
|
lines++
|
||
|
}
|
||
|
buf = append(buf, *msgbufp...)
|
||
|
buf = append(buf, cReset...)
|
||
|
return buf
|
||
|
case slog.SourceKey:
|
||
|
buf = append(buf, cDim.Wrap(a.Key+"=\"").Bytes()...)
|
||
|
buf = append(buf, color.Namespace(a.Value.String()).Bytes()...)
|
||
|
buf = append(buf, cDim.Wrap("\"").Bytes()...)
|
||
|
buf = append(buf, ' ')
|
||
|
return buf
|
||
|
default:
|
||
|
if a.Value.Kind() != slog.KindGroup {
|
||
|
for _, g := range h.groups {
|
||
|
buf = fmt.Appendf(buf, "%s.", g)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
switch a.Value.Kind() {
|
||
|
case slog.KindString:
|
||
|
// Quote string values, to make them easy to parse.
|
||
|
buf = append(buf, a.Key...)
|
||
|
buf = append(buf, "="...)
|
||
|
buf = strconv.AppendQuote(buf, a.Value.String())
|
||
|
buf = append(buf, ' ')
|
||
|
case slog.KindTime:
|
||
|
// Write times in a standard way, without the monotonic time.
|
||
|
buf = append(buf, a.Key...)
|
||
|
buf = append(buf, "="...)
|
||
|
buf = a.Value.Time().AppendFormat(buf, time.RFC3339Nano)
|
||
|
buf = append(buf, ' ')
|
||
|
case slog.KindGroup:
|
||
|
attrs := a.Value.Group()
|
||
|
// Ignore empty groups.
|
||
|
if len(attrs) == 0 {
|
||
|
return buf
|
||
|
}
|
||
|
// If the key is non-empty, write it out and indent the rest of the attrs.
|
||
|
// Otherwise, inline the attrs.
|
||
|
prefix := a.Key
|
||
|
if a.Key != "" {
|
||
|
prefix += "."
|
||
|
}
|
||
|
for _, ga := range attrs {
|
||
|
buf = h.appendAttr(buf, slog.Attr{
|
||
|
Key: prefix + ga.Key,
|
||
|
Value: ga.Value,
|
||
|
})
|
||
|
}
|
||
|
default:
|
||
|
buf = append(buf, a.Key...)
|
||
|
buf = append(buf, "="...)
|
||
|
buf = append(buf, a.Value.String()...)
|
||
|
buf = append(buf, ' ')
|
||
|
}
|
||
|
return buf
|
||
|
}
|