1// Package logging sets up the one event stream the system narrates itself2// through: stdlib log/slog, no wrapper library, a shared attribute3// vocabulary, and the CLI wiring that decides where the stream goes.4//5// The stream has two audiences that want the same thing: a person watching a6// pipeline run, and an agent diagnosing why it failed. Both are served by one7// line per meaningful unit of work, carrying domain facts under documented8// keys rather than prose.9//10// The conventions, in short:11//12// - Levels are used with intent. Debug is internal mechanics, off by13// default. Info is one line per meaningful unit of work. Warn is14// something tolerated that a human should eventually see. Error is an15// operation that failed.16// - The stream goes to stderr as human-readable text, so piped stdout stays17// clean for product output -- reports, JSON, anything a caller parses.18// --log-json switches to machine-readable; --log-level, or ATLAS_LOG,19// opens up Debug.20// - Attribute keys come from the vocabulary below. They name domain facts21// about the work, never code organization: which component emitted an22// event is the logger's name, not a vocabulary entry.23//24// The vocabulary and the level policy are documented for humans in25// docs/logging.md, which is this package's prose twin.26//27// This package deliberately sits in internal/: format/ stays log-free, and28// CLI flag wiring is not the format's concern.29package logging3031import (32 "flag"33 "fmt"34 "io"35 "log/slog"36 "os"37 "strings"38 "time"39)4041// The shared attribute vocabulary. Every event that has one of these facts to42// state states it under this key, so grep, the workbench, and an agent can43// correlate events across packages without per-package archaeology.44const (45 // KeyOp names the unit of work: "crawl", "compose", "install",46 // "enrich", "measure". It is the first thing to group by.47 KeyOp = "op"48 // KeyVolume, KeyWorld, and KeyLens are the subject, in the format's own49 // words: which .atlas file, which ground within it, which picture of that50 // ground. All three are slugs.51 KeyVolume = "volume"52 KeyWorld = "world"53 KeyLens = "lens"54 // KeyStamp is the content fingerprint of the build being spoken about,55 // short form. It is what ties an event to a file on disk.56 KeyStamp = "stamp"57 // KeySource names the capture source a generate-lane event is about.58 // Only merge ledgers and the generate lane ever speak it.59 KeySource = "source"60 // KeyEnricher names the enricher an enrich-lane event is about.61 KeyEnricher = "enricher"62 // KeyDur is how long a unit of work took, as a time.Duration.63 KeyDur = "dur"64 // KeyPath is a filesystem path an event is about. It is the one key that65 // names the machine rather than the domain, and it is here because a66 // person reading a failure needs to know which file.67 KeyPath = "path"68)6970// Vocabulary lists the documented attribute keys, for the test that holds71// this package and docs/logging.md to the same list.72func Vocabulary() []string {73 return []string{KeyOp, KeyVolume, KeyWorld, KeyLens, KeyStamp, KeySource, KeyEnricher, KeyDur, KeyPath}74}7576// The vocabulary as attribute constructors. They exist so a caller writes77// logging.Volume(slug) rather than a bare string literal, which is what keeps78// a key from being misspelled into invisibility.79func Op(name string) slog.Attr { return slog.String(KeyOp, name) }80func Volume(slug string) slog.Attr { return slog.String(KeyVolume, slug) }81func World(slug string) slog.Attr { return slog.String(KeyWorld, slug) }82func Lens(name string) slog.Attr { return slog.String(KeyLens, name) }83func Stamp(stamp string) slog.Attr { return slog.String(KeyStamp, stamp) }84func Source(slug string) slog.Attr { return slog.String(KeySource, slug) }85func Enricher(name string) slog.Attr { return slog.String(KeyEnricher, name) }86func Dur(d time.Duration) slog.Attr { return slog.Duration(KeyDur, d) }87func Path(path string) slog.Attr { return slog.String(KeyPath, path) }8889// LevelEnv is the environment variable that opens up Debug when no flag says90// otherwise. A flag always wins: an explicit instruction beats an ambient one.91const LevelEnv = "ATLAS_LOG"9293// Options is how a command chooses where its event stream goes. The zero94// value is the default every CLI run gets: human-readable text on stderr at95// Info.96type Options struct {97 // JSON writes machine-readable events instead of text.98 JSON bool99 // Level is the lowest level that is written: "debug", "info", "warn",100 // "error", or empty to fall back to LevelEnv and then to Info.101 Level string102 // Source adds the emitting file and line to every event. It is off by103 // default because it is noise to a person and a build detail to an agent.104 Source bool105}106107// Bind registers --log-json and --log-level on a flag set. A command calls it108// once, parses, and hands the options to [Setup].109func (o *Options) Bind(fs *flag.FlagSet) {110 fs.BoolVar(&o.JSON, "log-json", o.JSON, "write the event stream as JSON instead of text")111 fs.StringVar(&o.Level, "log-level", o.Level,112 "lowest event level to write: debug, info, warn, error (default from "+LevelEnv+", else info)")113}114115// Level resolves the level this run writes at: the flag if it was given, then116// the environment, then Info.117func (o Options) level() (slog.Level, error) {118 if o.Level != "" {119 return ParseLevel(o.Level)120 }121 if fromEnv := os.Getenv(LevelEnv); fromEnv != "" {122 level, err := ParseLevel(fromEnv)123 if err != nil {124 return 0, fmt.Errorf("%s: %w", LevelEnv, err)125 }126 return level, nil127 }128 return slog.LevelInfo, nil129}130131// ParseLevel reads a level name, case-insensitively. It admits the four names132// the level policy uses and nothing else: a typo should be a refusal, not a133// silent fall back to Info.134func ParseLevel(name string) (slog.Level, error) {135 switch strings.ToLower(strings.TrimSpace(name)) {136 case "debug":137 return slog.LevelDebug, nil138 case "info":139 return slog.LevelInfo, nil140 case "warn", "warning":141 return slog.LevelWarn, nil142 case "error":143 return slog.LevelError, nil144 }145 return 0, fmt.Errorf("%q is not one of debug, info, warn, error", name)146}147148// New builds a logger writing to w. It is the testable form: [Setup] is this149// with stderr and the process default wired up.150func New(w io.Writer, o Options) (*slog.Logger, error) {151 level, err := o.level()152 if err != nil {153 return nil, err154 }155 options := &slog.HandlerOptions{Level: level, AddSource: o.Source}156 var handler slog.Handler157 if o.JSON {158 handler = slog.NewJSONHandler(w, options)159 } else {160 handler = slog.NewTextHandler(w, options)161 }162 return slog.New(handler), nil163}164165// Setup builds the run's logger on stderr and makes it the process default,166// so a package that reaches for slog's top-level functions writes to the same167// stream as one that carries a logger.168//169// It is called once, explicitly, from a command's main -- never from an init170// -- so a library importing this package gets nothing it did not ask for.171func Setup(o Options) (*slog.Logger, error) {172 logger, err := New(os.Stderr, o)173 if err != nil {174 return nil, err175 }176 slog.SetDefault(logger)177 return logger, nil178}