Skip to content

Commit

Permalink
logger: add default logger and subsystem log handler
Browse files Browse the repository at this point in the history
Adds ability to control logging via env vars.
  • Loading branch information
malt3 committed Jan 9, 2024
1 parent ca3b8f9 commit 0fae8dd
Show file tree
Hide file tree
Showing 4 changed files with 431 additions and 0 deletions.
78 changes: 78 additions & 0 deletions internal/logger/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package logger

import (
"context"
"log/slog"
"os"
"strings"
)

// Handler is a slog.Handler that can be used to enable logging on a per-subsystem basis.
type Handler struct {
inner slog.Handler
subsystem string
enabled bool
}

// NewHandler returns a new Handler.
func NewHandler(inner slog.Handler, subsystem string) *Handler {
handler := &Handler{
inner: inner.WithGroup(subsystem),
subsystem: subsystem,
enabled: subsystemEnvEnabled(os.Getenv, subsystem),
}
slog.New(handler).Info("Subsystem logger initialized", "subsystem", subsystem, "state", handler.state())
return handler
}

// Enabled returns true if the given level is enabled.
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.enabled && h.inner.Enabled(ctx, level)
}

// Handle handles the given record.
func (h *Handler) Handle(ctx context.Context, record slog.Record) error {
return h.inner.Handle(ctx, record)
}

// WithAttrs returns a new Handler with the given attributes.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &Handler{
inner: h.inner.WithAttrs(attrs),
subsystem: h.subsystem,
enabled: h.enabled,
}
}

// WithGroup returns a new Handler with the given group.
func (h *Handler) WithGroup(name string) slog.Handler {
return &Handler{
inner: h.inner.WithGroup(name),
subsystem: h.subsystem,
enabled: h.enabled,
}
}

func (h *Handler) state() string {
if h.enabled {
return "enabled"
}
return "disabled"
}

func subsystemEnvEnabled(getEnv func(string) string, subsystem string) bool {
return subsystemAllowListMatch(subsystem, getEnv(LogSubsystems))
}

func subsystemAllowListMatch(subsystem string, allowList string) bool {
if allowList == "*" {
return true
}
for _, allow := range strings.Split(allowList, ",") {
allow = strings.ToLower(strings.TrimSpace(allow))
if allow == subsystem {
return true
}
}
return false
}
109 changes: 109 additions & 0 deletions internal/logger/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package logger

import (
"bytes"
"log/slog"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestHandlerOutput(t *testing.T) {
testCases := map[string]struct {
subsystem string
subsystemEnvList string
wantMessages int
}{
"star": {
subsystem: "foo",
subsystemEnvList: "*",
wantMessages: 2, // message and empty line
},
"match": {
subsystem: "foo",
subsystemEnvList: "foo,bar,baz",
wantMessages: 2, // message and empty line
},
"no match": {
subsystem: "foo",
wantMessages: 1, // empty line
},
}

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)

getEnv := newTestGetEnv(map[string]string{
LogSubsystems: tc.subsystemEnvList,
})

buf := bytes.Buffer{}

handler := &Handler{
inner: slog.NewJSONHandler(&buf, nil).WithGroup(tc.subsystem),
subsystem: tc.subsystem,
enabled: subsystemEnvEnabled(getEnv, tc.subsystem),
}
logger := slog.New(handler)

logger.Info("info", "key", "value")

got := buf.String()
lines := strings.Split(got, "\n")
assert.Len(lines, tc.wantMessages)
for _, line := range lines {
if line == "" {
continue
}
assert.Contains(line, tc.subsystem)
}
})
}
}

func TestSubsystemEnvEnabled(t *testing.T) {
testCases := map[string]struct {
subsystem string
subsystemEnvList string
wantEnabled bool
}{
"empty with star": {
subsystem: "",
subsystemEnvList: "*",
wantEnabled: true,
},
"value with star": {
subsystem: "foo",
subsystemEnvList: "*",
wantEnabled: true,
},
"empty list": {
subsystem: "bar",
subsystemEnvList: "",
},
"match": {
subsystem: "bar",
subsystemEnvList: "foo,bar,baz",
wantEnabled: true,
},
"no match": {
subsystem: "bar",
subsystemEnvList: "foo,baz",
},
}

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)

getEnv := newTestGetEnv(map[string]string{
LogSubsystems: tc.subsystemEnvList,
})

got := subsystemEnvEnabled(getEnv, tc.subsystem)
assert.Equal(tc.wantEnabled, got)
})
}
}
66 changes: 66 additions & 0 deletions internal/logger/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Package logger provides a slog.Logger that can be configured via environment variables.
// NUNKI_LOG_LEVEL can be used to set the log level.
// NUNKI_LOG_FORMAT can be used to set the log format.
// It also offer a slog.Handler that can be used to enable logging on a per-subsystem basis.
// NUNKI_LOG_SUBSYSTEMS can be used to enable logging for specific subsystems.
// If NUNKI_LOG_SUBSYSTEMS has the special value "*", all subsystems are enabled.
// Otherwise, a comma-separated list of subsystem names can be specified.
package logger

import (
"fmt"
"io"
"log/slog"
"os"
"strings"
)

const (
// LogLevel is the environment variable used to set the log level.
LogLevel = "NUNKI_LOG_LEVEL"
// LogFormat is the environment variable used to set the log format.
LogFormat = "NUNKI_LOG_FORMAT"
// LogSubsystems is the environment variable used to enable logging for specific subsystems.
LogSubsystems = "NUNKI_LOG_SUBSYSTEMS"
)

// Default returns a logger configured via environment variables.
func Default() (*slog.Logger, error) {
logLevel, err := getLogLevel(os.Getenv)
if err != nil {
return nil, err
}
logger := slog.New(logHandler(os.Getenv)(os.Stderr, &slog.HandlerOptions{
Level: logLevel,
}))
logger.Info("Logger initialized", "level", logLevel.String())
return logger, nil
}

func getLogLevel(getEnv func(string) string) (slog.Level, error) {
logLevel := getEnv(LogLevel)
switch strings.ToLower(logLevel) {
case "debug":
return slog.LevelDebug, nil
case "", "info":
return slog.LevelInfo, nil
case "warn":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
}

return slog.Level(0), fmt.Errorf("invalid log level: %q", logLevel)
}

func logHandler(getEnv func(string) string) func(w io.Writer, opts *slog.HandlerOptions) slog.Handler {
switch strings.ToLower(getEnv(LogFormat)) {
case "json":
return func(w io.Writer, opts *slog.HandlerOptions) slog.Handler {
return slog.NewJSONHandler(w, opts)
}
}
return func(w io.Writer, opts *slog.HandlerOptions) slog.Handler {
return slog.NewTextHandler(w, opts)
}
}
Loading

0 comments on commit 0fae8dd

Please sign in to comment.