databricks-cli/libs/log/handler/friendly.go

249 lines
5.8 KiB
Go

package handler
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"time"
"github.com/databricks/cli/libs/log"
)
// friendlyHandler implements a custom [slog.Handler] that writes
// human readable (and colorized) log lines to a terminal.
//
// The implementation is based on the guide at:
// https://github.com/golang/example/blob/master/slog-handler-guide/README.md
type friendlyHandler struct {
opts Options
goas []groupOrAttrs
mu *sync.Mutex
out io.Writer
// List of colors to use for formatting.
ttyColors
// Cache (colorized) level strings.
levelTrace string
levelDebug string
levelInfo string
levelWarn string
levelError string
}
// groupOrAttrs holds either a group name or a list of slog.Attrs.
type groupOrAttrs struct {
group string // group name if non-empty
attrs []slog.Attr // attrs if non-empty
}
func NewFriendlyHandler(out io.Writer, opts *Options) slog.Handler {
h := &friendlyHandler{out: out, mu: &sync.Mutex{}}
if opts != nil {
h.opts = *opts
}
if h.opts.Level == nil {
h.opts.Level = slog.LevelInfo
}
h.ttyColors = newColors(opts.Color)
// Cache (colorized) level strings.
// The colors to use for each level are configured in `colors.go`.
h.levelTrace = h.sprintf(ttyColorLevelTrace, "%5s", "TRACE")
h.levelDebug = h.sprintf(ttyColorLevelDebug, "%5s", "DEBUG")
h.levelInfo = h.sprintf(ttyColorLevelInfo, "%5s", "INFO")
h.levelWarn = h.sprintf(ttyColorLevelWarn, "%5s", "WARN")
h.levelError = h.sprintf(ttyColorLevelError, "%5s", "ERROR")
return h
}
func (h *friendlyHandler) sprint(color ttyColor, args ...any) string {
return h.ttyColors[color].Sprint(args...)
}
func (h *friendlyHandler) sprintf(color ttyColor, format string, args ...any) string {
return h.ttyColors[color].Sprintf(format, args...)
}
func (h *friendlyHandler) coloredLevel(r slog.Record) string {
switch r.Level {
case log.LevelTrace:
return h.levelTrace
case log.LevelDebug:
return h.levelDebug
case log.LevelInfo:
return h.levelInfo
case log.LevelWarn:
return h.levelWarn
case log.LevelError:
return h.levelError
}
return ""
}
// Enabled implements slog.Handler.
func (h *friendlyHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.opts.Level.Level()
}
type handleState struct {
h *friendlyHandler
buf []byte
prefix string
// Keep stack of groups to pass to [slog.ReplaceAttr] function.
groups []string
}
func (h *friendlyHandler) handleState() *handleState {
return &handleState{
h: h,
buf: make([]byte, 0, 1024),
prefix: "",
}
}
func (s *handleState) openGroup(name string) {
s.groups = append(s.groups, name)
s.prefix += name + "."
}
func (s *handleState) closeGroup(name string) {
s.prefix = s.prefix[:len(s.prefix)-len(name)-1]
s.groups = s.groups[:len(s.groups)-1]
}
func (s *handleState) append(args ...any) {
s.buf = fmt.Append(s.buf, args...)
}
func (s *handleState) appendf(format string, args ...any) {
s.buf = fmt.Appendf(s.buf, format, args...)
}
func (s *handleState) appendAttr(a slog.Attr) {
if rep := s.h.opts.ReplaceAttr; rep != nil && a.Value.Kind() != slog.KindGroup {
// Resolve before calling ReplaceAttr, so the user doesn't have to.
a.Value = a.Value.Resolve()
a = rep(s.groups, a)
}
// Resolve the Attr's value before doing anything else.
a.Value = a.Value.Resolve()
// Ignore empty Attrs.
if a.Equal(slog.Attr{}) {
return
}
switch a.Value.Kind() {
case slog.KindGroup:
attrs := a.Value.Group()
// Output only non-empty groups.
if len(attrs) > 0 {
if a.Key != "" {
s.openGroup(a.Key)
}
for _, aa := range attrs {
s.appendAttr(aa)
}
if a.Key != "" {
s.closeGroup(a.Key)
}
}
case slog.KindTime:
s.append(
" ",
s.h.sprint(ttyColorAttrKey, s.prefix, a.Key),
s.h.sprint(ttyColorAttrSeparator, "="),
s.h.sprint(ttyColorAttrValue, a.Value.Time().Format(time.RFC3339Nano)),
)
default:
str := a.Value.String()
format := "%s"
// Quote values wih spaces, to make them easy to parse.
if strings.ContainsAny(str, " \t\n") {
format = "%q"
}
s.append(
" ",
s.h.sprint(ttyColorAttrKey, s.prefix, a.Key),
s.h.sprint(ttyColorAttrSeparator, "="),
s.h.sprint(ttyColorAttrValue, fmt.Sprintf(format, str)),
)
}
}
// Handle implements slog.Handler.
func (h *friendlyHandler) Handle(ctx context.Context, r slog.Record) error {
state := h.handleState()
state.append(h.sprintf(ttyColorTime, "%02d:%02d:%02d ", r.Time.Hour(), r.Time.Minute(), r.Time.Second()))
state.appendf("%s ", h.coloredLevel(r))
state.append(h.sprint(ttyColorMessage, r.Message))
// Handle state from WithGroup and WithAttrs.
goas := h.goas
if r.NumAttrs() == 0 {
// If the record has no Attrs, remove groups at the end of the list; they are empty.
for len(goas) > 0 && goas[len(goas)-1].group != "" {
goas = goas[:len(goas)-1]
}
}
for _, goa := range goas {
if goa.group != "" {
state.openGroup(goa.group)
} else {
for _, a := range goa.attrs {
state.appendAttr(a)
}
}
}
// Add attributes from the record.
r.Attrs(func(a slog.Attr) bool {
state.appendAttr(a)
return true
})
// Add newline.
state.append("\n")
// Write the log line.
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.out.Write(state.buf)
return err
}
func (h *friendlyHandler) withGroupOrAttrs(goa groupOrAttrs) *friendlyHandler {
h2 := *h
h2.goas = make([]groupOrAttrs, len(h.goas)+1)
copy(h2.goas, h.goas)
h2.goas[len(h2.goas)-1] = goa
return &h2
}
// WithGroup implements slog.Handler.
func (h *friendlyHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{group: name})
}
// WithAttrs implements slog.Handler.
func (h *friendlyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{attrs: attrs})
}