diff --git a/v2/context.go b/v2/context.go new file mode 100644 index 0000000..0f5fd31 --- /dev/null +++ b/v2/context.go @@ -0,0 +1,65 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package log provides types and functions related to logging, passing +// loggers through a context, and attaching context to the logger. +// +// This package uses [log/slog] as the logging backend. It provides an +// [Entry] type for compatibility with code previously using logrus-style +// logging, while delegating all output to slog. +// +// Log level, format, and handler configuration should be done directly +// through [log/slog] using [slog.SetDefault] before using this package. +package log + +import ( + "context" + "log/slog" +) + +// G is a shorthand for [GetLogger]. +// +// We may want to define this locally to a package to get package tagged log +// messages. +var G = GetLogger + +// L is the default logger. +var L = &Entry{} + +type loggerKey struct{} + +// WithLogger returns a new context with the provided logger. Use in +// combination with logger.WithField(s) for great effect. +func WithLogger(ctx context.Context, logger *Entry) context.Context { + e := &Entry{ + logger: logger.logger, + attr: logger.attr, + ctx: ctx, + } + return context.WithValue(ctx, loggerKey{}, e) +} + +// GetLogger retrieves the current logger from the context. If no logger is +// available, the default logger is returned. +func GetLogger(ctx context.Context) *Entry { + if logger := ctx.Value(loggerKey{}); logger != nil { + return logger.(*Entry) + } + return &Entry{ + logger: slog.Default(), + ctx: ctx, + } +} diff --git a/v2/context_test.go b/v2/context_test.go new file mode 100644 index 0000000..4e3c2dd --- /dev/null +++ b/v2/context_test.go @@ -0,0 +1,98 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package log + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "testing" +) + +func TestLoggerContext(t *testing.T) { + ctx := context.Background() + ctx = WithLogger(ctx, G(ctx).WithField("test", "one")) + + e := GetLogger(ctx) + a := G(ctx) + if e != a { + t.Errorf("should be the same entry: %+v, %+v", e, a) + } +} + +func TestWithFields(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + ctx := context.Background() + ctx = WithLogger(ctx, &Entry{logger: logger, ctx: ctx}) + + l := G(ctx) + l = l.WithFields(Fields{"hello1": "world1"}) + l = l.WithFields(map[string]any{"hello2": "world2"}) + l.Info("test message") + + output := buf.String() + for _, expected := range []string{"hello1=world1", "hello2=world2", "test message"} { + if !bytes.Contains([]byte(output), []byte(expected)) { + t.Errorf("expected output to contain %q, got: %s", expected, output) + } + } +} + +func TestLogf(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: LevelTrace})) + + ctx := context.Background() + ctx = WithLogger(ctx, &Entry{logger: logger, ctx: ctx}) + + l := G(ctx) + + l.Tracef("trace %s", "msg") + l.Debugf("debug %s", "msg") + l.Infof("info %s", "msg") + l.Warnf("warn %s", "msg") + l.Errorf("error %s", "msg") + + output := buf.String() + for _, expected := range []string{"trace msg", "debug msg", "info msg", "warn msg", "error msg"} { + if !bytes.Contains([]byte(output), []byte(expected)) { + t.Errorf("expected output to contain %q, got: %s", expected, output) + } + } +} + +func TestWithError(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + ctx := context.Background() + ctx = WithLogger(ctx, &Entry{logger: logger, ctx: ctx}) + + l := G(ctx).WithError(fmt.Errorf("test error")) + l.Info("something failed") + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("error")) { + t.Errorf("expected output to contain error field, got: %s", output) + } + if !bytes.Contains([]byte(output), []byte("test error")) { + t.Errorf("expected output to contain error message, got: %s", output) + } +} diff --git a/v2/entry.go b/v2/entry.go new file mode 100644 index 0000000..afa8852 --- /dev/null +++ b/v2/entry.go @@ -0,0 +1,159 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package log + +import ( + "context" + "fmt" + "log/slog" +) + +// Entry is a logging entry. It contains all the fields passed with +// [Entry.WithFields]. It's finally logged when Trace, Debug, Info, Warn, +// or Error is called on it. These objects can be reused and passed around +// as much as you wish to avoid field duplication. +// +// Entry is for close compatibility with logrus and containerd/log, +// consider using slog directly for new packages. +// +// NOTE: while similar, this package is not compatible with logrus +// or containerd/log. It should be compatible for most uses but +// the interface is reduced and only supports slog capabilities. +// Some notable differences: +// - No Fatal or Panic levels, no equivalent in slog +type Entry struct { + logger *slog.Logger + + attr []slog.Attr + + ctx context.Context +} + +// Fields type to pass to "WithFields". +type Fields = map[string]any + +// WithError adds an error as single field to the Entry. +func (entry *Entry) WithError(err error) *Entry { + return entry.WithField("error", err) +} + +// WithContext adds a context to the Entry. +func (entry *Entry) WithContext(ctx context.Context) *Entry { + return &Entry{ + logger: entry.logger, + attr: entry.attr, + ctx: ctx, + } +} + +// WithField adds a single field to the Entry. +func (entry *Entry) WithField(key string, value any) *Entry { + return entry.WithFields(Fields{key: value}) +} + +// WithFields adds a map of fields to the Entry. +func (entry *Entry) WithFields(fields Fields) *Entry { + attr := make([]slog.Attr, len(entry.attr), len(entry.attr)+len(fields)) + copy(attr, entry.attr) + for k, v := range fields { + attr = append(attr, slog.Any(k, v)) + } + return &Entry{ + logger: entry.logger, + attr: attr, + ctx: entry.ctx, + } +} + +// Log logs a message at the given level. +func (entry *Entry) Log(level slog.Level, msg string) { + logger := entry.logger + if logger == nil { + logger = slog.Default() + } + ctx := entry.ctx + if ctx == nil { + ctx = context.Background() + } + logger.LogAttrs(ctx, level, msg, entry.attr...) +} + +// Logf logs a formatted message at the given level. +func (entry *Entry) Logf(level slog.Level, format string, args ...any) { + logger := entry.logger + if logger == nil { + logger = slog.Default() + } + if !logger.Enabled(entry.ctx, level) { + return + } + ctx := entry.ctx + if ctx == nil { + ctx = context.Background() + } + logger.LogAttrs(ctx, level, fmt.Sprintf(format, args...), entry.attr...) +} + +// Trace logs a message at trace level (slog.LevelDebug-4). +func (entry *Entry) Trace(msg string) { + entry.Log(LevelTrace, msg) +} + +// Tracef logs a formatted message at trace level. +func (entry *Entry) Tracef(format string, args ...any) { + entry.Logf(LevelTrace, format, args...) +} + +// Debug logs a message at debug level. +func (entry *Entry) Debug(msg string) { + entry.Log(slog.LevelDebug, msg) +} + +// Debugf logs a formatted message at debug level. +func (entry *Entry) Debugf(format string, args ...any) { + entry.Logf(slog.LevelDebug, format, args...) +} + +// Info logs a message at info level. +func (entry *Entry) Info(msg string) { + entry.Log(slog.LevelInfo, msg) +} + +// Infof logs a formatted message at info level. +func (entry *Entry) Infof(format string, args ...any) { + entry.Logf(slog.LevelInfo, format, args...) +} + +// Warn logs a message at warn level. +func (entry *Entry) Warn(msg string) { + entry.Log(slog.LevelWarn, msg) +} + +// Warnf logs a formatted message at warn level. +func (entry *Entry) Warnf(format string, args ...any) { + entry.Logf(slog.LevelWarn, format, args...) +} + +// Error logs a message at error level. +func (entry *Entry) Error(msg string) { + entry.Log(slog.LevelError, msg) +} + +// Errorf logs a formatted message at error level. +func (entry *Entry) Errorf(format string, args ...any) { + entry.Logf(slog.LevelError, format, args...) +} diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..ed2572c --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,3 @@ +module github.com/containerd/log/v2 + +go 1.24 diff --git a/v2/level.go b/v2/level.go new file mode 100644 index 0000000..b33477f --- /dev/null +++ b/v2/level.go @@ -0,0 +1,52 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package log + +import "log/slog" + +// Level is a logging level. +type Level = slog.Level + +// Supported log levels. These correspond to slog levels, with additional +// levels defined for trace, fatal, and panic for compatibility. +const ( + // LevelTrace designates finer-grained informational events than + // [slog.LevelDebug]. + LevelTrace Level = slog.LevelDebug - 4 + + // LevelDebug level. Usually only enabled when debugging. Very verbose + // logging. + LevelDebug Level = slog.LevelDebug + + // LevelInfo level. General operational entries about what's going on + // inside the application. + LevelInfo Level = slog.LevelInfo + + // LevelWarn level. Non-critical entries that deserve eyes. + LevelWarn Level = slog.LevelWarn + + // LevelError level. Logs errors that should definitely be noted. + LevelError Level = slog.LevelError + + // LevelFatal level. Provided for compatibility; maps to + // slog.LevelError + 2. + LevelFatal Level = slog.LevelError + 2 + + // LevelPanic level. Provided for compatibility; maps to + // slog.LevelError + 4. + LevelPanic Level = slog.LevelError + 4 +)