package log import ( "errors" "fmt" "io" "log" "log/slog" "time" "github.com/TheZeroSlave/zapsentry" "github.com/getsentry/sentry-go" "github.com/go-logr/logr" "github.com/go-logr/zapr" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) type SyncFunc func() error type SinkOption func(*sinkConfig) // New creates a new log object with the provided sinks. If no sinks are // provided, a no-op sink will be used. Returns the logger and a cleanup // function that should be executed before the program exits. func New(service string, cores ...zapcore.Core) (logr.Logger, SyncFunc) { return NewWithCaller(service, false, cores...) } // NewWithCaller creates a new logger named after the specified service with // the provided sinks. If addCaller is true, call site information will be // attached to each emitted log message. (This behavior can be disabled on a // per-sink basis using WithSuppressCaller.) func NewWithCaller(service string, addCaller bool, cores ...zapcore.Core) (logr.Logger, SyncFunc) { // create logger zapLogger := zap.New(zapcore.NewTee(cores...), zap.WithCaller(addCaller)) logger := zapr.NewLogger(zapLogger).WithName(service) return logger, zapLogger.Sync } // WithSentry adds sentry integration to the logger. func WithSentry(client *sentry.Client, tags map[string]string) zapcore.Core { // create sentry core cfg := zapsentry.Configuration{ Tags: tags, Level: zapcore.ErrorLevel, EnableBreadcrumbs: true, BreadcrumbLevel: zapcore.InfoLevel, } core, err := zapsentry.NewCore(cfg, zapsentry.NewSentryClientFromClient(client)) if err != nil { // NewCore should never fail because NewSentryClientFromClient // never returns an error and NewCore only returns an error if // the zapsentry.Configuration is invalid, which would indicate // a programmer error. panic(err) } return core } type sinkConfig struct { encoder zapcore.Encoder sink zapcore.WriteSyncer level levelSetter redactor *dynamicRedactor suppressCaller bool } // WithJSONSink adds a JSON encoded output to the logger. func WithJSONSink(sink io.Writer, opts ...SinkOption) zapcore.Core { return newCore( zapcore.NewJSONEncoder(defaultEncoderConfig()), zapcore.Lock(zapcore.AddSync(sink)), globalLogLevel, opts..., ) } // WithConsoleSink adds a console-style output to the logger. func WithConsoleSink(sink io.Writer, opts ...SinkOption) zapcore.Core { return newCore( zapcore.NewConsoleEncoder(defaultEncoderConfig()), zapcore.Lock(zapcore.AddSync(sink)), globalLogLevel, opts..., ) } func defaultEncoderConfig() zapcore.EncoderConfig { conf := zap.NewProductionEncoderConfig() // Use more human-readable time format. conf.EncodeTime = zapcore.TimeEncoderOfLayout(time.RFC3339) conf.EncodeLevel = func(level zapcore.Level, enc zapcore.PrimitiveArrayEncoder) { if level == zapcore.ErrorLevel { enc.AppendString("error") return } enc.AppendString(fmt.Sprintf("info-%d", -int8(level))) } return conf } // AddSentry initializes a sentry client and extends an existing // logr.Logger with the hook. func AddSentry(l logr.Logger, client *sentry.Client, tags map[string]string) (logr.Logger, SyncFunc, error) { return AddSink(l, WithSentry(client, tags)) } // AddSink extends an existing logr.Logger with a new sink. It returns the new logr.Logger, a cleanup function, and an // error. // // The new sink will not inherit any of the existing logger's key-value pairs. Key-value pairs can be added to the new // sink specifically by passing them to this function. func AddSink(l logr.Logger, core zapcore.Core, keysAndValues ...any) (logr.Logger, SyncFunc, error) { // New key-value pairs cannot be ergonomically added directly to cores. logr has code to do it, but that code is not // exported. Rather than replicating it ourselves, we indirectly use it by creating a temporary logger for the new // core, adding the key-value pairs to the temporary logger, and then extracting the temporary logger's modified // core. newSinkLogger := zapr.NewLogger(zap.New(core)) newSinkLogger = newSinkLogger.WithValues(keysAndValues...) newCoreLogger, err := getZapLogger(newSinkLogger) if err != nil { return l, nil, fmt.Errorf("error setting up new key-value pairs: %w", err) } newSinkCore := newCoreLogger.Core() zapLogger, err := getZapLogger(l) if err != nil { return l, nil, errors.New("unsupported logr implementation") } newLoggerOptions := []zap.Option{ // Tee the new core together with the original core zap.WrapCore(func(core zapcore.Core) zapcore.Core { return zapcore.NewTee(core, newSinkCore) }), // CMR: zapr.NewLogger, for whatever reason, assumes that the passed-in logger doesn't have its caller frame // adjustment already set up, so it adds a frame skip of 2. However, that assumption doesn't hold here because // we're adding a core to an existing logger rather than creating a new one. I can't figure out a way to disable // this automatic frame adjustment, so we compensate for it with the hamfisted kludge of a compensating offset. zap.AddCallerSkip(-2), } zapLogger = zapLogger.WithOptions(newLoggerOptions...) newLogger := zapr.NewLogger(zapLogger) return newLogger, zapLogger.Sync, nil } // getZapLogger is a helper function that gets the underlying zap logger from a // logr.Logger interface. func getZapLogger(l logr.Logger) (*zap.Logger, error) { if u, ok := l.GetSink().(zapr.Underlier); ok { return u.GetUnderlying(), nil } return nil, errors.New("not a zapr logger") } // WithLevel sets the sink's level to a static level. This option prevents // changing the log level for this sink later on. func WithLevel(level int8) SinkOption { return WithLeveler( // Zap's levels get more verbose as the number gets smaller, as explained // by zapr here: https://github.com/go-logr/zapr#increasing-verbosity // For example setting the level to -2 below, means log.V(2) will be enabled. zap.NewAtomicLevelAt(zapcore.Level(-level)), ) } // WithLeveler sets the sink's level enabler to leveler. func WithLeveler(leveler levelSetter) SinkOption { return func(conf *sinkConfig) { conf.level = leveler } } // WithGlobalRedaction adds values to be redacted from logs. func WithGlobalRedaction() SinkOption { return func(conf *sinkConfig) { conf.redactor = globalRedactor } } // WithSuppressCaller prevents the sink being configured from logging any caller information, irrespective of any other // logger settings. func WithSuppressCaller() SinkOption { return func(conf *sinkConfig) { conf.suppressCaller = true } } // ToLogger converts the logr.Logger into a legacy *log.Logger. func ToLogger(l logr.Logger) *log.Logger { return slog.NewLogLogger(logr.ToSlogHandler(l), slog.LevelInfo) } // ToSlogger converts the logr.Logger into a *slog.Logger. func ToSlogger(l logr.Logger) *slog.Logger { return slog.New(logr.ToSlogHandler(l)) } // newCore is a helper function that creates a default sinkConfig, // applies the options, then creates a zapcore.Core. func newCore( defaultEncoder zapcore.Encoder, defaultSink zapcore.WriteSyncer, defaultLevel levelSetter, opts ...SinkOption, ) zapcore.Core { conf := sinkConfig{ encoder: defaultEncoder, sink: defaultSink, level: defaultLevel, } for _, f := range opts { f(&conf) } core := zapcore.NewCore( conf.encoder, conf.sink, conf.level, ) if conf.redactor != nil { core = NewRedactionCore(core, conf.redactor) } if conf.suppressCaller { core = &suppressCallerCore{Core: core} } return core }