Skip to content

Commit

Permalink
feat: add logger package and tests (#3108)
Browse files Browse the repository at this point in the history
Signed-off-by: Kit Patella <[email protected]>
  • Loading branch information
mkcp authored Oct 17, 2024
1 parent 5d71319 commit 39d0812
Show file tree
Hide file tree
Showing 2 changed files with 375 additions and 0 deletions.
159 changes: 159 additions & 0 deletions src/pkg/logger/logger.go
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)
}
216 changes: 216 additions & 0 deletions src/pkg/logger/logger_test.go
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)
})
}
}

0 comments on commit 39d0812

Please sign in to comment.