-
Notifications
You must be signed in to change notification settings - Fork 174
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Kit Patella <[email protected]>
- Loading branch information
Showing
2 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
} | ||
} |