corso/src/pkg/logger/logger.go
Keepers e43455cc49
re-enable stderr logs in tests (#2215)
## Does this PR need a docs update or release note?

- [x]  No 

## Type of change

- [x] 🐛 Bugfix
- [x] 🤖 Test
- [x] 💻 CI/Deployment

## Test Plan

- [x] 💪 Manual
2023-01-23 19:52:26 +00:00

351 lines
8.8 KiB
Go

package logger
import (
"context"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Default location for writing logs, initialized in platform specific files
var userLogsDir string
var (
logCore *zapcore.Core
loggerton *zap.SugaredLogger
// logging level flag
llFlag = "info"
// logging file flags
lfFlag = ""
DebugAPI bool
readableOutput bool
)
type logLevel int
const (
Development logLevel = iota
Info
Warn
Production
Disabled
)
const (
debugAPIFN = "debug-api-calls"
logLevelFN = "log-level"
logFileFN = "log-file"
readableLogsFN = "readable-logs"
)
// Returns the default location for writing logs
func defaultLogLocation() string {
return filepath.Join(userLogsDir, "corso", "logs", time.Now().UTC().Format("2006-01-02T15-04-05Z")+".log")
}
// adds the persistent flag --log-level and --log-file to the provided command.
// defaults to "info" and the default log location.
// This is a hack for help displays. Due to seeding the context, we also
// need to parse the log level before we execute the command.
func AddLoggingFlags(cmd *cobra.Command) {
fs := cmd.PersistentFlags()
fs.StringVar(&llFlag, logLevelFN, "info", "set the log level to debug|info|warn|error")
// The default provided here is only for help info
fs.StringVar(&lfFlag, logFileFN, "corso-<timestamp>.log", "location for writing logs, use '-' for stdout")
fs.Bool(debugAPIFN, false, "add non-2xx request/response errors to logging")
fs.Bool(
readableLogsFN, false,
"minimizes log output for console readability: removes the file and date, colors the level")
//nolint:errcheck
fs.MarkHidden(readableLogsFN)
}
// Due to races between the lazy evaluation of flags in cobra and the
// need to init logging behavior in a ctx, log-level and log-file gets
// pre-processed manually here using pflags. The canonical
// AddLogLevelFlag() and AddLogFileFlag() ensures the flags are
// displayed as part of the help/usage output.
func PreloadLoggingFlags() (string, string) {
dlf := defaultLogLocation()
fs := pflag.NewFlagSet("seed-logger", pflag.ContinueOnError)
fs.ParseErrorsWhitelist.UnknownFlags = true
fs.String(logLevelFN, "info", "set the log level to debug|info|warn|error")
fs.String(logFileFN, dlf, "location for writing logs")
fs.BoolVar(&DebugAPI, debugAPIFN, false, "add non-2xx request/response errors to logging")
fs.BoolVar(&readableOutput, readableLogsFN, false, "minimizes log output: removes the file and date, colors the level")
// prevents overriding the corso/cobra help processor
fs.BoolP("help", "h", false, "")
// parse the os args list to find the log level flag
if err := fs.Parse(os.Args[1:]); err != nil {
return "info", dlf
}
// retrieve the user's preferred log level
// automatically defaults to "info"
levelString, err := fs.GetString(logLevelFN)
if err != nil {
return "info", dlf
}
// retrieve the user's preferred log file location
// automatically defaults to default log location
logfile, err := fs.GetString(logFileFN)
if err != nil {
return "info", dlf
}
// if not specified, attempt to fall back to env declaration.
if len(logfile) == 0 {
logfile = os.Getenv("CORSO_LOG_FILE")
}
if logfile == "-" {
logfile = "stdout"
}
if logfile != "stdout" && logfile != "stderr" {
logdir := filepath.Dir(logfile)
err := os.MkdirAll(logdir, 0o755)
if err != nil {
return "info", "stderr"
}
}
return levelString, logfile
}
func genLogger(level logLevel, logfile string) (*zapcore.Core, *zap.SugaredLogger) {
// when testing, ensure debug logging matches the test.v setting
for _, arg := range os.Args {
if arg == `--test.v=true` {
level = Development
}
}
// set up a logger core to use as a fallback
levelFilter := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
switch level {
case Info:
return lvl >= zapcore.InfoLevel
case Warn:
return lvl >= zapcore.WarnLevel
case Production:
return lvl >= zapcore.ErrorLevel
case Disabled:
return false
default:
return true
}
})
out := zapcore.Lock(os.Stderr)
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core := zapcore.NewTee(
zapcore.NewCore(consoleEncoder, out, levelFilter),
)
// then try to set up a logger directly
var (
lgr *zap.Logger
err error
)
if level != Production {
cfg := zap.NewDevelopmentConfig()
switch level {
case Info:
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
case Warn:
cfg.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel)
case Disabled:
cfg.Level = zap.NewAtomicLevelAt(zapcore.FatalLevel)
}
opts := []zap.Option{}
if readableOutput {
opts = append(opts, zap.WithCaller(false), zap.AddStacktrace(zapcore.DPanicLevel))
cfg.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05.00")
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
cfg.OutputPaths = []string{logfile}
lgr, err = cfg.Build(opts...)
} else {
cfg := zap.NewProductionConfig()
cfg.OutputPaths = []string{logfile}
lgr, err = cfg.Build()
}
// fall back to the core config if the default creation fails
if err != nil {
lgr = zap.New(core)
}
return &core, lgr.Sugar()
}
func singleton(level logLevel, logfile string) *zap.SugaredLogger {
if loggerton != nil {
return loggerton
}
if logCore != nil {
lgr := zap.New(*logCore)
loggerton = lgr.Sugar()
return loggerton
}
logCore, loggerton = genLogger(level, logfile)
return loggerton
}
// ------------------------------------------------------------------------------------------------
// context management
// ------------------------------------------------------------------------------------------------
type loggingKey string
const ctxKey loggingKey = "corsoLogger"
// Seed generates a logger within the context for later retrieval.
// It also parses the command line for flag values prior to executing
// cobra. This early parsing is necessary since logging depends on
// a seeded context prior to cobra evaluating flags.
func Seed(ctx context.Context, lvl, logfile string) (context.Context, *zap.SugaredLogger) {
if len(lvl) == 0 {
lvl = "info"
}
zsl := singleton(levelOf(lvl), logfile)
return Set(ctx, zsl), zsl
}
// SeedLevel generates a logger within the context with the given log-level.
func SeedLevel(ctx context.Context, level logLevel) (context.Context, *zap.SugaredLogger) {
l := ctx.Value(ctxKey)
if l == nil {
zsl := singleton(level, defaultLogLocation())
return Set(ctx, zsl), zsl
}
return ctx, l.(*zap.SugaredLogger)
}
// Set allows users to embed their own zap.SugaredLogger within the context.
func Set(ctx context.Context, logger *zap.SugaredLogger) context.Context {
if logger == nil {
return ctx
}
return context.WithValue(ctx, ctxKey, logger)
}
// Ctx retrieves the logger embedded in the context.
func Ctx(ctx context.Context) *zap.SugaredLogger {
l := ctx.Value(ctxKey)
if l == nil {
return singleton(levelOf(llFlag), defaultLogLocation())
}
return l.(*zap.SugaredLogger)
}
// transforms the llevel flag value to a logLevel enum
func levelOf(lvl string) logLevel {
switch lvl {
case "debug":
return Development
case "warn":
return Warn
case "error":
return Production
case "disabled":
return Disabled
}
return Info
}
// Flush writes out all buffered logs.
func Flush(ctx context.Context) {
_ = Ctx(ctx).Sync()
}
// ------------------------------------------------------------------------------------------------
// log wrapper for downstream api compliance
// ------------------------------------------------------------------------------------------------
type wrapper struct {
zap.SugaredLogger
forceDebugLogLevel bool
}
func (w *wrapper) process(opts ...option) {
for _, opt := range opts {
opt(w)
}
}
type option func(*wrapper)
// ForceDebugLogLevel reduces all logs emitted in the wrapper to
// debug level, independent of their original log level. Useful
// for silencing noisy dependency packages without losing the info
// altogether.
func ForceDebugLogLevel() option {
return func(w *wrapper) {
w.forceDebugLogLevel = true
}
}
// Wrap returns the logger in the package with an extended api used for
// dependency package interface compliance.
func WrapCtx(ctx context.Context, opts ...option) *wrapper {
return Wrap(Ctx(ctx), opts...)
}
// Wrap returns the sugaredLogger with an extended api used for
// dependency package interface compliance.
func Wrap(zsl *zap.SugaredLogger, opts ...option) *wrapper {
w := &wrapper{SugaredLogger: *zsl}
w.process(opts...)
return w
}
func (w *wrapper) Logf(tmpl string, args ...any) {
if w.forceDebugLogLevel {
w.SugaredLogger.Debugf(tmpl, args...)
return
}
w.SugaredLogger.Infof(tmpl, args...)
}
func (w *wrapper) Errorf(tmpl string, args ...any) {
if w.forceDebugLogLevel {
w.SugaredLogger.Debugf(tmpl, args...)
return
}
w.SugaredLogger.Errorf(tmpl, args...)
}