-
Notifications
You must be signed in to change notification settings - Fork 173
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add logger package and tests (#3108)
Signed-off-by: Kit Patella <[email protected]>
- Loading branch information
Showing
2 changed files
with
375 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,159 @@ | ||
// SPDX-License-Identifier: Apache-2.0 | ||
// SPDX-FileCopyrightText: 2021-Present The Zarf Authors | ||
|
||
// Package logger implements a log/slog based logger in Zarf. | ||
package logger | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"log/slog" | ||
"os" | ||
"strings" | ||
"sync/atomic" | ||
) | ||
|
||
var defaultLogger atomic.Pointer[slog.Logger] | ||
|
||
// init sets a logger with default config when the package is initialized. | ||
func init() { | ||
l, _ := New(ConfigDefault()) //nolint:errcheck | ||
SetDefault(l) | ||
} | ||
|
||
// Level declares each supported log level. These are 1:1 what log/slog supports by default. Info is the default level. | ||
type Level int | ||
|
||
// Store names for Levels | ||
var ( | ||
Debug = Level(slog.LevelDebug) // -4 | ||
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. An empty Format defaults to text. | ||
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 ( | ||
// 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 | ||
|
||
// Destination declares an io.Writer to send logs to. | ||
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{} | ||
|
||
// Use default destination if none | ||
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 | ||
case "", 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 retrieves a logger from the package default. This is intended as a fallback when a logger cannot easily be | ||
// passed in as a dependency, like when developing a new function. Use it like you would use context.TODO(). | ||
func Default() *slog.Logger { | ||
return defaultLogger.Load() | ||
} | ||
|
||
// SetDefault takes a logger and atomically stores it as the package default. This is intended to be called when the | ||
// application starts to override the default config with application-specific config. See Default() for more usage | ||
// details. | ||
func SetDefault(l *slog.Logger) { | ||
defaultLogger.Store(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,216 @@ | ||
// SPDX-License-Identifier: Apache-2.0 | ||
// SPDX-FileCopyrightText: 2021-Present The Zarf Authors | ||
|
||
// Package logger implements a log/slog based logger in Zarf. | ||
package logger | ||
|
||
import ( | ||
"os" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
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) | ||
require.NoError(t, err) | ||
require.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) | ||
require.Error(t, err) | ||
require.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) | ||
require.NoError(t, err) | ||
require.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) | ||
require.Error(t, err) | ||
}) | ||
} | ||
} |