Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Logging Bridge For ZeroLog #5761

Closed
wants to merge 14 commits into from
2 changes: 2 additions & 0 deletions CHANGELOG.md
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A changelog entry is required.

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

- The `go.opentelemetry.io/contrib/config` add support to configure periodic reader interval and timeout. (#5661)
- Add support to configure views when creating MeterProvider using the config package. (#5654)
- The `go.opentelemetry.io/contrib/bridges/otelzerolog` module.
This module provides an OpenTelemetry logging bridge for `github.com/rs/zerolog`. (#5405)

AkhigbeEromo marked this conversation as resolved.
Show resolved Hide resolved
### Fixed

Expand Down
23 changes: 23 additions & 0 deletions bridges/otelzerolog/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module go.opentelemetry.io/contrib/bridges/otelzerolog

go 1.22.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repo currently needs to support Go 1.21.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I resolved this in #5782


require (
github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.9.0
go.opentelemetry.io/otel/log v0.3.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/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
golang.org/x/sys v0.12.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
40 changes: 40 additions & 0 deletions bridges/otelzerolog/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
go.opentelemetry.io/otel/log v0.3.0 h1:kJRFkpUFYtny37NQzL386WbznUByZx186DpEMKhEGZs=
go.opentelemetry.io/otel/log v0.3.0/go.mod h1:ziCwqZr9soYDwGNbIL+6kAvQC+ANvjgG367HVcyR/ys=
go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
153 changes: 153 additions & 0 deletions bridges/otelzerolog/hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package otelzerolog
AkhigbeEromo marked this conversation as resolved.
Show resolved Hide resolved

import (
"github.com/rs/zerolog"
"fmt"
"go.opentelemetry.io/otel/log"
"go.opentelemetry.io/otel/log/global"
)

type config struct {
provider log.LoggerProvider
version string
schemaURL string

}

func newConfig(options []Option) config {
var c config
for _, opt := range options {
c = opt.apply(c)
}

if c.provider == nil {
c.provider = global.GetLoggerProvider()
}

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 Hook.
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 [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
})
}
func NewHook(name string, options ...Option) *SeverityHook {
cfg := newConfig(options)
return &SeverityHook{
logger: cfg.logger(name),
}
}
type SeverityHook struct{
logger log.Logger
levels zerolog.Level
}

// Levels returns the list of log levels we want to be sent to OpenTelemetry.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't return a list. Is it supposed to return the minimum logged severity?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It now returns a list

func (h *SeverityHook) Levels() zerolog.Level {
return h.levels
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never set, it will always be empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just set it in the Severity Struct

}

func (h SeverityHook) Run(e *zerolog.Event, level zerolog.Level, msg string) error {
if level != zerolog.NoLevel {
e.Str("severity", level.String())
}
h.logger.Emit(e.GetCtx(),h.convertEvent(e,level,msg))
return nil
}
func(h *SeverityHook) convertEvent(e *zerolog.Event,level zerolog.Level, msg string) log.Record{
var record log.Record
record.SetTimestamp(zerolog.TimestampFunc())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observed timestamp should also be set here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added it as time.now(), hope that is okay

record.SetBody(log.StringValue(msg))
const sevOffset = zerolog.Level(log.SeverityDebug) - zerolog.DebugLevel
record.SetSeverity(log.Severity(level + sevOffset))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fields := extractFields(e)

record.AddAttributes(convertFields(fields,msg)...);
return record
}
func extractFields(_ *zerolog.Event) map[string]interface{} {
// Here you would implement the logic to extract fields from the zerolog event
// This might involve using reflection or zerolog internals if necessary
fields := make(map[string]interface{})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ... worrying, it is allocating a map just to make a conversion. Can the zero log fields not be directly converted sent to SetAttribute and skip this allocation?

// Dummy implementation - replace with actual field extraction

return fields
}
func convertFields(fields map[string]interface{}, msg string) []log.KeyValue {
kvs := make([]log.KeyValue, 0, len(fields))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar here, this is making an allocation that is going to be potentially thrown away every time a message is emitted.

kvs = append(kvs, log.String("message", msg))
for k, v := range fields {
kvs = append(kvs, convertAttribute(k, v))
}
return kvs
}

func convertAttribute(key string, value interface{}) log.KeyValue {
switch v := value.(type) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing cases:

  • map
  • slice

case bool:
return log.Bool(key, v)
case []byte:
return log.String(key, string(v))
case float64:
return log.Float64(key, v)
case int:
return log.Int(key, v)
case int64:
return log.Int64(key, v)
case string:
return log.String(key, v)
default:
// Fallback to string representation for unhandled types
return log.String(key, fmt.Sprintf("%v", v))
}
}




116 changes: 116 additions & 0 deletions bridges/otelzerolog/hook_test.go
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a fair amount of test coverage missing. If we plan to implement a method or function in the PR it should be accompanied with comprehensive tests.

If you need to break this into smaller PRs, adding a method/function stub that you will implement and test in a follow-up PR is acceptable. You will need to track that as an issue though and keep the bridge unreleased until all parts are complete.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @MrAlias
I will do just that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to check in regarding this PR. While I haven't finished all the implementations yet, are the functions I've initialized sufficient, or do I need to add any additional functions? @MrAlias

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am more asking about adding test coverage for the functions that are included in this PR. There is a considerable amount of functionality being introduced here that is not tested.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood
Thank you

Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package otelzerolog

import (
"testing"

"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"

"go.opentelemetry.io/otel/log"
"go.opentelemetry.io/otel/log/embedded"
"go.opentelemetry.io/otel/log/global"
)

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{
provider: global.GetLoggerProvider(),
},
},
{
name: "with a custom version",
options: []Option{
WithVersion("1.0"),
},
wantConfig: config{
version: "1.0",
provider: global.GetLoggerProvider(),
},
},
{
name: "with a custom schema URL",
options: []Option{
WithSchemaURL("https://example.com"),
},
wantConfig: config{
schemaURL: "https://example.com",
provider: global.GetLoggerProvider(),
},
},
{
name: "with a custom logger provider",
options: []Option{
WithLoggerProvider(customLoggerProvider),
},
wantConfig: config{
provider: customLoggerProvider,
},
},
} {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.wantConfig, newConfig(tt.options))
})
}
}

func TestNewHook(t *testing.T) {
const name = "test-logger"
provider := global.GetLoggerProvider()

for _, tt := range []struct {
name string
options []Option

wantLogger log.Logger
}{
{
name: "with the default options",
wantLogger: provider.Logger(name),
},
{
name: "with a schema URL",
options: []Option{
WithVersion("1.0"),
WithSchemaURL("https://example.com"),
},
wantLogger: provider.Logger(name,
log.WithInstrumentationVersion("1.0"),
log.WithSchemaURL("https://example.com"),
),
},
} {
t.Run(tt.name, func(t *testing.T) {
hook := NewHook(name, tt.options...)
assert.NotNil(t, hook)
assert.Equal(t, tt.wantLogger, hook.logger)
})
}
}

func TestSeverityHook_Levels(t *testing.T) {
hook := &SeverityHook{
levels: zerolog.DebugLevel,
}

assert.Equal(t, zerolog.DebugLevel, hook.Levels())
}

// TODO: Tests for the Run Method