All checks were successful
check / check (push) Successful in 4m20s
Closes #97. internal/log attached both handlers to os.Stdout, so any record that was not suppressed landed in the middle of a --json document. WARN and ERROR are never suppressed, so this was not hypothetical: a config file with permissions looser than 0600 was enough to break `vaultik snapshot list --json | jq`. Both handlers now write to os.Stderr, and the TTY-vs-JSON format choice tests os.Stderr rather than os.Stdout - the format has to follow the stream the records land on, or a redirected stderr gets colorized whenever stdout happens to be a terminal. User-visible: --verbose and --debug output moves to stderr too, so `vaultik snapshot list -v > out.txt` no longer captures diagnostics. --quiet and --cron semantics are unchanged. TTYHandler.WithAttrs and WithGroup discarded their arguments and returned the receiver, while their doc comments claimed otherwise, so attributes passed through the exported log.With vanished. The effect was environment-dependent in the worst direction: handler choice is by TTY-ness, so attributes disappeared on a terminal - where a developer is debugging - and appeared correctly in CI. Both now return a new handler with copied state rather than mutating the receiver, since slog permits a handler to be shared and derived from concurrently. A test asserts the TTY and JSON handlers emit the same attribute set, which is the test that would have caught the original defect. The local workaround in snapshot_list.go is removed now that the logger no longer writes to stdout. The collect-then-emit machinery is kept, but for a different reason than it was added: emitting from the fetch workers would order warnings by network timing, whereas key-order emission after group.Wait() is deterministic run to run. Not yet complete: --json stdout still carries the startup banner, which internal/cli/entry.go writes before cobra parses and which bannerSuppressedInArgs does not recognise --json for. That is the remaining stdout contamination path and is tracked in #106.
283 lines
7.4 KiB
Go
283 lines
7.4 KiB
Go
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])
|
|
}
|