diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bb6bf7d7278..3e6cbd5f723 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -109,6 +109,15 @@ updates: schedule: interval: weekly day: sunday + - package-ecosystem: gomod + directory: /bridges/otellogr + labels: + - dependencies + - go + - Skip Changelog + schedule: + interval: weekly + day: sunday - package-ecosystem: gomod directory: /bridges/prometheus labels: diff --git a/bridges/otellogr/config.go b/bridges/otellogr/config.go new file mode 100644 index 00000000000..e4998ef7eb8 --- /dev/null +++ b/bridges/otellogr/config.go @@ -0,0 +1,82 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package otellogr // import "go.opentelemetry.io/contrib/bridges/otellogr" + +import ( + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/sdk/instrumentation" +) + +type config struct { + provider log.LoggerProvider + scope instrumentation.Scope +} + +func newConfig(options []Option) config { + var c config + for _, opt := range options { + c = opt.apply(c) + } + + var emptyScope instrumentation.Scope + if c.scope == emptyScope { + c.scope = instrumentation.Scope{ + Name: bridgeName, + Version: version, + } + } + + if c.provider == nil { + c.provider = global.GetLoggerProvider() + } + + return c +} + +func (c config) logger() log.Logger { + var opts []log.LoggerOption + if c.scope.Version != "" { + opts = append(opts, log.WithInstrumentationVersion(c.scope.Version)) + } + if c.scope.SchemaURL != "" { + opts = append(opts, log.WithSchemaURL(c.scope.SchemaURL)) + } + return c.provider.Logger(c.scope.Name, opts...) +} + +// Option configures a [Handler]. +type Option interface { + apply(config) config +} + +type optFunc func(config) config + +func (f optFunc) apply(c config) config { return f(c) } + +// WithInstrumentationScope returns an [Option] that configures the scope of +// the [log.Logger] used by a [LogSink]. +// +// By default if this Option is not provided, the LogSink will use a default +// instrumentation scope describing this bridge package. It is recommended to +// provide this so log data can be associated with its source package or +// module. +func WithInstrumentationScope(scope instrumentation.Scope) Option { + return optFunc(func(c config) config { + c.scope = scope + 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 + }) +} diff --git a/bridges/otellogr/example_test.go b/bridges/otellogr/example_test.go new file mode 100644 index 00000000000..d511a210ad5 --- /dev/null +++ b/bridges/otellogr/example_test.go @@ -0,0 +1,18 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package otellogr_test + +import ( + "github.com/go-logr/logr" + otellogr "go.opentelemetry.io/contrib/bridges/otellogr" + "go.opentelemetry.io/otel/log/noop" +) + +func Example() { + // Use a working LoggerProvider implementation instead e.g. using go.opentelemetry.io/otel/sdk/log. + provider := noop.NewLoggerProvider() + + // Create an *slog.Logger with *otelslog.Handler and use it in your application. + logr.New(otellogr.NewLogSink(otellogr.WithLoggerProvider(provider))) +} diff --git a/bridges/otellogr/go.mod b/bridges/otellogr/go.mod new file mode 100644 index 00000000000..c4f2ea3f0f1 --- /dev/null +++ b/bridges/otellogr/go.mod @@ -0,0 +1,16 @@ +module go.opentelemetry.io/contrib/bridges/otellogr + +go 1.21 + +require ( + github.com/go-logr/logr v1.4.1 + go.opentelemetry.io/otel/log v0.0.1-alpha.0.20240319182811-335f4de960ff + go.opentelemetry.io/otel/sdk v1.24.0 +) + +require ( + github.com/go-logr/stdr v1.2.2 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect +) diff --git a/bridges/otellogr/go.sum b/bridges/otellogr/go.sum new file mode 100644 index 00000000000..193ec03125f --- /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.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/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.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/log v0.0.1-alpha.0.20240319182811-335f4de960ff h1:WMikyBC7alFcyvvVj22Spm8ad72hjUJTS5BQ4YlBDXY= +go.opentelemetry.io/otel/log v0.0.1-alpha.0.20240319182811-335f4de960ff/go.mod h1:ToOZ06+agH/C+P2+bp6Ea/CLMDviyMVUNUQaKTB1ieg= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +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..3ac2b3c240c --- /dev/null +++ b/bridges/otellogr/logsink.go @@ -0,0 +1,255 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package otellogr // import "go.opentelemetry.io/contrib/bridges/otellogr" + +import ( + "context" + "fmt" + "reflect" + "strconv" + "time" + + "github.com/go-logr/logr" + "go.opentelemetry.io/otel/log" +) + +const ( + bridgeName = "go.opentelemetry.io/contrib/bridges/otellogr" + + // nameKey is used to log the `WithName` values as an additional attribute. + nameKey = "logger" + + // errKey is used to log the error parameter of Error as an additional attribute. + errKey = "err" +) + +type LogSink struct { + name string + logger log.Logger + values []log.KeyValue +} + +// Compile-time check *Handler implements logr.LogSink. +var _ logr.LogSink = (*LogSink)(nil) + +func NewLogSink(options ...Option) *LogSink { + c := newConfig(options) + return &LogSink{ + logger: c.logger(), + } +} + +func (l *LogSink) log(err error, msg string, serverity log.Severity, kvList ...any) { + var record log.Record + record.SetTimestamp(time.Now()) + record.SetBody(log.StringValue(msg)) + record.SetSeverity(serverity) + + if l.name != "" { + record.AddAttributes(log.String(nameKey, l.name)) + } + + if err != nil { + record.AddAttributes(log.KeyValue{ + Key: errKey, + Value: convertValue(err), + }) + } + + if len(l.values) > 0 { + record.AddAttributes(l.values...) + } + + kv := convertKVList(kvList) + if len(kv) > 0 { + record.AddAttributes(kv...) + } + + ctx := context.Background() + 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 + const sevOffset = int(log.SeverityDebug) + record.SetSeverity(log.Severity(level + sevOffset)) + ctx := context.Background() + return l.logger.Enabled(ctx, record) +} + +// Error logs an error, with the given message and key/value pairs as +// context. +func (l *LogSink) Error(err error, msg string, keysAndValues ...any) { + const severity = log.SeverityError + + l.log(err, msg, severity, keysAndValues...) +} + +// Info logs a non-error message with the given key/value pairs as context. +func (l *LogSink) Info(level int, msg string, keysAndValues ...any) { + const sevOffset = int(log.SeverityInfo) + severity := log.Severity(sevOffset + level) + + l.log(nil, msg, severity, 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. +} + +// WithName returns a new LogSink with the specified name appended. +func (l LogSink) WithName(name string) logr.LogSink { + if len(l.name) > 0 { + l.name += "/" + } + l.name += name + return &l +} + +// WithValues returns a new LogSink with additional key/value pairs. +func (l LogSink) WithValues(keysAndValues ...any) logr.LogSink { + attrs := convertKVList(keysAndValues) + l.values = append(l.values, attrs...) + return &l +} + +func convertKVList(kvList []any) []log.KeyValue { + if len(kvList) == 0 { + return nil + } + if len(kvList)%2 != 0 { + // Ensure an odd number of items here does not corrupt the list + kvList = append(kvList, nil) + } + + kv := make([]log.KeyValue, 0, len(kvList)/2) + for i := 0; i < len(kvList); i += 2 { + k, ok := kvList[i].(string) + if !ok { + // Ensure that the key is a string + k = fmt.Sprintf("%v", kvList[i]) + } + kv = append(kv, log.KeyValue{ + Key: k, + Value: convertValue(kvList[i+1]), + }) + } + return kv +} + +func convertValue(v interface{}) 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 assignUintValue(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 assignUintValue(val) + case uintptr: + return assignUintValue(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: + stringValue := `"` + strconv.FormatComplex(complex128(val), 'f', -1, 64) + `"` + return log.StringValue(stringValue) + case complex128: + stringValue := `"` + strconv.FormatComplex(val, 'f', -1, 128) + `"` + return log.StringValue(stringValue) + case time.Time: + return log.Int64Value(val.UnixNano()) + case []byte: + return log.BytesValue(val) + case error: + return log.StringValue(fmt.Sprintf("%+v", val)) + } + + t := reflect.TypeOf(v) + if t == nil { + return log.Value{} + } + val := reflect.ValueOf(v) + switch t.Kind() { + case reflect.Bool: + return log.BoolValue(val.Bool()) + case reflect.String: + return log.StringValue(val.String()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return log.Int64Value(val.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return assignUintValue(val.Uint()) + case reflect.Float32, reflect.Float64: + return log.Float64Value(val.Float()) + case reflect.Complex64, reflect.Complex128: + stringValue := `"` + strconv.FormatComplex(complex128(val.Complex()), 'f', -1, 64) + `"` + return log.StringValue(stringValue) + 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() { + kvs = append(kvs, log.KeyValue{ + Key: k.String(), + 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)) +} + +func assignUintValue(v uint64) log.Value { + const maxInt64 = ^uint64(0) >> 1 + if v > maxInt64 { + value := strconv.FormatUint(v, 10) + return log.StringValue(value) + } + 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..c2e94e6dec4 --- /dev/null +++ b/bridges/otellogr/logsink_test.go @@ -0,0 +1,32 @@ +package otellogr + +import ( + "errors" + "testing" + + "github.com/go-logr/logr" + "go.opentelemetry.io/otel/log/noop" +) + +func TestWIP(t *testing.T) { + provider := noop.NewLoggerProvider() + logger := logr.New(NewLogSink(WithLoggerProvider(provider))) + + // Tests are WIP, the following code is just to make sure the code compiles + + logger.Info("This is a test message") + + logger.Error(errors.New("This is a test error message"), "This is a test error message") + + logger.V(1).Info("This is a test message with verbosity level 1") + + logger.WithName("test").Info("This is a test message with a name") + + logger.WithValues("key", "value").Info("This is a test message with values") + + logger.WithName("test").WithValues("key", "value").Info("This is a test message with a name and values") + + logger.Info("This is a test message with a name and values", "key", "value") + + logger.Info("This is a test message with a name and values", "int", 10, "bool", true) +} diff --git a/bridges/otellogr/version.go b/bridges/otellogr/version.go new file mode 100644 index 00000000000..c0931956f91 --- /dev/null +++ b/bridges/otellogr/version.go @@ -0,0 +1,7 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package otellogr // import "go.opentelemetry.io/contrib/bridges/otellogr" + +// version is the current release version of otelslog in use. +const version = "0.0.1-alpha"