package log //nolint:revive,nolintlint // stdlib log unused here; see #76 import ( "context" "fmt" "io" "log/slog" "strings" "sync" "time" ) // groupSeparator joins an open group path to an attribute key. This // format has no nesting, so a group becomes a dotted key prefix: // slog.New(h).WithGroup("db").With("rows", 3) renders "db.rows=3". const groupSeparator = "." // bytesAttrKey is the attribute key whose int64 value is rendered as a // human-readable byte count rather than a bare number. const bytesAttrKey = "bytes" // ANSI color codes const ( colorReset = "\033[0m" colorRed = "\033[31m" colorYellow = "\033[33m" colorBlue = "\033[34m" colorGray = "\033[90m" colorGreen = "\033[32m" colorCyan = "\033[36m" colorBold = "\033[1m" ) // TTYHandler is a custom slog handler for TTY output with colors. // // A handler and the handlers derived from it via WithAttrs/WithGroup // all write to the same stream, so they share one mutex; that is why mu // is a pointer. A value mutex would give every derived handler its own // lock and stop serializing writes to the stream they have in common. type TTYHandler struct { opts slog.HandlerOptions mu *sync.Mutex out io.Writer // attrs are the attributes accumulated through WithAttrs, emitted // ahead of each record's own attributes. Their keys already carry // the group path that was open when they were added, so no // qualification happens at write time. attrs []slog.Attr // groups is the group path opened by WithGroup, applied as a key // prefix to attributes that arrive later — both on a record and // through a further WithAttrs. groups []string } // NewTTYHandler creates a new TTY handler with colored output. func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler { if opts == nil { opts = &slog.HandlerOptions{} } return &TTYHandler{ out: out, opts: *opts, mu: &sync.Mutex{}, } } // Enabled reports whether the handler handles records at the given level. func (h *TTYHandler) Enabled(_ context.Context, level slog.Level) bool { return level >= h.opts.Level.Level() } // Handle writes the log record to the output with color formatting. func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error { h.mu.Lock() defer h.mu.Unlock() // Format timestamp timestamp := r.Time.Format("15:04:05") // Level and color level := r.Level.String() var levelColor string switch r.Level { case slog.LevelDebug: levelColor = colorGray level = "DEBUG" case slog.LevelInfo: levelColor = colorGreen level = "INFO " case slog.LevelWarn: levelColor = colorYellow level = "WARN " case slog.LevelError: levelColor = colorRed level = "ERROR" default: levelColor = colorReset } // Print main message _, _ = fmt.Fprintf(h.out, "%s%s%s %s%s%s %s%s%s", colorGray, timestamp, colorReset, levelColor, level, colorReset, colorBold, r.Message, colorReset) // Attributes carried by the handler come first, then the record's // own. Handler attributes were qualified when they were added; the // record's are qualified now, against whatever group path is open. for _, a := range h.attrs { h.writeAttr(a) } prefix := strings.Join(h.groups, groupSeparator) r.Attrs(func(a slog.Attr) bool { for _, flat := range appendAttr(nil, prefix, a) { h.writeAttr(flat) } return true }) _, _ = fmt.Fprintln(h.out) return nil } // appendAttr flattens a into dst, folding prefix into its key and // expanding group values into further dotted keys. Following the // slog.Handler contract: an empty Attr is dropped, a group with no // attributes is dropped, and a group with an empty key is inlined into // its parent rather than contributing a level. func appendAttr(dst []slog.Attr, prefix string, a slog.Attr) []slog.Attr { a.Value = a.Value.Resolve() if a.Equal(slog.Attr{}) { return dst } key := a.Key switch { case prefix == "": // key stands alone. case key == "": key = prefix default: key = prefix + groupSeparator + key } if a.Value.Kind() != slog.KindGroup { return append(dst, slog.Attr{Key: key, Value: a.Value}) } for _, member := range a.Value.Group() { dst = appendAttr(dst, key, member) } return dst } // WithAttrs returns a new handler that emits attrs on every record it // handles, in addition to whatever the handler already carried. Keys // are qualified by the group path open at the time of the call, so // WithGroup("db").WithAttrs(rows=3) later renders "db.rows=3". // // The receiver is not modified. func (h *TTYHandler) WithAttrs(attrs []slog.Attr) slog.Handler { if len(attrs) == 0 { return h } prefix := strings.Join(h.groups, groupSeparator) next := h.clone() for _, a := range attrs { next.attrs = appendAttr(next.attrs, prefix, a) } return next } // WithGroup returns a new handler that qualifies every subsequent // attribute key with name. This format is a single line with nowhere to // nest, so grouping is rendered as a dotted key prefix: after // WithGroup("db"), an attribute "rows" is emitted as "db.rows". // // An empty name returns the receiver unchanged, per the slog.Handler // contract. The receiver is not modified. func (h *TTYHandler) WithGroup(name string) slog.Handler { if name == "" { return h } next := h.clone() next.groups = append(next.groups, name) return next } // clone returns a copy of h that shares its output stream and mutex but // owns its attribute and group slices. // // The slices are copied rather than resliced on purpose. slog permits // one handler to be derived from concurrently, and two derivations that // appended into a shared backing array would each overwrite the other's // attribute — a data race with a silent wrong-output failure mode. func (h *TTYHandler) clone() *TTYHandler { next := &TTYHandler{ opts: h.opts, mu: h.mu, out: h.out, attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1), groups: make([]string, len(h.groups), len(h.groups)+1), } copy(next.attrs, h.attrs) copy(next.groups, h.groups) return next } // writeAttr renders one already-flattened, already-qualified attribute // as " key=value". Callers hold h.mu. func (h *TTYHandler) writeAttr(a slog.Attr) { value := a.Value.String() // Special handling for certain attribute types switch a.Value.Kind() { case slog.KindDuration: if d, ok := a.Value.Any().(time.Duration); ok { value = formatDuration(d) } case slog.KindInt64: if a.Key == bytesAttrKey { value = formatBytes(a.Value.Int64()) } case slog.KindAny, slog.KindBool, slog.KindFloat64, slog.KindString, slog.KindTime, slog.KindUint64, slog.KindGroup, slog.KindLogValuer: // Plain string form above is already correct for these kinds. default: // Future kinds also use the plain string form. } _, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s", colorCyan, a.Key, colorReset, colorBlue, value, colorReset) } // formatDuration formats a duration in a human-readable way func formatDuration(d time.Duration) string { switch { case d < time.Millisecond: return fmt.Sprintf("%dµs", d.Microseconds()) case d < time.Second: return fmt.Sprintf("%dms", d.Milliseconds()) case d < time.Minute: return fmt.Sprintf("%.1fs", d.Seconds()) default: return d.String() } } // formatBytes formats bytes in a human-readable way func formatBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) }