byob-go-cli

Logger on the Factory, injected into context

byob-logging.2 contextlogging

Problem: two extremes both hurt. Threading *slog.Logger as a parameter through every helper pollutes every signature. Using slog.Default() everywhere makes per-command attributes (command name, run ID, host) impossible without stepping on other callers' defaults.

Idea: the Factory holds the root logger as an eager field (f.Logger *slog.Logger) — the logger itself is cheap to construct (one slog handler over IO.ErrOut) so it doesn't need the func() (T, error) lazy-closure shape that byob-factory-di.1 reserves for expensive deps. The root command's PersistentPreRunE (byob-command-shape.5) attaches per-run attributes with logger.With(...) and stuffs the result into cmd.Context() — the same context already threaded through every runFunc (byob-lifecycle.1). Command code reaches for the logger via slog.InfoContext(ctx, ...) or a tiny logs.From(ctx) helper.

Tradeoffs: slog.FromContext is not stdlib — you write a 5-line context-key helper. Not every log call site has a ctx, so a default logger still exists as a fallback. The win: commands never pass a logger parameter, and per-command attributes attach in one place.

Design

// internal/logs/ctx.go
type ctxKey struct{}

func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
    return context.WithValue(ctx, ctxKey{}, l)
}
func From(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
        return l
    }
    return slog.Default()
}

// pkg/cmd/root/root.go, inside PersistentPreRunE:
l := f.Logger.With("cmd", cmd.CommandPath())
cmd.SetContext(logs.WithLogger(cmd.Context(), l))

The cmd.SetContext call must run on the leaf command's PersistentPreRunE (cobra sets it on the specific command under invocation; children inherit at run time). A helper that runs before cobra resolves the target command sees a stale context and the logger attribute never lands.