Repository: open-telemetry/opentelemetry-go-contrib (go)

Issue:
Add logrus log bridge
Add [`logrus.Hook`](https://pkg.go.dev/github.com/sirupsen/logrus#Hook) log bridge.

Pull request:
logrus logging bridge
Closes #5193.

This adds the logrus log bridge

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index b9f99171d..33c25dbf7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -100,6 +100,15 @@ updates:
     schedule:
       interval: weekly
       day: sunday
+  - package-ecosystem: gomod
+    directory: /bridges/otellogrus
+    labels:
+      - dependencies
+      - go
+      - Skip Changelog
+    schedule:
+      interval: weekly
+      day: sunday
   - package-ecosystem: gomod
     directory: /bridges/otelslog
     labels:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 281cec2d1..328858f43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 - The `go.opentelemetry.io/contrib/processors/baggage/baggagetrace` module. This module provides a Baggage Span Processor. (#5404)
 - Add gRPC trace `Filter` for stats handler to `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`. (#5196)
 - Add a repository Code Ownership Policy. (#5555)
+- The `go.opentelemetry.io/contrib/bridges/otellogrus` module.
+  This module provides an OpenTelemetry logging bridge for `github.com/sirupsen/logrus`. (#5355)
 
 ### Changed
 
diff --git a/CODEOWNERS b/CODEOWNERS
index a6a722a61..0103a9b89 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -23,6 +23,7 @@
 CODEOWNERS @MrAlias @MadVikingGod @pellared @dashpole
 
 bridges/otelslog                                                        @open-telemetry/go-approvers @MrAlias @pellared
+bridges/otellogrus/                                                     @open-telemetry/go-approvers @dmathieu @pellared
 bridges/prometheus/                                                     @open-telemetry/go-approvers @dashpole
 bridges/otelzap/                                                        @open-telemetry/go-approvers @pellared @khushijain21
 
diff --git a/bridges/otellogrus/go.mod b/bridges/otellogrus/go.mod
new file mode 100644
index 000000000..479664a08
--- /dev/null
+++ b/bridges/otellogrus/go.mod
@@ -0,0 +1,22 @@
+module go.opentelemetry.io/contrib/bridges/otellogrus
+
+go 1.21
+
+require (
+	github.com/sirupsen/logrus v1.9.3
+	github.com/stretchr/testify v1.9.0
+	go.opentelemetry.io/otel/log v0.2.0-alpha.0.20240515103629-7708ace91199
+	go.opentelemetry.io/otel/sdk v1.24.0
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/go-logr/logr v1.4.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.26.0 // indirect
+	go.opentelemetry.io/otel/metric v1.26.0 // indirect
+	go.opentelemetry.io/otel/trace v1.26.0 // indirect
+	golang.org/x/sys v0.18.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/bridges/otellogrus/go.sum b/bridges/otellogrus/go.sum
new file mode 100644
index 000000000..9b33d0a91
--- /dev/null
+++ b/bridges/otellogrus/go.sum
@@ -0,0 +1,36 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+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.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
+go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
+go.opentelemetry.io/otel/log v0.2.0-alpha.0.20240515103629-7708ace91199 h1:jHKW4lMJY0fGbONUyJAEdQ+Vc/oY516uGqtCJUtNmpU=
+go.opentelemetry.io/otel/log v0.2.0-alpha.0.20240515103629-7708ace91199/go.mod h1:vbFZc65yq4c4ssvXY43y/nIqkNJLxORrqw0L85P59LA=
+go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
+go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
+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.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
+go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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/otellogrus/hook.go b/bridges/otellogrus/hook.go
new file mode 100644
index 000000000..e3ac75857
--- /dev/null
+++ b/bridges/otellogrus/hook.go
@@ -0,0 +1,241 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+// Package otellogrus provides a [Hook], a [logrus.Hook] implementation that
+// can be used to bridge between the [github.com/sirupsen/logrus] API and
+// [OpenTelemetry].
+//
+// # Record Conversion
+//
+// The [logrus.Entry] records are converted to OpenTelemetry [log.Record] in
+// the following way:
+//
+//   - Time is set as the Timestamp.
+//   - Message is set as the Body using a [log.StringValue].
+//   - Level is transformed and set as the Severity. The SeverityText is not
+//     set.
+//   - Fields are transformed and set as the attributes.
+//
+// The Level is transformed by using the static offset to the OpenTelemetry
+// Severity types. For example:
+//
+//   - [slog.LevelDebug] is transformed to [log.SeverityDebug]
+//   - [slog.LevelInfo] is transformed to [log.SeverityInfo]
+//   - [slog.LevelWarn] is transformed to [log.SeverityWarn]
+//   - [slog.LevelError] is transformed to [log.SeverityError]
+//
+// Attribute values are transformed based on their type into log attributes, or
+// into a string value if there is no matching type.
+//
+// [OpenTelemetry]: https://opentelemetry.io/docs/concepts/signals/logs/
+package otellogrus // import "go.opentelemetry.io/contrib/bridges/otellogrus"
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/sirupsen/logrus"
+
+	"go.opentelemetry.io/otel/log"
+	"go.opentelemetry.io/otel/log/global"
+	"go.opentelemetry.io/otel/sdk/instrumentation"
+)
+
+const bridgeName = "go.opentelemetry.io/contrib/bridges/otellogrus"
+
+type config struct {
+	provider log.LoggerProvider
+	scope    instrumentation.Scope
+
+	levels []logrus.Level
+}
+
+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()
+	}
+
+	if c.levels == nil {
+		c.levels = logrus.AllLevels
+	}
+
+	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 [Hook].
+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 logs that will be emitted by the configured [Hook].
+//
+// By default if this Option is not provided, the Hook 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 [Hook].
+//
+// By default if this Option is not provided, the Hook will use the global
+// LoggerProvider.
+func WithLoggerProvider(provider log.LoggerProvider) Option {
+	return optFunc(func(c config) config {
+		c.provider = provider
+		return c
+	})
+}
+
+// WithLevels returns an [Option] that configures the log levels that will fire
+// the configured [Hook].
+//
+// By default if this Option is not provided, the Hook will fire for all levels.
+// LoggerProvider.
+func WithLevels(l []logrus.Level) Option {
+	return optFunc(func(c config) config {
+		c.levels = l
+		return c
+	})
+}
+
+// NewHook returns a new [Hook] to be used as a [logrus.Hook].
+//
+// If [WithLoggerProvider] is not provided, the returned Hook will use the
+// global LoggerProvider.
+func NewHook(options ...Option) *Hook {
+	cfg := newConfig(options)
+	return &Hook{
+		logger: cfg.logger(),
+		levels: cfg.levels,
+	}
+}
+
+// Hook is a [logrus.Hook] that sends all logging records it receives to
+// OpenTelemetry. See package documentation for how conversions are made.
+type Hook struct {
+	logger log.Logger
+	levels []logrus.Level
+}
+
+// Levels returns the list of log levels we want to be sent to OpenTelemetry.
+func (h *Hook) Levels() []logrus.Level {
+	return h.levels
+}
+
+// Fire handles the passed record, and sends it to OpenTelemetry.
+func (h *Hook) Fire(entry *logrus.Entry) error {
+	ctx := entry.Context
+	h.logger.Emit(ctx, h.convertEntry(entry))
+	return nil
+}
+
+func (h *Hook) convertEntry(e *logrus.Entry) log.Record {
+	var record log.Record
+	record.SetTimestamp(e.Time)
+	record.SetBody(log.StringValue(e.Message))
+
+	const sevOffset = logrus.Level(log.SeverityDebug) - logrus.DebugLevel
+	record.SetSeverity(log.Severity(e.Level + sevOffset))
+	record.AddAttributes(convertFields(e.Data)...)
+
+	return record
+}
+
+func convertFields(fields logrus.Fields) []log.KeyValue {
+	kvs := make([]log.KeyValue, 0, len(fields))
+	for k, v := range fields {
+		kvs = append(kvs, log.KeyValue{
+			Key:   k,
+			Value: convertValue(v),
+		})
+	}
+	return kvs
+}
+
+func convertValue(v interface{}) log.Value {
+	switch v := v.(type) {
+	case bool:
+		return log.BoolValue(v)
+	case []byte:
+		return log.BytesValue(v)
+	case float64:
+		return log.Float64Value(v)
+	case int:
+		return log.IntValue(v)
+	case int64:
+		return log.Int64Value(v)
+	case string:
+		return log.StringValue(v)
+	}
+
+	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:
+		return convertValue(val.Elem().Interface())
+	}
+
+	return log.StringValue(fmt.Sprintf("unhandled attribute type: (%s) %+v", t, v))
+}
diff --git a/bridges/otellogrus/version.go b/bridges/otellogrus/version.go
new file mode 100644
index 000000000..3e82fb727
--- /dev/null
+++ b/bridges/otellogrus/version.go
@@ -0,0 +1,7 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package otellogrus // import "go.opentelemetry.io/contrib/bridges/otellogrus"
+
+// version is the current release version of otellogrus in use.
+const version = "0.0.1-alpha"
diff --git a/versions.yaml b/versions.yaml
index b49942eac..155629263 100644
--- a/versions.yaml
+++ b/versions.yaml
@@ -77,6 +77,7 @@ module-sets:
     version: v0.1.0
     modules:
       - go.opentelemetry.io/contrib/bridges/otelslog
+      - go.opentelemetry.io/contrib/bridges/otellogrus
   experimental-processors:
     version: v0.0.1
     modules:


Test patch:
diff --git a/bridges/otellogrus/example_test.go b/bridges/otellogrus/example_test.go
new file mode 100644
index 000000000..4b5806a6d
--- /dev/null
+++ b/bridges/otellogrus/example_test.go
@@ -0,0 +1,22 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package otellogrus_test
+
+import (
+	"github.com/sirupsen/logrus"
+
+	"go.opentelemetry.io/contrib/bridges/otellogrus"
+	"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 *otellogrus.Hook and use it in your application.
+	hook := otellogrus.NewHook(otellogrus.WithLoggerProvider(provider))
+
+	// Set the newly created hook as a global logrus hook
+	logrus.AddHook(hook)
+}
diff --git a/bridges/otellogrus/hook_test.go b/bridges/otellogrus/hook_test.go
new file mode 100644
index 000000000..cc2142469
--- /dev/null
+++ b/bridges/otellogrus/hook_test.go
@@ -0,0 +1,457 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+package otellogrus
+
+import (
+	"slices"
+	"testing"
+	"time"
+
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+
+	"go.opentelemetry.io/otel/log"
+	"go.opentelemetry.io/otel/log/embedded"
+	"go.opentelemetry.io/otel/log/global"
+	"go.opentelemetry.io/otel/log/logtest"
+	"go.opentelemetry.io/otel/sdk/instrumentation"
+)
+
+type mockLoggerProvider struct {
+	embedded.LoggerProvider
+}
+
+func (mockLoggerProvider) Logger(name string, options ...log.LoggerOption) log.Logger {
+	return nil
+}
+
+func TestNewConfig(t *testing.T) {
+	customLoggerProvider := mockLoggerProvider{}
+
+	for _, tt := range []struct {
+		name    string
+		options []Option
+
+		wantConfig config
+	}{
+		{
+			name: "with no options",
+
+			wantConfig: config{
+				scope: instrumentation.Scope{
+					Name:    bridgeName,
+					Version: version,
+				},
+				provider: global.GetLoggerProvider(),
+				levels:   logrus.AllLevels,
+			},
+		},
+		{
+			name: "with a custom instrumentation scope",
+			options: []Option{
+				WithInstrumentationScope(instrumentation.Scope{
+					Name:    "test",
+					Version: "42.0",
+				}),
+			},
+
+			wantConfig: config{
+				scope: instrumentation.Scope{
+					Name:    "test",
+					Version: "42.0",
+				},
+				provider: global.GetLoggerProvider(),
+				levels:   logrus.AllLevels,
+			},
+		},
+		{
+			name: "with a custom logger provider",
+			options: []Option{
+				WithLoggerProvider(customLoggerProvider),
+			},
+
+			wantConfig: config{
+				scope: instrumentation.Scope{
+					Name:    bridgeName,
+					Version: version,
+				},
+				provider: customLoggerProvider,
+				levels:   logrus.AllLevels,
+			},
+		},
+		{
+			name: "with custom log levels",
+			options: []Option{
+				WithLevels([]logrus.Level{logrus.FatalLevel}),
+			},
+
+			wantConfig: config{
+				scope: instrumentation.Scope{
+					Name:    bridgeName,
+					Version: version,
+				},
+				provider: global.GetLoggerProvider(),
+				levels:   []logrus.Level{logrus.FatalLevel},
+			},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.wantConfig, newConfig(tt.options))
+		})
+	}
+}
+
+func TestNewHook(t *testing.T) {
+	provider := global.GetLoggerProvider()
+
+	for _, tt := range []struct {
+		name    string
+		options []Option
+
+		wantLogger log.Logger
+	}{
+		{
+			name: "with the default options",
+
+			wantLogger: provider.Logger(bridgeName, log.WithInstrumentationVersion(version)),
+		},
+		{
+			name: "with a schema URL",
+			options: []Option{
+				WithInstrumentationScope(instrumentation.Scope{
+					Name:      "test",
+					Version:   "42.1",
+					SchemaURL: "https://example.com",
+				}),
+			},
+
+			wantLogger: provider.Logger("test",
+				log.WithInstrumentationVersion("42.1"),
+				log.WithSchemaURL("https://example.com"),
+			),
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			hook := NewHook(tt.options...)
+			assert.NotNil(t, hook)
+
+			assert.Equal(t, tt.wantLogger, hook.logger)
+		})
+	}
+}
+
+func TestHookLevels(t *testing.T) {
+	for _, tt := range []struct {
+		name    string
+		options []Option
+
+		wantLevels []logrus.Level
+	}{
+		{
+			name:       "with the default levels",
+			wantLevels: logrus.AllLevels,
+		},
+		{
+			name: "with provided levels",
+			options: []Option{
+				WithLevels([]logrus.Level{logrus.PanicLevel}),
+			},
+			wantLevels: []logrus.Level{logrus.PanicLevel},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			levels := NewHook(tt.options...).Levels()
+			assert.Equal(t, tt.wantLevels, levels)
+		})
+	}
+}
+
+func TestHookFire(t *testing.T) {
+	now := time.Now()
+
+	for _, tt := range []struct {
+		name  string
+		entry *logrus.Entry
+
+		wantRecords map[string][]log.Record
+		wantErr     error
+	}{
+		{
+			name:  "emits an empty log entry",
+			entry: &logrus.Entry{},
+
+			wantRecords: map[string][]log.Record{
+				bridgeName: {
+					buildRecord(log.StringValue(""), time.Time{}, 0, nil),
+				},
+			},
+		},
+		{
+			name: "emits a log entry with a timestamp",
+			entry: &logrus.Entry{
+				Time: now,
+			},
+			wantRecords: map[string][]log.Record{
+				bridgeName: {
+					buildRecord(log.StringValue(""), now, 0, nil),
+				},
+			},
+		},
+		{
+			name: "emits a log entry with severity level",
+			entry: &logrus.Entry{
+				Level: logrus.FatalLevel,
+			},
+			wantRecords: map[string][]log.Record{
+				bridgeName: {
+					buildRecord(log.StringValue(""), time.Time{}, log.SeverityTrace1, nil),
+				},
+			},
+		},
+		{
+			name: "emits a log entry with data",
+			entry: &logrus.Entry{
+				Data: logrus.Fields{
+					"hello": "world",
+				},
+			},
+			wantRecords: map[string][]log.Record{
+				bridgeName: {
+					buildRecord(log.StringValue(""), time.Time{}, 0, []log.KeyValue{
+						log.String("hello", "world"),
+					}),
+				},
+			},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			rec := logtest.NewRecorder()
+
+			err := NewHook(WithLoggerProvider(rec)).Fire(tt.entry)
+			assert.Equal(t, tt.wantErr, err)
+
+			for k, v := range tt.wantRecords {
+				found := false
+
+				for _, s := range rec.Result() {
+					if k == s.Name {
+						assertRecords(t, v, s.Records)
+						found = true
+					}
+				}
+
+				assert.Truef(t, found, "want to find records with a scope named %q", k)
+			}
+		})
+	}
+}
+
+func TestConvertFields(t *testing.T) {
+	for _, tt := range []struct {
+		name string
+
+		fields       logrus.Fields
+		wantKeyValue []log.KeyValue
+	}{
+		{
+			name:   "with a boolean",
+			fields: logrus.Fields{"hello": true},
+			wantKeyValue: []log.KeyValue{
+				log.Bool("hello", true),
+			},
+		},
+		{
+			name:   "with a bytes array",
+			fields: logrus.Fields{"hello": []byte("world")},
+			wantKeyValue: []log.KeyValue{
+				log.Bytes("hello", []byte("world")),
+			},
+		},
+		{
+			name:   "with a float64",
+			fields: logrus.Fields{"hello": 6.5},
+			wantKeyValue: []log.KeyValue{
+				log.Float64("hello", 6.5),
+			},
+		},
+		{
+			name:   "with an int",
+			fields: logrus.Fields{"hello": 42},
+			wantKeyValue: []log.KeyValue{
+				log.Int("hello", 42),
+			},
+		},
+		{
+			name:   "with an int64",
+			fields: logrus.Fields{"hello": int64(42)},
+			wantKeyValue: []log.KeyValue{
+				log.Int64("hello", 42),
+			},
+		},
+		{
+			name:   "with a string",
+			fields: logrus.Fields{"hello": "world"},
+			wantKeyValue: []log.KeyValue{
+				log.String("hello", "world"),
+			},
+		},
+		{
+			name:   "with nil",
+			fields: logrus.Fields{"hello": nil},
+			wantKeyValue: []log.KeyValue{
+				{Key: "hello", Value: log.Value{}},
+			},
+		},
+		{
+			name:   "with a struct",
+			fields: logrus.Fields{"hello": struct{ Name string }{Name: "foobar"}},
+			wantKeyValue: []log.KeyValue{
+				log.String("hello", "{Name:foobar}"),
+			},
+		},
+		{
+			name:   "with a slice",
+			fields: logrus.Fields{"hello": []string{"foo", "bar"}},
+			wantKeyValue: []log.KeyValue{
+				log.Slice("hello",
+					log.StringValue("foo"),
+					log.StringValue("bar"),
+				),
+			},
+		},
+		{
+			name:   "with an interface slice",
+			fields: logrus.Fields{"hello": []interface{}{"foo", 42}},
+			wantKeyValue: []log.KeyValue{
+				log.Slice("hello",
+					log.StringValue("foo"),
+					log.Int64Value(42),
+				),
+			},
+		},
+		{
+			name:   "with a map",
+			fields: logrus.Fields{"hello": map[string]int{"answer": 42}},
+			wantKeyValue: []log.KeyValue{
+				log.Map("hello", log.Int("answer", 42)),
+			},
+		},
+		{
+			name:   "with an interface map",
+			fields: logrus.Fields{"hello": map[interface{}]interface{}{1: "question", "answer": 42}},
+			wantKeyValue: []log.KeyValue{
+				log.Map("hello", log.Int("answer", 42), log.String("1", "question")),
+			},
+		},
+		{
+			name:   "with a nested map",
+			fields: logrus.Fields{"hello": map[string]map[string]int{"sublevel": {"answer": 42}}},
+			wantKeyValue: []log.KeyValue{
+				log.Map("hello", log.Map("sublevel", log.Int("answer", 42))),
+			},
+		},
+		{
+			name:   "with a struct map",
+			fields: logrus.Fields{"hello": map[struct{ name string }]string{{name: "hello"}: "world"}},
+			wantKeyValue: []log.KeyValue{
+				log.Map("hello", log.String("{name:hello}", "world")),
+			},
+		},
+		{
+			name:   "with a pointer to struct",
+			fields: logrus.Fields{"hello": &struct{ Name string }{Name: "foobar"}},
+			wantKeyValue: []log.KeyValue{
+				log.String("hello", "{Name:foobar}"),
+			},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			assertKeyValues(t, tt.wantKeyValue, convertFields(tt.fields))
+		})
+	}
+}
+
+func BenchmarkHook(b *testing.B) {
+	record := &logrus.Entry{
+		Data: map[string]interface{}{
+			"string": "hello",
+			"int":    42,
+			"float":  1.5,
+			"bool":   false,
+		},
+		Message: "body",
+		Time:    time.Now(),
+		Level:   logrus.InfoLevel,
+	}
+
+	b.Run("Fire", func(b *testing.B) {
+		hooks := make([]*Hook, b.N)
+		for i := range hooks {
+			hooks[i] = NewHook()
+		}
+
+		b.ReportAllocs()
+		b.ResetTimer()
+		for n := 0; n < b.N; n++ {
+			_ = hooks[n].Fire(record)
+		}
+	})
+}
+
+func buildRecord(body log.Value, timestamp time.Time, severity log.Severity, attrs []log.KeyValue) log.Record {
+	var record log.Record
+	record.SetBody(body)
+	record.SetTimestamp(timestamp)
+	record.SetSeverity(severity)
+	record.AddAttributes(attrs...)
+
+	return record
+}
+
+func assertKeyValues(t *testing.T, want, got []log.KeyValue) {
+	t.Helper()
+	if !slices.EqualFunc(want, got, log.KeyValue.Equal) {
+		t.Errorf("KeyValues are not equal:\nwant: %v\ngot:  %v", want, got)
+	}
+}
+
+func assertBody(t *testing.T, want log.Value, got log.Value) {
+	t.Helper()
+	if !got.Equal(want) {
+		t.Errorf("Body value is not equal:\nwant: %v\ngot:  %v", want, got)
+	}
+}
+
+func assertAttributes(t *testing.T, want, got log.Record) {
+	t.Helper()
+
+	var wantAttr []log.KeyValue
+	want.WalkAttributes(func(kv log.KeyValue) bool {
+		wantAttr = append(wantAttr, kv)
+		return true
+	})
+	var gotAttr []log.KeyValue
+	got.WalkAttributes(func(kv log.KeyValue) bool {
+		gotAttr = append(gotAttr, kv)
+		return true
+	})
+
+	if !slices.EqualFunc(wantAttr, gotAttr, log.KeyValue.Equal) {
+		t.Errorf("Attributes are not equal:\nwant: %v\ngot:  %v", want, got)
+	}
+}
+
+func assertRecords(t *testing.T, want, got []log.Record) {
+	t.Helper()
+
+	assert.Equal(t, len(want), len(got))
+
+	for i, j := range want {
+		assert.Equal(t, j.Timestamp(), got[i].Timestamp())
+		assert.Equal(t, j.ObservedTimestamp(), got[i].ObservedTimestamp())
+		assert.Equal(t, j.Severity(), got[i].Severity())
+		assert.Equal(t, j.SeverityText(), got[i].SeverityText())
+		assertBody(t, j.Body(), got[i].Body())
+		assertAttributes(t, j, got[i])
+	}
+}