-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
logger: add default logger and subsystem log handler
Adds ability to control logging via env vars.
- Loading branch information
Showing
4 changed files
with
436 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,75 @@ | ||
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 { | ||
if subsystem != "" { | ||
inner = inner.WithGroup(subsystem) | ||
} | ||
|
||
return &Handler{ | ||
inner: inner, | ||
subsystem: subsystem, | ||
enabled: subsystemEnvEnabled(os.Getenv, subsystem), | ||
} | ||
} | ||
|
||
// 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 subsystemEnvEnabled(getEnv func(string) string, subsystem string) bool { | ||
allowList := getEnv(LogSubsystems) | ||
|
||
return subsystemAllowListMatch(subsystem, allowList) | ||
} | ||
|
||
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 | ||
} |
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,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 := new(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) | ||
}) | ||
} | ||
} |
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,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" | ||
"strconv" | ||
"strings" | ||
) | ||
|
||
const ( | ||
LogLevel = "NUNKI_LOG_LEVEL" | ||
LogFormat = "NUNKI_LOG_FORMAT" | ||
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 | ||
} | ||
return slog.New(logHandler(os.Getenv)(os.Stderr, &slog.HandlerOptions{ | ||
Level: logLevel, | ||
})), 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 | ||
} | ||
|
||
numericLevel, err := strconv.Atoi(logLevel) | ||
if err != nil { | ||
return slog.Level(0), fmt.Errorf("invalid log level: %q", logLevel) | ||
} | ||
return slog.Level(numericLevel), nil | ||
} | ||
|
||
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) | ||
} | ||
} |
Oops, something went wrong.