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}) }