Skip to content

Commit

Permalink
logtest: Add Recorder (#5134)
Browse files Browse the repository at this point in the history
* introduce in-memory log exporter

* add changelog entry

* move logtest into a recorder within the api

* rename GetRecords to Result

* rename InMemoryRecorder to Recorder

* name the struct r

* ensure Logger creates a struct copy

* replace severity with enabledFn

* Update CHANGELOG.md

Co-authored-by: Robert Pająk <pellared@hotmail.com>

* kUpdate log/logtest/config.go

Co-authored-by: Robert Pająk <pellared@hotmail.com>

* store all scope records, so we can retrieve everything with `Result()`

* store child loggers instead of all scope records

* no need to explicitly create a new slice

* add concurrent safe test

* handle default enabled function if the struct was manually created

* rename WithEnabledFn to WithEnabledFunc

* test result/reset with child loggers

* add enabled to concurrent safe

* fix lint missing period

* rename defaultEnabledFn to defaultEnabledFunc

* merge recorder.go and config.go

* Update log/logtest/recorder_test.go

Co-authored-by: Robert Pająk <pellared@hotmail.com>

* create empty recorder in concurrent safe test

* Update log/logtest/recorder_test.go

Co-authored-by: Robert Pająk <pellared@hotmail.com>

* fix lint

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* Update log/logtest/recorder.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>

* make enabledFunc callable from outside the package

* replace expected with want

---------

Co-authored-by: Robert Pająk <pellared@hotmail.com>
Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>
Co-authored-by: Sam Xie <sam@samxie.me>
  • Loading branch information
4 people committed Apr 9, 2024
1 parent b9752eb commit 8d1d62b
Show file tree
Hide file tree
Showing 4 changed files with 324 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Add `String` method to `Value` and `KeyValue` in `go.opentelemetry.io/otel/log`. (#5117)
- Add Exemplar support to `go.opentelemetry.io/otel/exporters/prometheus`. (#5111)
- Add metric semantic conventions to `go.opentelemetry.io/otel/semconv/v1.24.0`. Future `semconv` packages will include metric semantic conventions as well. (#4528)
- Add `Recorder` in `go.opentelemetry.io/otel/log/logtest` to facilitate testing the log bridge implementations. (#5134)

### Changed

Expand Down
3 changes: 3 additions & 0 deletions log/logtest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Log Test

[![PkgGoDev](https://pkg.go.dev/badge/go.opentelemetry.io/otel/log/logtest)](https://pkg.go.dev/go.opentelemetry.io/otel/log/logtest)
164 changes: 164 additions & 0 deletions log/logtest/recorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

// Package logtest is a testing helper package. Users can retrieve an in-memory
// logger to verify the behavior of their integrations.
package logtest // import "go.opentelemetry.io/otel/log/logtest"

import (
"context"
"sync"

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

// embeddedLogger is a type alias so the embedded.Logger type doesn't conflict
// with the Logger method of the Recorder when it is embedded.
type embeddedLogger = embedded.Logger // nolint:unused // Used below.

type enabledFn func(context.Context, log.Record) bool

var defaultEnabledFunc = func(context.Context, log.Record) bool {
return true
}

type config struct {
enabledFn enabledFn
}

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

return c
}

// Option configures a [Recorder].
type Option interface {
apply(config) config
}

type optFunc func(config) config

func (f optFunc) apply(c config) config { return f(c) }

// WithEnabledFunc allows configuring whether the [Recorder] is enabled for specific log entries or not.
//
// By default, the Recorder is enabled for every log entry.
func WithEnabledFunc(fn func(context.Context, log.Record) bool) Option {
return optFunc(func(c config) config {
c.enabledFn = fn
return c
})
}

// NewRecorder returns a new [Recorder].
func NewRecorder(options ...Option) *Recorder {
cfg := newConfig(options)

sr := &ScopeRecords{}

return &Recorder{
currentScopeRecord: sr,
enabledFn: cfg.enabledFn,
}
}

// ScopeRecords represents the records for a single instrumentation scope.
type ScopeRecords struct {
// Name is the name of the instrumentation scope.
Name string
// Version is the version of the instrumentation scope.
Version string
// SchemaURL of the telemetry emitted by the scope.
SchemaURL string

// Records are the log records this instrumentation scope recorded.
Records []log.Record
}

// Recorder is a recorder that stores all received log records
// in-memory.
type Recorder struct {
embedded.LoggerProvider
embeddedLogger // nolint:unused // Used to embed embedded.Logger.

mu sync.Mutex

loggers []*Recorder
currentScopeRecord *ScopeRecords

// enabledFn decides whether the recorder should enable logging of a record or not
enabledFn enabledFn
}

// Logger returns a copy of Recorder as a [log.Logger] with the provided scope
// information.
func (r *Recorder) Logger(name string, opts ...log.LoggerOption) log.Logger {
cfg := log.NewLoggerConfig(opts...)

nr := &Recorder{
currentScopeRecord: &ScopeRecords{
Name: name,
Version: cfg.InstrumentationVersion(),
SchemaURL: cfg.SchemaURL(),
},
enabledFn: r.enabledFn,
}
r.addChildLogger(nr)

return nr
}

func (r *Recorder) addChildLogger(nr *Recorder) {
r.mu.Lock()
defer r.mu.Unlock()

r.loggers = append(r.loggers, nr)
}

// Enabled indicates whether a specific record should be stored.
func (r *Recorder) Enabled(ctx context.Context, record log.Record) bool {
if r.enabledFn == nil {
return defaultEnabledFunc(ctx, record)
}

return r.enabledFn(ctx, record)
}

// Emit stores the log record.
func (r *Recorder) Emit(_ context.Context, record log.Record) {
r.mu.Lock()
defer r.mu.Unlock()

r.currentScopeRecord.Records = append(r.currentScopeRecord.Records, record)
}

// Result returns the current in-memory recorder log records.
func (r *Recorder) Result() []*ScopeRecords {
r.mu.Lock()
defer r.mu.Unlock()

ret := []*ScopeRecords{}
ret = append(ret, r.currentScopeRecord)
for _, l := range r.loggers {
ret = append(ret, l.Result()...)
}
return ret
}

// Reset clears the in-memory log records.
func (r *Recorder) Reset() {
r.mu.Lock()
defer r.mu.Unlock()

if r.currentScopeRecord != nil {
r.currentScopeRecord.Records = nil
}
for _, l := range r.loggers {
l.Reset()
}
}
156 changes: 156 additions & 0 deletions log/logtest/recorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package logtest

import (
"context"
"sync"
"testing"

"github.com/stretchr/testify/assert"

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

func TestRecorderLogger(t *testing.T) {
for _, tt := range []struct {
name string
options []Option

loggerName string
loggerOptions []log.LoggerOption

wantLogger log.Logger
}{
{
name: "provides a default logger",

wantLogger: &Recorder{
currentScopeRecord: &ScopeRecords{},
},
},
{
name: "provides a logger with a configured scope",

loggerName: "test",
loggerOptions: []log.LoggerOption{
log.WithInstrumentationVersion("logtest v42"),
log.WithSchemaURL("https://example.com"),
},

wantLogger: &Recorder{
currentScopeRecord: &ScopeRecords{
Name: "test",
Version: "logtest v42",
SchemaURL: "https://example.com",
},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
l := NewRecorder(tt.options...).Logger(tt.loggerName, tt.loggerOptions...)
// unset enabledFn to allow comparison
l.(*Recorder).enabledFn = nil

assert.Equal(t, tt.wantLogger, l)
})
}
}

func TestRecorderLoggerCreatesNewStruct(t *testing.T) {
r := &Recorder{}
assert.NotEqual(t, r, r.Logger("test"))
}

func TestRecorderEnabled(t *testing.T) {
for _, tt := range []struct {
name string
options []Option
ctx context.Context
buildRecord func() log.Record

isEnabled bool
}{
{
name: "the default option enables every log entry",
ctx: context.Background(),
buildRecord: func() log.Record {
return log.Record{}
},

isEnabled: true,
},
{
name: "with everything disabled",
options: []Option{
WithEnabledFunc(func(context.Context, log.Record) bool {
return false
}),
},
ctx: context.Background(),
buildRecord: func() log.Record {
return log.Record{}
},

isEnabled: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
e := NewRecorder(tt.options...).Enabled(tt.ctx, tt.buildRecord())
assert.Equal(t, tt.isEnabled, e)
})
}
}

func TestRecorderEnabledFnUnset(t *testing.T) {
r := &Recorder{}
assert.True(t, r.Enabled(context.Background(), log.Record{}))
}

func TestRecorderEmitAndReset(t *testing.T) {
r := NewRecorder()
assert.Len(t, r.Result()[0].Records, 0)

r1 := log.Record{}
r1.SetSeverity(log.SeverityInfo)
r.Emit(context.Background(), r1)
assert.Equal(t, r.Result()[0].Records, []log.Record{r1})

l := r.Logger("test")
assert.Empty(t, r.Result()[1].Records)

r2 := log.Record{}
r2.SetSeverity(log.SeverityError)
l.Emit(context.Background(), r2)
assert.Equal(t, r.Result()[0].Records, []log.Record{r1})
assert.Equal(t, r.Result()[1].Records, []log.Record{r2})

r.Reset()
assert.Empty(t, r.Result()[0].Records)
assert.Empty(t, r.Result()[1].Records)
}

func TestRecorderConcurrentSafe(t *testing.T) {
const goRoutineN = 10

var wg sync.WaitGroup
wg.Add(goRoutineN)

r := &Recorder{}

for i := 0; i < goRoutineN; i++ {
go func() {
defer wg.Done()

nr := r.Logger("test")
nr.Enabled(context.Background(), log.Record{})
nr.Emit(context.Background(), log.Record{})

r.Result()
r.Reset()
}()
}

wg.Wait()
}

0 comments on commit 8d1d62b

Please sign in to comment.