1
0
Fork 0
mirror of https://github.com/chainguard-dev/clog synced 2026-07-06 22:12:46 +00:00

Add context logger.

This is heavily inspired by https://pkg.go.dev/knative.dev/pkg/logging

Also adds top level logging methods for convinience.
This commit is contained in:
Billy Lynch 2023-12-12 18:27:55 -05:00
parent 81392732b8
commit 5565dbc976
Failed to extract signature
8 changed files with 389 additions and 30 deletions

View file

@ -4,6 +4,37 @@ Context aware slog
## Usage
### Context Logger
The context Logger can be used to use Loggers from the context. This is
sometimes preferred over the [Context Handler](#context-handler), since this can
make it easier to use different loggers in different contexts (e.g. testing).
This approach is heavily inspired by
[knative.dev/pkg/logging](https://pkg.go.dev/knative.dev/pkg/logging)
```go
func main() {
log := slogctx.New(slog.Default).With("a", "b")
ctx := slogctx.WithLogger(log)
// Grab logger from context and use
slogctx.FromContext(ctx).With("foo", "bar").Infof("hello world")
// Package level context loggers are also aware
slogctx.ErrorContext(ctx, "asdf")
}
```
```sh
2023/12/12 18:27:27 INFO hello world a=b foo=bar
2023/12/12 18:27:27 ERROR asdf a=b
```
### Context Handler
The context Handler can be used to insert values from the context.
```go
func init() {
slog.SetDefault(slog.New(slogctx.NewHandler(slog.NewTextHandler(os.Stdout, nil))))
@ -11,7 +42,7 @@ func init() {
func main() {
ctx := context.Background()
ctx = slogctx.With(ctx, "foo", "bar")
ctx = slogctx.WithValue(ctx, "foo", "bar")
// Use slog package directly
slog.InfoContext(ctx, "hello world", slog.Bool("baz", true))

View file

@ -14,11 +14,15 @@ func init() {
func main() {
ctx := context.Background()
ctx = slogctx.With(ctx, "foo", "bar")
ctx = slogctx.WithValues(ctx, "foo", "bar")
// Use slog package directly
slog.InfoContext(ctx, "hello world", slog.Bool("baz", true))
// glog / zap style (note: can't pass additional attributes)
slogctx.Errorf(ctx, "hello %s", "world")
slogctx.Errorf("hello %s", "world")
log := slogctx.DefaultLogger()
log.With("foo", "bar").Infof("hello %s", "world")
log.Info("hello world", slog.Bool("baz", true))
}

19
examples/logger/main.go Normal file
View file

@ -0,0 +1,19 @@
package main
import (
"context"
"log/slog"
"github.com/wlynch/slogctx"
)
func main() {
log := slogctx.NewLogger(slog.Default()).With("a", "b")
ctx := slogctx.WithLogger(context.Background(), log)
// Grab logger from context and use
slogctx.FromContext(ctx).With("foo", "bar").Infof("hello world")
// Package level context loggers are also aware
slogctx.ErrorContext(ctx, "asdf")
}

View file

@ -13,10 +13,10 @@ type ctxVal map[string]any
// With returns a new context with the given values.
// Values are expected to be key-value pairs, where the key is a string.
// e.g. With(ctx, "foo", "bar", "baz", 1)
// e.g. WithValues(ctx, "foo", "bar", "baz", 1)
// If a value already exists, it is overwritten.
// If an odd number of arguments are provided, With panics.
func With(ctx context.Context, args ...any) context.Context {
func WithValues(ctx context.Context, args ...any) context.Context {
if len(args)%2 != 0 {
panic("non-even number of arguments")
}

View file

@ -3,30 +3,51 @@ package slogctx
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"reflect"
"testing"
)
func TestContextHandler(t *testing.T) {
ctx := context.Background()
ctx = With(ctx, "foo", "bar")
ctx2 := With(ctx,
ctx = WithValues(ctx, "foo", "bar")
ctx2 := WithValues(ctx,
"a", "b",
"c", "d",
)
ctx = With(ctx, "b", 1)
ctx = WithValues(ctx, "b", 1)
for _, tc := range []struct {
ctx context.Context
want string
want map[string]any
}{
{ctx, "foo=bar b=1"},
{ctx2, "foo=bar a=b c=d"},
{ctx, map[string]any{
"b": float64(1),
"foo": "bar",
}},
{ctx2, map[string]any{
"foo": "bar",
"a": "b",
"c": "d",
}},
} {
t.Run("", func(t *testing.T) {
b := new(bytes.Buffer)
log := slog.New(NewHandler(slog.NewTextHandler(b, nil)))
log.InfoContext(tc.ctx, "hello world")
log := slog.New(NewHandler(slog.NewJSONHandler(b, testopts)))
log.InfoContext(tc.ctx, "")
tc.want["level"] = "INFO"
tc.want["msg"] = ""
var got map[string]any
if err := json.Unmarshal(b.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(tc.want, got) {
t.Errorf("want %v, got %v", tc.want, got)
}
})
}
}

86
log.go
View file

@ -2,38 +2,90 @@ package slogctx
import (
"context"
"fmt"
"log/slog"
)
func Info(ctx context.Context, args ...any) {
slog.InfoContext(ctx, fmt.Sprint(args...))
// Info calls Info on the default logger.
func Info(msg string, args ...any) {
wrap(context.Background(), DefaultLogger(), slog.LevelInfo, msg, args...)
}
func Infof(ctx context.Context, msg string, args ...any) {
slog.InfoContext(ctx, fmt.Sprintf(msg, args...))
// InfoContext calls InfoContext on the context logger.
// If a Logger is found in the context, it will be used.
func InfoContext(ctx context.Context, msg string, args ...any) {
wrap(ctx, FromContext(ctx), slog.LevelInfo, msg, args...)
}
func Warn(ctx context.Context, args ...any) {
slog.WarnContext(ctx, fmt.Sprint(args...))
// Infof calls Infof on the default logger.
func Infof(format string, args ...any) {
wrapf(context.Background(), DefaultLogger(), slog.LevelInfo, format, args...)
}
func Warnf(ctx context.Context, msg string, args ...any) {
slog.WarnContext(ctx, fmt.Sprintf(msg, args...))
// InfoContextf calls InfoContextf on the context logger.
// If a Logger is found in the context, it will be used.
func InfoContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, FromContext(ctx), slog.LevelInfo, format, args...)
}
func Error(ctx context.Context, args ...any) {
slog.ErrorContext(ctx, fmt.Sprint(args...))
// Warn calls Warn on the default logger.
func Warn(msg string, args ...any) {
wrap(context.Background(), DefaultLogger(), slog.LevelWarn, msg, args...)
}
func Errorf(ctx context.Context, msg string, args ...any) {
slog.ErrorContext(ctx, fmt.Sprintf(msg, args...))
// WarnContext calls WarnContext on the context logger.
// If a Logger is found in the context, it will be used.
func WarnContext(ctx context.Context, msg string, args ...any) {
wrap(ctx, FromContext(ctx), slog.LevelWarn, msg, args...)
}
func Debug(ctx context.Context, args ...any) {
slog.DebugContext(ctx, fmt.Sprint(args...))
// Warnf calls Warnf on the default logger.
func Warnf(format string, args ...any) {
wrapf(context.Background(), DefaultLogger(), slog.LevelWarn, format, args...)
}
func Debugf(ctx context.Context, msg string, args ...any) {
slog.DebugContext(ctx, fmt.Sprintf(msg, args...))
// WarnContextf calls WarnContextf on the context logger.
// If a Logger is found in the context, it will be used.
func WarnContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, FromContext(ctx), slog.LevelWarn, format, args...)
}
// Error calls Error on the default logger.
func Error(msg string, args ...any) {
wrap(context.Background(), DefaultLogger(), slog.LevelError, msg, args...)
}
// ErrorContext calls ErrorContext on the context logger.
func ErrorContext(ctx context.Context, msg string, args ...any) {
wrap(ctx, FromContext(ctx), slog.LevelError, msg, args...)
}
// Errorf calls Errorf on the default logger.
func Errorf(format string, args ...any) {
wrapf(context.Background(), DefaultLogger(), slog.LevelError, format, args...)
}
// ErrorContextf calls ErrorContextf on the context logger.
func ErrorContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, FromContext(ctx), slog.LevelError, format, args...)
}
// Debug calls Debug on the default logger.
func Debug(msg string, args ...any) {
wrap(context.Background(), DefaultLogger(), slog.LevelDebug, msg, args...)
}
// DebugContext calls DebugContext on the context logger.
func DebugContext(ctx context.Context, msg string, args ...any) {
wrap(ctx, FromContext(ctx), slog.LevelDebug, msg, args...)
}
// Debugf calls Debugf on the default logger.
func Debugf(format string, args ...any) {
wrapf(context.Background(), DefaultLogger(), slog.LevelDebug, format, args...)
}
// DebugContextf calls DebugContextf on the context logger.
// If a Logger is found in the context, it will be used.
func DebugContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, FromContext(ctx), slog.LevelDebug, format, args...)
}

117
logger.go Normal file
View file

@ -0,0 +1,117 @@
package slogctx
import (
"context"
"fmt"
"log/slog"
"runtime"
"time"
)
// Logger implements a wrapper around [slog.Logger] that adds formatter functions (e.g. Infof, Errorf)
type Logger struct {
slog.Logger
}
// DefaultLogger returns a new logger that uses the default [slog.Logger].
func DefaultLogger() *Logger {
return NewLogger(slog.Default())
}
// NewLogger returns a new logger that wraps the given [slog.Logger].
func NewLogger(l *slog.Logger) *Logger {
if l == nil {
l = slog.Default()
}
return &Logger{Logger: *l}
}
// New returns a new logger that wraps the given [slog.Handler].
func New(h slog.Handler) *Logger {
return NewLogger(slog.New(h))
}
// With calls [Logger.With] on the default logger.
func With(args ...any) *Logger {
return DefaultLogger().With(args...)
}
// With calls [Logger.With] on the logger.
func (l *Logger) With(args ...any) *Logger {
return NewLogger(l.Logger.With(args...))
}
// Infof logs at LevelInfo with the given format and arguments.
func (l *Logger) Infof(format string, args ...any) {
wrapf(context.Background(), l, slog.LevelInfo, format, args...)
}
// InfoContextf logs at LevelInfo with the given context, format and arguments.
func (l *Logger) InfoContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, l, slog.LevelInfo, format, args...)
}
// Warnf logs at LevelWarn with the given format and arguments.
func (l *Logger) Warnf(format string, args ...any) {
wrapf(context.Background(), l, slog.LevelWarn, format, args...)
}
// WarnContextf logs at LevelWarn with the given context, format and arguments.
func (l *Logger) WarnContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, l, slog.LevelWarn, format, args...)
}
// Errorf logs at LevelError with the given format and arguments.
func (l *Logger) Errorf(format string, args ...any) {
wrapf(context.Background(), l, slog.LevelError, format, args...)
}
// ErrorContextf logs at LevelError with the given context, format and arguments.
func (l *Logger) ErrorContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, l, slog.LevelError, format, args...)
}
// Debugf logs at LevelDebug with the given format and arguments.
func (l *Logger) Debugf(format string, args ...any) {
wrapf(context.Background(), l, slog.LevelDebug, format, args...)
}
// DebugContextf logs at LevelDebug with the given context, format and arguments.
func (l *Logger) DebugContextf(ctx context.Context, format string, args ...any) {
wrapf(ctx, l, slog.LevelDebug, format, args...)
}
// Base returns the underlying [slog.Logger].
func (l *Logger) Base() *slog.Logger {
return &l.Logger
}
func wrap(ctx context.Context, logger *Logger, level slog.Level, msg string, args ...any) {
var pcs [1]uintptr
runtime.Callers(3, pcs[:]) // skip [Callers, Infof, wrapf]
r := slog.NewRecord(time.Now(), level, msg, pcs[0])
r.Add(args...)
_ = logger.Handler().Handle(ctx, r)
}
// wrapf is like wrap, but uses fmt.Sprintf to format the message.
// NOTE: args are passed to fmt.Sprintf, not as [slog.Attr].
func wrapf(ctx context.Context, logger *Logger, level slog.Level, format string, args ...any) {
var pcs [1]uintptr
runtime.Callers(3, pcs[:]) // skip [Callers, Infof, wrapf]
r := slog.NewRecord(time.Now(), level, fmt.Sprintf(format, args...), pcs[0])
_ = logger.Handler().Handle(ctx, r)
}
type loggerKey struct{}
func WithLogger(ctx context.Context, logger *Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, logger)
}
func FromContext(ctx context.Context) *Logger {
if logger, ok := ctx.Value(loggerKey{}).(*Logger); ok {
return logger
}
return DefaultLogger()
}

115
logger_test.go Normal file
View file

@ -0,0 +1,115 @@
package slogctx
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"reflect"
"testing"
)
var (
testopts = &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Ignore time to make testing easier.
if a.Key == "time" {
return slog.Attr{}
}
return a
},
}
)
func TestLogger(t *testing.T) {
ctx := context.Background()
b := new(bytes.Buffer)
base := slog.New(NewHandler(slog.NewJSONHandler(b, testopts)))
log := NewLogger(base).With("a", "b")
log.InfoContext(ctx, "")
t.Log(b.String())
want := map[string]any{
"level": "INFO",
"msg": "",
"a": "b",
}
var got map[string]any
if err := json.Unmarshal(b.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, got) {
t.Errorf("want %v, got %v", want, got)
}
}
func TestLoggerNilBase(t *testing.T) {
log := NewLogger(nil)
log.Info("")
}
func TestLoggerFromContext(t *testing.T) {
b := new(bytes.Buffer)
base := slog.New(NewHandler(slog.NewJSONHandler(b, testopts)))
log := NewLogger(base).With("a", "b")
ctx := WithLogger(context.Background(), log)
FromContext(ctx).Info("")
want := map[string]any{
"level": "INFO",
"msg": "",
"a": "b",
}
t.Run("FromContext.Info", func(t *testing.T) {
var got map[string]any
if err := json.Unmarshal(b.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, got) {
t.Errorf("want %v, got %v", want, got)
}
})
b.Reset()
t.Run("slogctx.Info", func(t *testing.T) {
InfoContext(ctx, "")
var got map[string]any
if err := json.Unmarshal(b.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(want, got) {
t.Errorf("want %v, got %v", want, got)
}
})
}
func TestLoggerPC(t *testing.T) {
b := new(bytes.Buffer)
log := NewLogger(slog.New(NewHandler(slog.NewJSONHandler(b, &slog.HandlerOptions{
AddSource: true,
ReplaceAttr: testopts.ReplaceAttr,
}))))
log.Info("")
t.Log(b.String())
var got struct {
Source struct {
File string `json:"file"`
Function string `json:"function"`
} `json:"source"`
}
if err := json.Unmarshal(b.Bytes(), &got); err != nil {
t.Fatal(err)
}
// Knowing that the PC is from this test is good enough.
want := fmt.Sprintf("github.com/wlynch/slogctx.%s", t.Name())
if got.Source.Function != want {
t.Errorf("want %v, got %v", want, got)
}
}