package logfile import ( "errors" "io" "os" "path/filepath" "time" "github.com/aperturerobotics/cli" "github.com/mattn/go-isatty" "github.com/sirupsen/logrus" ) // ParseLogFileSpecs parses a list of raw spec strings into LogFileSpecs. // Entries with value "none" are filtered out. func ParseLogFileSpecs(raw []string, ts time.Time) ([]LogFileSpec, error) { var specs []LogFileSpec for _, r := range raw { spec, err := ParseLogFileSpec(r, ts) if errors.Is(err, ErrDisabled) { continue } if err != nil { return nil, err } specs = append(specs, spec) } return specs, nil } // AttachLogFiles opens log files and attaches hooks to the logger. // Returns a cleanup function that closes all hooks and files. func AttachLogFiles(logger *logrus.Logger, specs []LogFileSpec) (func(), error) { var hooks []*FileHook for _, spec := range specs { dir := filepath.Dir(spec.Path) if err := os.MkdirAll(dir, 0o755); err != nil { // Close any already-opened hooks before returning. for _, h := range hooks { h.Close() } return nil, err } // Open with append semantics: multiple processes can resolve the // same log-file path within the same second (same-second {ts} // template or an explicit shared spec), and a truncate-on-open // here would destroy the records a still-running first writer // already emitted. f, err := os.OpenFile(spec.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) if err != nil { for _, h := range hooks { h.Close() } return nil, err } // Mark the file in use so concurrent prunes keep it while open. markLogInUse(f) h := NewFileHook(f, spec.Level, spec.Format) logger.AddHook(h) hooks = append(hooks, h) } cleanup := func() { for _, h := range hooks { h.Close() } } return cleanup, nil } // EnsureLoggerLevel raises the logger level to the most verbose hook level. // If the logger level is already sufficient, this is a no-op. Otherwise it // redirects the logger's console output through a level-filtered hook so // that --log-level controls console verbosity independently of file hooks. func EnsureLoggerLevel(logger *logrus.Logger, specs []LogFileSpec) { // Find the verbosity needed to deliver every file hook level. maxLevel := logger.GetLevel() for _, spec := range specs { if spec.Level > maxLevel { maxLevel = spec.Level } } if maxLevel <= logger.GetLevel() { return } // Preserve color output: logrus TextFormatter detects TTY via // entry.Logger.Out, which will be io.Discard after the swap. Build // a new formatter with ForceColors when the original output is a TTY. formatter := logger.Formatter if tf, ok := formatter.(*logrus.TextFormatter); ok && !tf.DisableColors && writerIsTerminal(logger.Out) { formatter = &logrus.TextFormatter{ ForceColors: true, ForceQuote: tf.ForceQuote, DisableQuote: tf.DisableQuote, EnvironmentOverrideColors: tf.EnvironmentOverrideColors, DisableTimestamp: tf.DisableTimestamp, FullTimestamp: tf.FullTimestamp, TimestampFormat: tf.TimestampFormat, DisableSorting: tf.DisableSorting, SortingFunc: tf.SortingFunc, DisableLevelTruncation: tf.DisableLevelTruncation, PadLevelText: tf.PadLevelText, QuoteEmptyFields: tf.QuoteEmptyFields, FieldMap: tf.FieldMap, CallerPrettyfier: tf.CallerPrettyfier, } } // Route console records through their own threshold before raising logger verbosity. consoleHook := NewConsoleHook(logger.Out, formatter, logger.GetLevel()) logger.AddHook(consoleHook) logger.SetOutput(io.Discard) logger.SetLevel(maxLevel) } // writerIsTerminal reports whether w is connected to a terminal. func writerIsTerminal(w io.Writer) bool { if f, ok := w.(interface{ Fd() uintptr }); ok { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } return false } // BuildLogFileFlag returns a CLI flag for --log-file / --log-files. func BuildLogFileFlag(dest *cli.StringSlice) *cli.StringSliceFlag { return &cli.StringSliceFlag{ Name: "log-file", Aliases: []string{"log-files"}, Usage: "file logging spec: [level=LEVEL;][format=FORMAT;]path=PATH", EnvVars: []string{"BLDR_LOG_FILE"}, Destination: dest, } }