corso/src/pkg/logger/logger.go
Abin Simon bfea3dea34 Add autocompletion for cli commands and flags
This add autocompletion for all flags and commands in the cli.
To use this you'll have to source the completions file generated by
`corso completion [bash|zsh|fish|powershell]`.

You can do that by doing the following (example for bash):

``` bash
corso completion bash > /tmp/corso_completions
source /tmp/corso_completions
```
2023-11-16 18:43:14 +05:30

604 lines
16 KiB
Go

package logger
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/alcionai/clues"
"github.com/kopia/kopia/repo/logging"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/exp/slices"
"github.com/alcionai/corso/src/internal/common/str"
)
// Default location for writing logs, initialized in platform specific files
var userLogsDir string
var (
logCore *zapcore.Core
loggerton *zap.SugaredLogger
)
type logLevel string
const (
LLDebug logLevel = "debug"
LLInfo logLevel = "info"
LLWarn logLevel = "warn"
LLError logLevel = "error"
LLDisabled logLevel = "disabled"
)
type logFormat string
const (
// use for cli/terminal
LFText logFormat = "text"
// use for cloud logging
LFJSON logFormat = "json"
)
type piiAlg string
const (
PIIHash piiAlg = "hash"
PIIMask piiAlg = "mask"
PIIPlainText piiAlg = "plaintext"
)
// flag names
const (
DebugAPIFN = "debug-api-calls"
LogFileFN = "log-file"
LogFormatFN = "log-format"
LogLevelFN = "log-level"
ReadableLogsFN = "readable-logs"
MaskSensitiveDataFN = "mask-sensitive-data"
logStorageFN = "log-storage"
)
// flag values
var (
DebugAPIFV bool
logFileFV string
LogFormatFV string
LogLevelFV string
ReadableLogsFV bool
MaskSensitiveDataFV bool
logStorageFV bool
ResolvedLogFile string // logFileFV after processing
piiHandling string // piiHandling after MaskSensitiveDataFV processing
)
const (
Stderr = "stderr"
Stdout = "stdout"
)
// 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()
addFlags(fs, "corso-<timestamp>.log")
cobra.CheckErr(cmd.RegisterFlagCompletionFunc(LogLevelFN, cobra.FixedCompletions(
[]string{string(LLDebug), string(LLInfo), string(LLWarn), string(LLError), string(LLDisabled)},
cobra.ShellCompDirectiveNoFileComp)))
cobra.CheckErr(cmd.RegisterFlagCompletionFunc(LogFormatFN, cobra.FixedCompletions(
[]string{string(LFText), string(LFJSON)},
cobra.ShellCompDirectiveNoFileComp)))
cobra.CheckErr(cmd.RegisterFlagCompletionFunc(MaskSensitiveDataFN, cobra.FixedCompletions(
[]string{string(PIIPlainText), string(PIIMask), string(PIIHash)},
cobra.ShellCompDirectiveNoFileComp)))
//nolint:errcheck
fs.MarkHidden(ReadableLogsFN)
}
// internal deduplication for adding flags
func addFlags(fs *pflag.FlagSet, defaultFile string) {
fs.StringVar(
&LogLevelFV,
LogLevelFN,
string(LLInfo),
fmt.Sprintf("set the log level to %s|%s|%s|%s", LLDebug, LLInfo, LLWarn, LLError))
fs.StringVar(
&LogFormatFV,
LogFormatFN,
string(LFText),
fmt.Sprintf("set the log format to %s|%s", LFText, LFJSON))
// The default provided here is only for help info
fs.StringVar(&logFileFV, LogFileFN, defaultFile, "location for writing logs, use '-' for stdout")
fs.BoolVar(&DebugAPIFV, DebugAPIFN, false, "add non-2xx request/response errors to logging")
fs.BoolVar(
&ReadableLogsFV,
ReadableLogsFN,
false,
"minimizes log output for console readability: removes the file and date, colors the level")
fs.BoolVar(
&MaskSensitiveDataFV,
MaskSensitiveDataFN,
false,
"anonymize personal data in log output")
fs.BoolVar(
&logStorageFV,
logStorageFN,
false,
"include logs produced by the downstream storage systems. Uses the same log level as the corso logger")
cobra.CheckErr(fs.MarkHidden(logStorageFN))
}
// 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(args []string) Settings {
fs := pflag.NewFlagSet("seed-logger", pflag.ContinueOnError)
fs.ParseErrorsWhitelist.UnknownFlags = true
addFlags(fs, "")
// prevents overriding the corso/cobra help processor
fs.BoolP("help", "h", false, "")
set := Settings{
File: defaultLogLocation(),
Format: LFText,
Level: LLInfo,
PIIHandling: PIIPlainText,
}
// parse the os args list to find the log level flag
if err := fs.Parse(args); err != nil {
return set
}
// retrieve the user's preferred log level
// defaults to "info"
levelString, err := fs.GetString(LogLevelFN)
if err != nil {
return set
}
set.Level = logLevel(levelString)
// retrieve the user's preferred log format
// defaults to "text"
formatString, err := fs.GetString(LogFormatFN)
if err != nil {
return set
}
set.Format = logFormat(formatString)
// retrieve the user's preferred log file location
// defaults to default log location
lffv, err := fs.GetString(LogFileFN)
if err != nil {
return set
}
set.File = GetLogFile(lffv)
ResolvedLogFile = set.File
// retrieve the user's preferred PII handling algorithm
// defaults to "plaintext"
maskPII, err := fs.GetBool(MaskSensitiveDataFN)
if err != nil {
return set
}
if maskPII {
set.PIIHandling = PIIHash
}
// retrieve the user's preferred settings for storage engine logging in the
// corso log.
// defaults to not logging it.
storageLog, err := fs.GetBool(logStorageFN)
if err != nil {
return set
}
if storageLog {
set.LogStorage = storageLog
}
return set
}
// GetLogFile parses the log file. Uses the provided value, if populated,
// then falls back to the env var, and then defaults to stderr.
func GetLogFile(logFileFlagVal string) string {
if len(ResolvedLogFile) > 0 {
return ResolvedLogFile
}
r := logFileFlagVal
// if not specified, attempt to fall back to env declaration.
if len(r) == 0 {
r = os.Getenv("CORSO_LOG_FILE")
}
// if no flag or env is specified, fall back to the default
if len(r) == 0 {
r = defaultLogLocation()
}
if r == "-" {
r = Stdout
}
if r != Stdout && r != Stderr {
logdir := filepath.Dir(r)
err := os.MkdirAll(logdir, 0o755)
if err != nil {
return Stderr
}
}
return r
}
// Settings records the user's preferred logging settings.
type Settings struct {
File string // what file to log to (alt: stderr, stdout)
Format logFormat // whether to format as text (console) or json (cloud)
Level logLevel // what level to log at
PIIHandling piiAlg // how to obscure pii
LogStorage bool // Whether kopia logs should be added to the corso log.
}
// EnsureDefaults sets any non-populated settings to their default value.
// exported for testing without circular dependencies.
func (s Settings) EnsureDefaults() Settings {
set := s
levels := []logLevel{LLDisabled, LLDebug, LLInfo, LLWarn, LLError}
if len(set.Level) == 0 || !slices.Contains(levels, set.Level) {
set.Level = LLInfo
}
formats := []logFormat{LFText, LFJSON}
if len(set.Format) == 0 || !slices.Contains(formats, set.Format) {
set.Format = LFText
}
algs := []piiAlg{PIIPlainText, PIIMask, PIIHash}
if len(set.PIIHandling) == 0 || !slices.Contains(algs, set.PIIHandling) {
set.PIIHandling = piiAlg(str.First(piiHandling, string(PIIPlainText)))
}
if len(set.File) == 0 {
set.File = GetLogFile("")
ResolvedLogFile = set.File
}
return set
}
// ---------------------------------------------------------------------------
// constructors
// ---------------------------------------------------------------------------
func genLogger(set Settings) (*zapcore.Core, *zap.SugaredLogger) {
// when testing, ensure debug logging matches the test.v setting
for _, arg := range os.Args {
if arg == `--test.v=true` {
set.Level = LLDebug
}
}
var (
lgr *zap.Logger
err error
opts = []zap.Option{zap.AddStacktrace(zapcore.PanicLevel)}
// set up a logger core to use as a fallback
levelFilter = zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
switch set.Level {
case LLInfo:
return lvl >= zapcore.InfoLevel
case LLWarn:
return lvl >= zapcore.WarnLevel
case LLError:
return lvl >= zapcore.ErrorLevel
case LLDisabled:
return false
default:
return true
}
})
out = zapcore.Lock(os.Stderr)
consoleEncoder = zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core = zapcore.NewTee(
zapcore.NewCore(consoleEncoder, out, levelFilter))
cfg zap.Config
)
switch set.Format {
case LFJSON:
cfg = setLevel(zap.NewProductionConfig(), set.Level)
cfg.OutputPaths = []string{set.File}
default:
cfg = setLevel(zap.NewDevelopmentConfig(), set.Level)
if ReadableLogsFV {
opts = append(opts, zap.WithCaller(false))
cfg.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05.00")
if set.File == Stderr || set.File == Stdout {
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
}
cfg.OutputPaths = []string{set.File}
}
// fall back to the core config if the default creation fails
lgr, err = cfg.Build(opts...)
if err != nil {
lgr = zap.New(core)
}
return &core, lgr.Sugar()
}
func setLevel(cfg zap.Config, level logLevel) zap.Config {
switch level {
case LLInfo:
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
case LLWarn:
cfg.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel)
case LLError:
cfg.Level = zap.NewAtomicLevelAt(zapcore.ErrorLevel)
case LLDisabled:
cfg.Level = zap.NewAtomicLevelAt(zapcore.FatalLevel)
}
return cfg
}
func singleton(set Settings) *zap.SugaredLogger {
if loggerton != nil {
return loggerton
}
if logCore != nil {
lgr := zap.New(*logCore)
loggerton = lgr.Sugar()
return loggerton
}
set = set.EnsureDefaults()
setCluesSecretsHash(set.PIIHandling)
logCore, loggerton = genLogger(set)
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, set Settings) (context.Context, *zap.SugaredLogger) {
zsl := singleton(set)
return SetWithSettings(ctx, zsl, set), zsl
}
func setCluesSecretsHash(alg piiAlg) {
switch alg {
case PIIHash:
// TODO: a persistent hmac key for each tenant would be nice
// as a way to correlate logs across runs.
clues.SetHasher(clues.DefaultHash())
case PIIMask:
clues.SetHasher(clues.HashCfg{HashAlg: clues.Flatmask})
case PIIPlainText:
clues.SetHasher(clues.NoHash())
}
}
// CtxOrSeed attempts to retrieve the logger from the ctx. If not found, it
// generates a logger with the given settings and adds it to the context.
func CtxOrSeed(ctx context.Context, set Settings) (context.Context, *zap.SugaredLogger) {
l := ctx.Value(ctxKey)
if l == nil {
zsl := singleton(set)
return SetWithSettings(ctx, zsl, set), 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 {
set := Settings{}.EnsureDefaults()
return SetWithSettings(ctx, logger, set)
}
// SetWithSettings allows users to embed their own zap.SugaredLogger within the
// context and with the given logger settings.
func SetWithSettings(
ctx context.Context,
logger *zap.SugaredLogger,
set Settings,
) context.Context {
if logger == nil {
return ctx
}
// Add the kopia logger as well. Unfortunately we need to do this here instead
// of a kopia-specific package because we want it to be in the context that's
// used for the rest of execution.
if set.LogStorage {
ctx = logging.WithLogger(ctx, func(module string) logging.Logger {
return logger.Named("kopia-lib/" + module)
})
}
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 {
l = singleton(Settings{}.EnsureDefaults())
}
return l.(*zap.SugaredLogger).With(clues.In(ctx).Slice()...)
}
// CtxStack retrieves the logger embedded in the context, and adds the
// stacktrace to the log info.
// If skip is non-zero, it skips the stack calls starting from the
// first. Skip always adds +1 to account for this wrapper.
func CtxStack(ctx context.Context, skip int) *zap.SugaredLogger {
return Ctx(ctx).With(zap.StackSkip("trace", skip+1))
}
// CtxErr retrieves the logger embedded in the context
// and packs all of the structured data in the error inside it.
func CtxErr(ctx context.Context, err error) *zap.SugaredLogger {
return Ctx(ctx).
With(
"error", err,
"error_labels", clues.Labels(err)).
With(clues.InErr(err).Slice()...)
}
// CtxErrStack retrieves the logger embedded in the context
// and packs all of the structured data in the error inside it.
// If skip is non-zero, it skips the stack calls starting from the
// first. Skip always adds +1 to account for this wrapper.
func CtxErrStack(ctx context.Context, err error, skip int) *zap.SugaredLogger {
return Ctx(ctx).
With(
"error", err,
"error_labels", clues.Labels(err)).
With(zap.StackSkip("trace", skip+1)).
With(clues.InErr(err).Slice()...)
}
// 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...)
}
// ------------------------------------------------------------------------------------------------
// io.writer that writes values to the logger
// ------------------------------------------------------------------------------------------------
// Writer is a wrapper that turns the logger embedded in
// the given ctx into an io.Writer. All logs are currently
// info-level.
type Writer struct {
Ctx context.Context
}
func (w Writer) Write(p []byte) (int, error) {
Ctx(w.Ctx).Info(string(p))
return len(p), nil
}