From 8d1d62b90572534f372bff20f5536fda09a01cf6 Mon Sep 17 00:00:00 2001 From: Damien Mathieu Date: Tue, 9 Apr 2024 15:45:25 +0200 Subject: [PATCH] logtest: Add Recorder (#5134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * kUpdate log/logtest/config.go Co-authored-by: Robert Pająk * 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 * create empty recorder in concurrent safe test * Update log/logtest/recorder_test.go Co-authored-by: Robert Pająk * fix lint * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * Update log/logtest/recorder.go Co-authored-by: Tyler Yahn * make enabledFunc callable from outside the package * replace expected with want --------- Co-authored-by: Robert Pająk Co-authored-by: Tyler Yahn Co-authored-by: Sam Xie --- CHANGELOG.md | 1 + log/logtest/README.md | 3 + log/logtest/recorder.go | 164 +++++++++++++++++++++++++++++++++++ log/logtest/recorder_test.go | 156 +++++++++++++++++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 log/logtest/README.md create mode 100644 log/logtest/recorder.go create mode 100644 log/logtest/recorder_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ffbe0181a..2e90c80f25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/log/logtest/README.md b/log/logtest/README.md new file mode 100644 index 00000000000..1be2d98c32d --- /dev/null +++ b/log/logtest/README.md @@ -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) diff --git a/log/logtest/recorder.go b/log/logtest/recorder.go new file mode 100644 index 00000000000..746527411ce --- /dev/null +++ b/log/logtest/recorder.go @@ -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() + } +} diff --git a/log/logtest/recorder_test.go b/log/logtest/recorder_test.go new file mode 100644 index 00000000000..eb84ec22c32 --- /dev/null +++ b/log/logtest/recorder_test.go @@ -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() +}