diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c5d1a37850..486b5d9f82b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Support for the `OTEL_HTTP_CLIENT_COMPATIBILITY_MODE=http/dup` environment variable in `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` to emit attributes for both the v1.20.0 and v1.26.0 semantic conventions. (#5401) - The `go.opentelemetry.io/contrib/bridges/otelzerolog` module. This module provides an OpenTelemetry logging bridge for `github.com/rs/zerolog`. (#5405) +- The `go.opentelemetry.io/contrib/bridges/otellogr` module. + This module provides an OpenTelemetry logging bridge for `github.com/go-logr/logr`. (#5357) ### Removed diff --git a/CODEOWNERS b/CODEOWNERS index d4abc2b9d36..5903b168737 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,6 +30,7 @@ bridges/otellogrus/ @open-te bridges/prometheus/ @open-telemetry/go-approvers @dashpole bridges/otelzap/ @open-telemetry/go-approvers @pellared @khushijain21 bridges/otelzerolog/ @open-telemetry/go-approvers @dmathieu @AkhigbeEromo +bridges/otellogr/ @open-telemetry/go-approvers @scorpionknifes config/ @open-telemetry/go-approvers @MadVikingGod @pellared @codeboten diff --git a/bridges/otellogr/example_test.go b/bridges/otellogr/example_test.go new file mode 100644 index 00000000000..2d6fc02a006 --- /dev/null +++ b/bridges/otellogr/example_test.go @@ -0,0 +1,35 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package otellogr_test + +import ( + "github.com/go-logr/logr" + + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/noop" + + otellogr "go.opentelemetry.io/contrib/bridges/otellogr" +) + +func Example() { + // Use a working LoggerProvider implementation instead e.g. using go.opentelemetry.io/otel/sdk/log. + provider := noop.NewLoggerProvider() + + // Create an logr.Logger with *otellogr.LogSink and use it in your application. + logr.New(otellogr.NewLogSink( + "my/pkg/name", + otellogr.WithLoggerProvider(provider), + // Optionally, set the log level severity mapping. + otellogr.WithLevelSeverity(func(i int) log.Severity { + switch i { + case 0: + return log.SeverityInfo + case 1: + return log.SeverityWarn + default: + return log.SeverityFatal + } + })), + ) +} diff --git a/bridges/otellogr/go.mod b/bridges/otellogr/go.mod new file mode 100644 index 00000000000..6c30f3e814a --- /dev/null +++ b/bridges/otellogr/go.mod @@ -0,0 +1,19 @@ +module go.opentelemetry.io/contrib/bridges/otellogr + +go 1.21 + +require ( + github.com/go-logr/logr v1.4.2 + github.com/stretchr/testify v1.9.0 + go.opentelemetry.io/otel/log v0.4.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/bridges/otellogr/go.sum b/bridges/otellogr/go.sum new file mode 100644 index 00000000000..399466bb30c --- /dev/null +++ b/bridges/otellogr/go.sum @@ -0,0 +1,25 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/log v0.4.0 h1:/vZ+3Utqh18e8TPjuc3ecg284078KWrR8BRz+PQAj3o= +go.opentelemetry.io/otel/log v0.4.0/go.mod h1:DhGnQvky7pHy82MIRV43iXh3FlKN8UUKftn0KbLOq6I= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bridges/otellogr/logsink.go b/bridges/otellogr/logsink.go new file mode 100644 index 00000000000..5359387ba4d --- /dev/null +++ b/bridges/otellogr/logsink.go @@ -0,0 +1,415 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package otellogr provides [LogSink], an [logr.LogSink] implementation, that +// can be used to bridge between the [github.com/go-logr/logr] API and +// [OpenTelemetry]. +// +// # Record Conversion +// +// The logr records are converted to OpenTelemetry [log.Record] in the following +// way: +// +// - Time is set as the current time of conversion. +// - Message is set as the Body using a [log.StringValue]. +// - Level is transformed and set as the Severity. The SeverityText is not +// set. +// - PC is dropped. +// - KeyAndValues are transformed and set as Attributes. +// - Error is always logged as an additional attribute with the key "err" and +// with the severity [log.SeverityError]. +// - Name is logged as an additional attribute with the key "logger". +// - The [context.Context] value in KeyAndValues is propagated to OpenTelemetry +// log record. All non-nested [context.Context] values are ignored and not +// added as attributes. If there are multiple [context.Context] the last one +// is used. +// +// The Level is transformed by using the [WithLevelSeverity] option. If this is +// not provided it would default to a function that adds an offset to the logr +// such that [logr.Info] is transformed to [log.SeverityInfo]. For example: +// +// - [logr.Info] is transformed to [log.SeverityInfo]. +// - [logr.V(0)] is transformed to [log.SeverityInfo]. +// - [logr.V(1)] is transformed to [log.SeverityInfo2]. +// - [logr.V(2)] is transformed to [log.SeverityInfo3]. +// - ... +// - [logr.V(15)] is transformed to [log.SeverityFatal4]. +// - [logr.Error] is transformed to [log.SeverityError]. +// +// KeysAndValues values are transformed based on their type. The following types are +// supported: +// +// - [bool] are transformed to [log.BoolValue]. +// - [string] are transformed to [log.StringValue]. +// - [int], [int8], [int16], [int32], [int64] are transformed to +// [log.Int64Value]. +// - [uint], [uint8], [uint16], [uint32], [uint64], [uintptr] are transformed +// to [log.Int64Value] or [log.StringValue] if the value is too large. +// - [float32], [float64] are transformed to [log.Float64Value]. +// - [time.Duration] are transformed to [log.Int64Value] with the nanoseconds. +// - [complex64], [complex128] are transformed to [log.MapValue] with the keys +// "r" and "i" for the real and imaginary parts. The values are +// [log.Float64Value]. +// - [time.Time] are transformed to [log.Int64Value] with the nanoseconds. +// - [[]byte] are transformed to [log.BytesValue]. +// - [error] are transformed to [log.StringValue] with the error message. +// - [nil] are transformed to an empty [log.Value]. +// - [struct] are transformed to [log.StringValue] with the struct fields. +// - [slice], [array] are transformed to [log.SliceValue] with the elements. +// - [map] are transformed to [log.MapValue] with the key-value pairs. +// - [pointer], [interface] are transformed to the dereferenced value. +// +// [OpenTelemetry]: https://opentelemetry.io/docs/concepts/signals/logs/ +package otellogr // import "go.opentelemetry.io/contrib/bridges/otellogr" + +import ( + "context" + "fmt" + "math" + "reflect" + "strconv" + "time" + + "github.com/go-logr/logr" + + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" +) + +const ( + // exceptionMessageKey is the key used for the error message. + exceptionMessageKey = "exception.message" +) + +type config struct { + provider log.LoggerProvider + version string + schemaURL string + + levelSeverity func(int) log.Severity +} + +func newConfig(options []Option) config { + var c config + for _, opt := range options { + c = opt.apply(c) + } + + if c.provider == nil { + c.provider = global.GetLoggerProvider() + } + + if c.levelSeverity == nil { + c.levelSeverity = func(level int) log.Severity { + const sevOffset = int(log.SeverityInfo) + return log.Severity(level + sevOffset) + } + } + + return c +} + +func (c config) logger(name string) log.Logger { + var opts []log.LoggerOption + if c.version != "" { + opts = append(opts, log.WithInstrumentationVersion(c.version)) + } + if c.schemaURL != "" { + opts = append(opts, log.WithSchemaURL(c.schemaURL)) + } + return c.provider.Logger(name, opts...) +} + +// Option configures a [LogSink]. +type Option interface { + apply(config) config +} + +type optFunc func(config) config + +func (f optFunc) apply(c config) config { return f(c) } + +// WithVersion returns an [Option] that configures the version of the +// [log.Logger] used by a [Hook]. The version should be the version of the +// package that is being logged. +func WithVersion(version string) Option { + return optFunc(func(c config) config { + c.version = version + return c + }) +} + +// WithSchemaURL returns an [Option] that configures the semantic convention +// schema URL of the [log.Logger] used by a [Hook]. The schemaURL should be +// the schema URL for the semantic conventions used in log records. +func WithSchemaURL(schemaURL string) Option { + return optFunc(func(c config) config { + c.schemaURL = schemaURL + return c + }) +} + +// WithLoggerProvider returns an [Option] that configures [log.LoggerProvider] +// used by a [LogSink] to create its [log.Logger]. +// +// By default if this Option is not provided, the LogSink will use the global +// LoggerProvider. +func WithLoggerProvider(provider log.LoggerProvider) Option { + return optFunc(func(c config) config { + c.provider = provider + return c + }) +} + +// WithLevelSeverity returns an [Option] that configures the function used to +// convert logr levels to OpenTelemetry log severities. +// +// By default if this Option is not provided, the LogSink will use a default +// conversion function which adds an offset to the logr level to get the +// OpenTelemetry severity. The offset is such that logr.Info("message") +// converts to OpenTelemetry [log.SeverityInfo]. +func WithLevelSeverity(f func(int) log.Severity) Option { + return optFunc(func(c config) config { + c.levelSeverity = f + return c + }) +} + +// NewLogSink returns a new [LogSink] to be used as a [logr.LogSink]. +// +// If [WithLoggerProvider] is not provided, the returned LogSink will use the +// global LoggerProvider. +func NewLogSink(name string, options ...Option) *LogSink { + c := newConfig(options) + return &LogSink{ + name: name, + newLogger: c.logger, + logger: c.logger(name), + levelSeverity: c.levelSeverity, + } +} + +// LogSink is a [logr.LogSink] that sends all logging records it receives to +// OpenTelemetry. See package documentation for how conversions are made. +type LogSink struct { + // Ensure forward compatibility by explicitly making this not comparable. + noCmp [0]func() //nolint: unused // This is indeed used. + + name string + newLogger func(name string) log.Logger + logger log.Logger + levelSeverity func(int) log.Severity + values []log.KeyValue +} + +// Compile-time check *Handler implements logr.LogSink. +var _ logr.LogSink = (*LogSink)(nil) + +// log sends a log record to the OpenTelemetry logger. +func (l *LogSink) log(err error, msg string, serverity log.Severity, kvList ...any) { + var record log.Record + record.SetBody(log.StringValue(msg)) + record.SetSeverity(serverity) + + if err != nil { + record.AddAttributes( + log.KeyValue{ + Key: exceptionMessageKey, + Value: convertValue(err), + }, + ) + } + + if len(l.values) > 0 { + record.AddAttributes(l.values...) + } + + ctx, kv := convertKVs(kvList) + if len(kv) > 0 { + record.AddAttributes(kv...) + } + + l.logger.Emit(ctx, record) +} + +// Enabled tests whether this LogSink is enabled at the specified V-level. +// For example, commandline flags might be used to set the logging +// verbosity and disable some info logs. +func (l *LogSink) Enabled(level int) bool { + var record log.Record + record.SetSeverity(l.levelSeverity(level)) + ctx := context.Background() + return l.logger.Enabled(ctx, record) +} + +// Error logs an error, with the given message and key/value pairs. +func (l *LogSink) Error(err error, msg string, keysAndValues ...any) { + l.log(err, msg, log.SeverityError, keysAndValues...) +} + +// Info logs a non-error message with the given key/value pairs. +func (l *LogSink) Info(level int, msg string, keysAndValues ...any) { + l.log(nil, msg, l.levelSeverity(level), keysAndValues...) +} + +// Init receives optional information about the logr library this +// implementation does not use it. +func (l *LogSink) Init(info logr.RuntimeInfo) { + // We don't need to do anything here. + // CallDepth is used to calculate the caller's PC. + // PC is dropped as part of the conversion to the OpenTelemetry log.Record. +} + +// WithName returns a new LogSink with the specified name appended. +func (l LogSink) WithName(name string) logr.LogSink { + l.logger = l.newLogger(l.name + "/" + name) + return &l +} + +// WithValues returns a new LogSink with additional key/value pairs. +func (l LogSink) WithValues(keysAndValues ...any) logr.LogSink { + _, attrs := convertKVs(keysAndValues) + l.values = append(l.values, attrs...) + return &l +} + +// convertKVs converts a list of key-value pairs to a list of [log.KeyValue]. +// The last [context.Context] value is returned as the context. +func convertKVs(keysAndValues []any) (context.Context, []log.KeyValue) { + ctx := context.Background() + + if len(keysAndValues) == 0 { + return ctx, nil + } + if len(keysAndValues)%2 != 0 { + // Ensure an odd number of items here does not corrupt the list + keysAndValues = append(keysAndValues, nil) + } + + kv := make([]log.KeyValue, 0, len(keysAndValues)/2) + for i := 0; i < len(keysAndValues); i += 2 { + k, ok := keysAndValues[i].(string) + if !ok { + // Ensure that the key is a string + k = fmt.Sprintf("%v", keysAndValues[i]) + } + + v := keysAndValues[i+1] + if vCtx, ok := v.(context.Context); ok { + // Special case when a field is of context.Context type. + ctx = vCtx + continue + } + + kv = append(kv, log.KeyValue{ + Key: k, + Value: convertValue(v), + }) + } + return ctx, kv +} + +func convertValue(v any) log.Value { + // Handling the most common types without reflect is a small perf win. + switch val := v.(type) { + case bool: + return log.BoolValue(val) + case string: + return log.StringValue(val) + case int: + return log.Int64Value(int64(val)) + case int8: + return log.Int64Value(int64(val)) + case int16: + return log.Int64Value(int64(val)) + case int32: + return log.Int64Value(int64(val)) + case int64: + return log.Int64Value(val) + case uint: + return convertUintValue(uint64(val)) + case uint8: + return log.Int64Value(int64(val)) + case uint16: + return log.Int64Value(int64(val)) + case uint32: + return log.Int64Value(int64(val)) + case uint64: + return convertUintValue(val) + case uintptr: + return convertUintValue(uint64(val)) + case float32: + return log.Float64Value(float64(val)) + case float64: + return log.Float64Value(val) + case time.Duration: + return log.Int64Value(val.Nanoseconds()) + case complex64: + r := log.Float64("r", real(complex128(val))) + i := log.Float64("i", imag(complex128(val))) + return log.MapValue(r, i) + case complex128: + r := log.Float64("r", real(val)) + i := log.Float64("i", imag(val)) + return log.MapValue(r, i) + case time.Time: + return log.Int64Value(val.UnixNano()) + case []byte: + return log.BytesValue(val) + case error: + return log.StringValue(val.Error()) + } + + t := reflect.TypeOf(v) + if t == nil { + return log.Value{} + } + val := reflect.ValueOf(v) + switch t.Kind() { + case reflect.Struct: + return log.StringValue(fmt.Sprintf("%+v", v)) + case reflect.Slice, reflect.Array: + items := make([]log.Value, 0, val.Len()) + for i := 0; i < val.Len(); i++ { + items = append(items, convertValue(val.Index(i).Interface())) + } + return log.SliceValue(items...) + case reflect.Map: + kvs := make([]log.KeyValue, 0, val.Len()) + for _, k := range val.MapKeys() { + var key string + // If the key is a struct, use %+v to print the struct fields. + if k.Kind() == reflect.Struct { + key = fmt.Sprintf("%+v", k.Interface()) + } else { + key = fmt.Sprintf("%v", k.Interface()) + } + kvs = append(kvs, log.KeyValue{ + Key: key, + Value: convertValue(val.MapIndex(k).Interface()), + }) + } + return log.MapValue(kvs...) + case reflect.Ptr, reflect.Interface: + if val.IsNil() { + return log.Value{} + } + return convertValue(val.Elem().Interface()) + } + + // Try to handle this as gracefully as possible. + // + // Don't panic here. it is preferable to have user's open issue + // asking why their attributes have a "unhandled: " prefix than + // say that their code is panicking. + return log.StringValue(fmt.Sprintf("unhandled: (%s) %+v", t, v)) +} + +// convertUintValue converts a uint64 to a log.Value. +// If the value is too large to fit in an int64, it is converted to a string. +func convertUintValue(v uint64) log.Value { + if v > math.MaxInt64 { + return log.StringValue(strconv.FormatUint(v, 10)) + } + return log.Int64Value(int64(v)) +} diff --git a/bridges/otellogr/logsink_test.go b/bridges/otellogr/logsink_test.go new file mode 100644 index 00000000000..bca25479cbb --- /dev/null +++ b/bridges/otellogr/logsink_test.go @@ -0,0 +1,599 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package otellogr + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/log/logtest" +) + +type expectedRecord struct { + Body log.Value + Severity log.Severity + Attributes []log.KeyValue +} + +type errorTest struct { + s string +} + +func (e *errorTest) Error() string { + return e.s +} + +var now = time.Now() + +func TestNewLogSinkConfiguration(t *testing.T) { + t.Run("default", func(t *testing.T) { + rec := logtest.NewRecorder() + prev := global.GetLoggerProvider() + defer global.SetLoggerProvider(prev) + global.SetLoggerProvider(rec) + + var ls *LogSink + assert.NotPanics(t, func() { ls = NewLogSink("name") }) + assert.NotNil(t, ls) + assert.Equal(t, ls.newLogger("name"), ls.logger) + }) + + t.Run("with_options", func(t *testing.T) { + rec := logtest.NewRecorder() + var ls *LogSink + assert.NotPanics(t, func() { + ls = NewLogSink( + "name", + WithVersion("42.0"), + WithSchemaURL("https://example.com"), + WithLoggerProvider(rec), + WithLevelSeverity(func(i int) log.Severity { + return log.SeverityFatal + }), + ) + }) + assert.NotNil(t, ls) + assert.NotNil(t, ls.levelSeverity) + assert.Equal(t, log.SeverityFatal, ls.levelSeverity(0)) + assert.Equal(t, ls.newLogger("name"), ls.logger) + }) + + t.Run("with_name", func(t *testing.T) { + rec := logtest.NewRecorder() + var ls *LogSink + var lsWithName logr.LogSink + assert.NotPanics(t, func() { + ls = NewLogSink( + "name", + WithLoggerProvider(rec), + ) + lsWithName = ls.WithName("test") + }) + assert.NotNil(t, ls) + assert.NotNil(t, lsWithName) + assert.NotEqual(t, ls, lsWithName) + assert.Equal(t, ls.newLogger("name"), ls.logger) + assert.Equal(t, ls.newLogger("name/test"), lsWithName.(*LogSink).logger) + }) +} + +func TestLogSink(t *testing.T) { + for _, tt := range []struct { + name string + f func(*logr.Logger) + expectedLoggerCount int + expectedRecords []expectedRecord + }{ + { + name: "info", + f: func(l *logr.Logger) { + l.Info("info message") + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("info message"), + Severity: log.SeverityInfo, + }, + }, + }, + { + name: "info_multi_attrs", + f: func(l *logr.Logger) { + l.Info("msg", + "struct", struct{ data int64 }{data: 1}, + "bool", true, + "duration", time.Minute, + "float64", 3.14159, + "int64", -2, + "string", "str", + "time", now, + "uint64", uint64(3), + ) + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("msg"), + Severity: log.SeverityInfo, + Attributes: []log.KeyValue{ + log.String("struct", "{data:1}"), + log.Bool("bool", true), + log.Int64("duration", 60_000_000_000), + log.Float64("float64", 3.14159), + log.Int64("int64", -2), + log.String("string", "str"), + log.Int64("time", now.UnixNano()), + log.Int64("uint64", 3), + }, + }, + }, + }, + { + name: "error", + f: func(l *logr.Logger) { + l.Error(&errorTest{"test error"}, "error message") + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("error message"), + Severity: log.SeverityError, + Attributes: []log.KeyValue{ + log.String(exceptionMessageKey, "test error"), + }, + }, + }, + }, + { + name: "error_multi_attrs", + f: func(l *logr.Logger) { + l.Error(errors.New("test error"), "msg", + "struct", struct{ data int64 }{data: 1}, + "bool", true, + "duration", time.Minute, + "float64", 3.14159, + "int64", -2, + "string", "str", + "time", now, + "uint64", uint64(3), + ) + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("msg"), + Severity: log.SeverityError, + Attributes: []log.KeyValue{ + log.String(exceptionMessageKey, "test error"), + log.String("struct", "{data:1}"), + log.Bool("bool", true), + log.Int64("duration", 60_000_000_000), + log.Float64("float64", 3.14159), + log.Int64("int64", -2), + log.String("string", "str"), + log.Int64("time", now.UnixNano()), + log.Int64("uint64", 3), + }, + }, + }, + }, + { + name: "info_with_name", + f: func(l *logr.Logger) { + l.WithName("test").Info("info message with name") + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("info message with name"), + Severity: log.SeverityInfo, + }, + }, + }, + { + name: "info_with_name_nested", + f: func(l *logr.Logger) { + l.WithName("test").WithName("test").Info("info message with name") + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("info message with name"), + Severity: log.SeverityInfo, + }, + }, + }, + { + name: "info_with_attrs", + f: func(l *logr.Logger) { + l.WithValues("key", "value").Info("info message with attrs") + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("info message with attrs"), + Severity: log.SeverityInfo, + Attributes: []log.KeyValue{ + log.String("key", "value"), + }, + }, + }, + }, + { + name: "info_with_attrs_nested", + f: func(l *logr.Logger) { + l.WithValues("key1", "value1").Info("info message with attrs", "key2", "value2") + }, + expectedRecords: []expectedRecord{ + { + Body: log.StringValue("info message with attrs"), + Severity: log.SeverityInfo, + Attributes: []log.KeyValue{ + log.String("key1", "value1"), + log.String("key2", "value2"), + }, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + rec := logtest.NewRecorder() + ls := NewLogSink("name", WithLoggerProvider(rec)) + l := logr.New(ls) + tt.f(&l) + + last := len(rec.Result()) - 1 + + assert.Len(t, rec.Result()[last].Records, len(tt.expectedRecords)) + for i, record := range rec.Result()[last].Records { + assert.Equal(t, tt.expectedRecords[i].Body, record.Body()) + assert.Equal(t, tt.expectedRecords[i].Severity, record.Severity()) + + var attributes []log.KeyValue + record.WalkAttributes(func(kv log.KeyValue) bool { + attributes = append(attributes, kv) + return true + }) + assert.Equal(t, tt.expectedRecords[i].Attributes, attributes) + } + }) + } +} + +func TestLogSinkWithName(t *testing.T) { + rec := logtest.NewRecorder() + ls := NewLogSink("name", WithLoggerProvider(rec)) + lsWithName := ls.WithName("test") + require.NotEqual(t, ls, lsWithName) + + require.NotEqual(t, lsWithName, ls.WithName("test")) +} + +func TestLogSinkEnabled(t *testing.T) { + rec := logtest.NewRecorder( + logtest.WithEnabledFunc(func(ctx context.Context, record log.Record) bool { + return record.Severity() == log.SeverityInfo + }), + ) + ls := NewLogSink( + "name", + WithLoggerProvider(rec), + WithLevelSeverity(func(i int) log.Severity { + switch i { + case 0: + return log.SeverityDebug + default: + return log.SeverityInfo + } + }), + ) + + assert.False(t, ls.Enabled(0)) + assert.True(t, ls.Enabled(1)) +} + +func TestConvertKVs(t *testing.T) { + ctx := context.WithValue(context.Background(), "key", "value") // nolint: revive,staticcheck // test context + + for _, tt := range []struct { + name string + + kvs []any + expectedKVs []log.KeyValue + expectedCtx context.Context + }{ + { + name: "empty", + kvs: nil, + }, + { + name: "single_value", + kvs: []any{"key", "value"}, + expectedKVs: []log.KeyValue{ + log.String("key", "value"), + }, + }, + { + name: "multiple_values", + kvs: []any{"key1", "value1", "key2", "value2"}, + expectedKVs: []log.KeyValue{ + log.String("key1", "value1"), + log.String("key2", "value2"), + }, + }, + { + name: "missing_value", + kvs: []any{"key1", "value1", "key2"}, + expectedKVs: []log.KeyValue{ + log.String("key1", "value1"), + {Key: "key2", Value: log.Value{}}, + }, + }, + { + name: "key_not_string", + kvs: []any{42, "value"}, + expectedKVs: []log.KeyValue{ + log.String("42", "value"), + }, + }, + { + name: "context", + kvs: []any{"ctx", ctx, "key", "value"}, + expectedKVs: []log.KeyValue{log.String("key", "value")}, + expectedCtx: ctx, + }, + { + name: "last_context", + kvs: []any{"key", context.Background(), "ctx", ctx}, + expectedKVs: []log.KeyValue{}, + expectedCtx: ctx, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, kvs := convertKVs(tt.kvs) + assert.Equal(t, tt.expectedKVs, kvs) + + if tt.expectedCtx != nil { + assert.Equal(t, tt.expectedCtx, ctx) + } + }) + } +} + +func TestConvertValue(t *testing.T) { + for _, tt := range []struct { + name string + + value any + expectedValue log.Value + }{ + { + name: "bool", + value: true, + expectedValue: log.BoolValue(true), + }, + { + name: "string", + value: "value", + expectedValue: log.StringValue("value"), + }, + { + name: "int", + value: 10, + expectedValue: log.Int64Value(10), + }, + { + name: "int8", + value: int8(127), + expectedValue: log.Int64Value(127), + }, + { + name: "int16", + value: int16(32767), + expectedValue: log.Int64Value(32767), + }, + { + name: "int32", + value: int32(2147483647), + expectedValue: log.Int64Value(2147483647), + }, + { + name: "int64", + value: int64(9223372036854775807), + expectedValue: log.Int64Value(9223372036854775807), + }, + { + name: "uint", + value: uint(42), + expectedValue: log.Int64Value(42), + }, + { + name: "uint8", + value: uint8(255), + expectedValue: log.Int64Value(255), + }, + { + name: "uint16", + value: uint16(65535), + expectedValue: log.Int64Value(65535), + }, + { + name: "uint32", + value: uint32(4294967295), + expectedValue: log.Int64Value(4294967295), + }, + { + name: "uint64", + value: uint64(9223372036854775807), + expectedValue: log.Int64Value(9223372036854775807), + }, + { + name: "uint64-max", + value: uint64(18446744073709551615), + expectedValue: log.StringValue("18446744073709551615"), + }, + { + name: "uintptr", + value: uintptr(12345), + expectedValue: log.Int64Value(12345), + }, + { + name: "float64", + value: float64(3.14159), + expectedValue: log.Float64Value(3.14159), + }, + { + name: "time.Duration", + value: time.Second, + expectedValue: log.Int64Value(1_000_000_000), + }, + { + name: "complex64", + value: complex64(complex(float32(1), float32(2))), + expectedValue: log.MapValue(log.Float64("r", 1), log.Float64("i", 2)), + }, + { + name: "complex128", + value: complex(float64(3), float64(4)), + expectedValue: log.MapValue(log.Float64("r", 3), log.Float64("i", 4)), + }, + { + name: "time.Time", + value: now, + expectedValue: log.Int64Value(now.UnixNano()), + }, + { + name: "[]byte", + value: []byte("hello"), + expectedValue: log.BytesValue([]byte("hello")), + }, + { + name: "error", + value: errors.New("test error"), + expectedValue: log.StringValue("test error"), + }, + { + name: "error", + value: errors.New("test error"), + expectedValue: log.StringValue("test error"), + }, + { + name: "error-nested", + value: fmt.Errorf("test error: %w", errors.New("nested error")), + expectedValue: log.StringValue("test error: nested error"), + }, + { + name: "nil", + value: nil, + expectedValue: log.Value{}, + }, + { + name: "nil_ptr", + value: (*int)(nil), + expectedValue: log.Value{}, + }, + { + name: "int_ptr", + value: func() *int { i := 93; return &i }(), + expectedValue: log.Int64Value(93), + }, + { + name: "string_ptr", + value: func() *string { s := "hello"; return &s }(), + expectedValue: log.StringValue("hello"), + }, + { + name: "bool_ptr", + value: func() *bool { b := true; return &b }(), + expectedValue: log.BoolValue(true), + }, + { + name: "int_empty_array", + value: []int{}, + expectedValue: log.SliceValue([]log.Value{}...), + }, + { + name: "int_array", + value: []int{1, 2, 3}, + expectedValue: log.SliceValue([]log.Value{ + log.Int64Value(1), + log.Int64Value(2), + log.Int64Value(3), + }...), + }, + { + name: "key_value_map", + value: map[string]int{"one": 1}, + expectedValue: log.MapValue( + log.Int64("one", 1), + ), + }, + { + name: "int_string_map", + value: map[int]string{1: "one"}, + expectedValue: log.MapValue( + log.String("1", "one"), + ), + }, + { + name: "nested_map", + value: map[string]map[string]int{"nested": {"one": 1}}, + expectedValue: log.MapValue( + log.Map("nested", + log.Int64("one", 1), + ), + ), + }, + { + name: "struct_key_map", + value: map[struct{ Name string }]int{ + {Name: "John"}: 42, + }, + expectedValue: log.MapValue( + log.Int64("{Name:John}", 42), + ), + }, + { + name: "struct", + value: struct { + Name string + Age int + }{ + Name: "John", + Age: 42, + }, + expectedValue: log.StringValue("{Name:John Age:42}"), + }, + { + name: "struct_ptr", + value: &struct { + Name string + Age int + }{ + Name: "John", + Age: 42, + }, + expectedValue: log.StringValue("{Name:John Age:42}"), + }, + { + name: "ctx", + value: context.Background(), + expectedValue: log.StringValue("context.Background"), + }, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, convertValue(tt.value), tt.expectedValue) + }) + } +} + +func TestConvertValueFloat32(t *testing.T) { + actual := convertValue(float32(3.14)) + expected := log.Float64Value(3.14) + + assert.InDelta(t, actual.AsFloat64(), expected.AsFloat64(), 0.0001) +} diff --git a/versions.yaml b/versions.yaml index 0013dc4fa17..ec8f5ae4d12 100644 --- a/versions.yaml +++ b/versions.yaml @@ -76,6 +76,7 @@ module-sets: experimental-bridge: version: v0.3.0 modules: + - go.opentelemetry.io/contrib/bridges/otellogr - go.opentelemetry.io/contrib/bridges/otelslog - go.opentelemetry.io/contrib/bridges/otellogrus - go.opentelemetry.io/contrib/bridges/otelzap