From af968c534e1ae210e09f939a0bcc6ccc72f049a2 Mon Sep 17 00:00:00 2001 From: Kit Patella Date: Wed, 16 Oct 2024 10:40:46 -0700 Subject: [PATCH] feat: add logger package and tests Signed-off-by: Kit Patella --- src/pkg/logger/logger.go | 143 +++++++++++++++++++++++ src/pkg/logger/logger_test.go | 211 ++++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 src/pkg/logger/logger.go create mode 100644 src/pkg/logger/logger_test.go diff --git a/src/pkg/logger/logger.go b/src/pkg/logger/logger.go new file mode 100644 index 0000000000..b813aff84a --- /dev/null +++ b/src/pkg/logger/logger.go @@ -0,0 +1,143 @@ +package logger + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" +) + +// Level declares each supported log level. These are 1:1 what log/slog supports by default. +type Level int + +var ( + Debug = Level(slog.LevelDebug) // -4 + // Info is the default log level. + Info = Level(slog.LevelInfo) // 0 + Warn = Level(slog.LevelWarn) // 4 + Error = Level(slog.LevelError) // 8 +) + +// validLevels is a set that provides an ergonomic way to check if a level is a member of the set. +var validLevels = map[Level]bool{ + Debug: true, + Info: true, + Warn: true, + Error: true, +} + +// strLevels maps a string to its Level. +var strLevels = map[string]Level{ + "debug": Debug, + "info": Info, + "warn": Warn, + "error": Error, +} + +// ParseLevel takes a string representation of a Level, ensure it exists, and then converts it into a Level. +func ParseLevel(s string) (Level, error) { + k := strings.ToLower(s) + l, ok := strLevels[k] + if !ok { + return 0, fmt.Errorf("invalid log level: %s", k) + } + return l, nil +} + +// Format declares the kind of logging handler to use. +type Format string + +// ToLower takes a Format string and converts it to lowercase for case-agnostic validation. Users shouldn't have to care +// about "json" vs. "JSON" for example - they should both work. +func (f Format) ToLower() Format { + return Format(strings.ToLower(string(f))) +} + +// TODO(mkcp): Add dev format +var ( + // FormatEmpty means no format was supplied. This is equivalent to a default, or "Text". + FormatEmpty Format = "" + // FormatText uses the standard slog TextHandler + FormatText Format = "text" + // FormatJSON uses the standard slog JSONHandler + FormatJSON Format = "json" + // FormatNone sends log writes to DestinationNone / io.Discard + FormatNone Format = "none" +) + +// More printers would be great, like dev format https://github.com/golang-cz/devslog +// and a pretty console slog https://github.com/phsym/console-slog + +type Destination io.Writer + +var ( + // DestinationDefault points to Stderr + DestinationDefault Destination = os.Stderr + // DestinationNone discards logs as they are received + DestinationNone Destination = io.Discard +) + +// Config is configuration for a logger. +type Config struct { + // Level sets the log level. An empty value corresponds to Info aka 0. + Level + Format + Destination +} + +// ConfigDefault returns a Config with defaults like Text formatting at Info level writing to Stderr. +func ConfigDefault() Config { + return Config{ + Level: Info, + Format: FormatText, + Destination: DestinationDefault, // Stderr + } +} + +// New takes a Config and returns a validated logger. +func New(cfg Config) (*slog.Logger, error) { + var handler slog.Handler + opts := slog.HandlerOptions{} + + if cfg.Destination == nil { + cfg.Destination = DestinationDefault + } + + // Check that we have a valid log level. + if !validLevels[cfg.Level] { + return nil, fmt.Errorf("unsupported log level: %d", cfg.Level) + } + opts.Level = slog.Level(cfg.Level) + + switch cfg.Format.ToLower() { + // Use Text handler if no format provided, e.g. "" for Empty + case FormatEmpty, FormatText: + handler = slog.NewTextHandler(cfg.Destination, &opts) + case FormatJSON: + handler = slog.NewJSONHandler(cfg.Destination, &opts) + // TODO(mkcp): Add dev format + // case FormatDev: + // handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{ + // AddSource: true, + // }) + case FormatNone: + handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{}) + // Format not found, let's error out + default: + return nil, fmt.Errorf("unsupported log format: %s", cfg.Format) + } + + log := slog.New(handler) + return log, nil +} + +// Default gets a logger from the atomic slog default. +func Default() *slog.Logger { + return slog.Default() +} + +// SetDefault takes a logger and sets it as the atomic slog default. +func SetDefault(l *slog.Logger) { + slog.SetDefault(l) +} diff --git a/src/pkg/logger/logger_test.go b/src/pkg/logger/logger_test.go new file mode 100644 index 0000000000..c427482319 --- /dev/null +++ b/src/pkg/logger/logger_test.go @@ -0,0 +1,211 @@ +package logger + +import ( + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func Test_New(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + cfg Config + }{ + { + name: "Empty level, format, and destination are ok", + cfg: Config{}, + }, + { + name: "Default config is ok", + cfg: ConfigDefault(), + }, + { + name: "Debug logs are ok", + cfg: Config{ + Level: Debug, + }, + }, + { + name: "Info logs are ok", + cfg: Config{ + Level: Info, + }, + }, + { + name: "Warn logs are ok", + cfg: Config{ + Level: Warn, + }, + }, + { + name: "Error logs are ok", + cfg: Config{ + Level: Error, + }, + }, + { + name: "Text format is supported", + cfg: Config{ + Format: FormatText, + }, + }, + { + name: "JSON format is supported", + cfg: Config{ + Format: FormatJSON, + }, + }, + { + name: "FormatNone is supported to disable logs", + cfg: Config{ + Format: FormatNone, + }, + }, + { + name: "DestinationNone is supported to disable logs", + cfg: Config{ + Destination: DestinationNone, + }, + }, + { + name: "users can send logs to any io.Writer", + cfg: Config{ + Destination: os.Stdout, + }, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := New(tc.cfg) + assert.NoError(t, err) + assert.NotNil(t, res) + }) + } +} + +func Test_NewErrors(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + cfg Config + }{ + { + name: "unsupported log level errors", + cfg: Config{ + Level: 3, + }, + }, + { + name: "wildly unsupported log level errors", + cfg: Config{ + Level: 42389412389213489, + }, + }, + { + name: "unsupported format errors", + cfg: Config{ + Format: "foobar", + }, + }, + { + name: "wildly unsupported format errors", + cfg: Config{ + Format: "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$ lorem ipsum dolor sit amet 243897 )*&($#", + }, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := New(tc.cfg) + assert.Error(t, err) + assert.Nil(t, res) + }) + } +} + +func Test_ParseLevel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + s string + expect Level + }{ + { + name: "can parse debug", + s: "debug", + expect: Debug, + }, + { + name: "can parse info", + s: "Info", + expect: Info, + }, + { + name: "can parse warn", + s: "warn", + expect: Warn, + }, + { + name: "can parse error", + s: "error", + expect: Error, + }, + { + name: "can handle uppercase", + s: "ERROR", + expect: Error, + }, + { + name: "can handle inconsistent uppercase", + s: "errOR", + expect: Error, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := ParseLevel(tc.s) + assert.NoError(t, err) + assert.Equal(t, tc.expect, res) + }) + } +} + +func Test_ParseLevelErrors(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + s string + }{ + { + name: "errors out on unknown level", + s: "SUPER-DEBUG-10x-supremE", + }, + { + name: "is precise about character variations", + s: "érrør", + }, + { + name: "does not partial match level", + s: "error-info", + }, + { + name: "does not partial match level 2", + s: "info-error", + }, + { + name: "does not partial match level 3", + s: "info2", + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseLevel(tc.s) + assert.Error(t, err) + }) + } +}