diff --git a/CHANGELOG.md b/CHANGELOG.md index cb28b36b995..56ad295a572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- Add `RecordFactory` in `go.opentelemetry.io/otel/sdk/log/logtest` to facilitate testing the exporter and processor implementations. (#5258) + ## [1.26.0/0.48.0/0.2.0-alpha] 2024-04-24 ### Added diff --git a/sdk/log/logtest/README.md b/sdk/log/logtest/README.md new file mode 100644 index 00000000000..a2bf2690a5d --- /dev/null +++ b/sdk/log/logtest/README.md @@ -0,0 +1,3 @@ +# Log Test SDK + +[![PkgGoDev](https://pkg.go.dev/badge/go.opentelemetry.io/otel/sdk/log/logtest)](https://pkg.go.dev/go.opentelemetry.io/otel/sdk/log/logtest) diff --git a/sdk/log/logtest/example_test.go b/sdk/log/logtest/example_test.go new file mode 100644 index 00000000000..68376a726c1 --- /dev/null +++ b/sdk/log/logtest/example_test.go @@ -0,0 +1,64 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package logtest is a testing helper package. +package logtest_test + +import ( + "context" + "fmt" + "io" + "os" + + logapi "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/log/logtest" +) + +func ExampleRecordFactory() { + exp := exporter{os.Stdout} + rf := logtest.RecordFactory{ + InstrumentationScope: instrumentation.Scope{Name: "myapp"}, + } + + rf.Body = logapi.StringValue("foo") + r1 := rf.NewRecord() + + rf.Body = logapi.StringValue("bar") + r2 := rf.NewRecord() + + _ = exp.Export(context.Background(), []log.Record{r1, r2}) + + // Output: + // scope=myapp msg=foo + // scope=myapp msg=bar +} + +// Compile time check exporter implements log.Exporter. +var _ log.Exporter = exporter{} + +type exporter struct{ io.Writer } + +func (e exporter) Export(ctx context.Context, records []log.Record) error { + for i, r := range records { + if i != 0 { + if _, err := e.Write([]byte("\n")); err != nil { + return err + } + } + if _, err := fmt.Fprintf(e, "scope=%s msg=%s", r.InstrumentationScope().Name, r.Body().String()); err != nil { + return err + } + } + return nil +} + +func (e exporter) Shutdown(context.Context) error { + return nil +} + +// appropriate error should be returned in these situations. +func (e exporter) ForceFlush(context.Context) error { + return nil +} diff --git a/sdk/log/logtest/factory.go b/sdk/log/logtest/factory.go new file mode 100644 index 00000000000..850869141d5 --- /dev/null +++ b/sdk/log/logtest/factory.go @@ -0,0 +1,102 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package logtest is a testing helper package. +package logtest // import "go.opentelemetry.io/otel/sdk/log/logtest" + +import ( + "context" + "slices" + "time" + + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/sdk/instrumentation" + sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/trace" +) + +// RecordFactory is used to facilitate unit testing implementations of +// [go.opentelemetry.io/otel/sdk/log.Exporter] +// and [go.opentelemetry.io/otel/sdk/log.Processor]. +// +// Do not use RecordFactory to create records in production code. +type RecordFactory struct { + Timestamp time.Time + ObservedTimestamp time.Time + Severity log.Severity + SeverityText string + Body log.Value + Attributes []log.KeyValue + TraceID trace.TraceID + SpanID trace.SpanID + TraceFlags trace.TraceFlags + + Resource *resource.Resource + InstrumentationScope instrumentation.Scope + + DroppedAttributes int +} + +// NewRecord returns a log record. +func (b RecordFactory) NewRecord() sdklog.Record { + var record sdklog.Record + p := processor(func(r sdklog.Record) { + r.SetTimestamp(b.Timestamp) + r.SetObservedTimestamp(b.ObservedTimestamp) + r.SetSeverity(b.Severity) + r.SetSeverityText(b.SeverityText) + r.SetBody(b.Body) + r.SetAttributes(slices.Clone(b.Attributes)...) + + // Generate dropped attributes. + for i := 0; i < b.DroppedAttributes; i++ { + r.AddAttributes(log.KeyValue{}) + } + + r.SetTraceID(b.TraceID) + r.SetSpanID(b.SpanID) + r.SetTraceFlags(b.TraceFlags) + + record = r + }) + + attributeCountLimit := -1 + if b.DroppedAttributes > 0 { + // Make sure that we can generate dropped attributes. + attributeCountLimit = len(b.Attributes) + } + + provider := sdklog.NewLoggerProvider( + sdklog.WithResource(b.Resource), + sdklog.WithAttributeCountLimit(attributeCountLimit), + sdklog.WithAttributeValueLengthLimit(-1), + sdklog.WithProcessor(p), + ) + + l := provider.Logger(b.InstrumentationScope.Name, + log.WithInstrumentationVersion(b.InstrumentationScope.Version), + log.WithSchemaURL(b.InstrumentationScope.SchemaURL), + ) + l.Emit(context.Background(), log.Record{}) // This executes the processor function. + return record +} + +type processor func(r sdklog.Record) + +func (p processor) OnEmit(ctx context.Context, r sdklog.Record) error { + p(r) + return nil +} + +func (processor) Enabled(context.Context, sdklog.Record) bool { + return true +} + +func (processor) Shutdown(ctx context.Context) error { + return nil +} + +func (processor) ForceFlush(context.Context) error { + return nil +} diff --git a/sdk/log/logtest/factory_test.go b/sdk/log/logtest/factory_test.go new file mode 100644 index 00000000000..dc9662cd633 --- /dev/null +++ b/sdk/log/logtest/factory_test.go @@ -0,0 +1,124 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package logtest + +import ( + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/sdk/instrumentation" + sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/trace" +) + +func TestRecordFactory(t *testing.T) { + now := time.Now() + observed := now.Add(time.Second) + severity := log.SeverityDebug + severityText := "DBG" + body := log.StringValue("Message") + attrs := []log.KeyValue{ + log.Int("int", 1), + log.String("str", "foo"), + log.Float64("flt", 3.14), + } + traceID := trace.TraceID([16]byte{1}) + spanID := trace.SpanID([8]byte{2}) + traceFlags := trace.FlagsSampled + dropped := 3 + scope := instrumentation.Scope{ + Name: t.Name(), + } + r := resource.NewSchemaless(attribute.Bool("works", true)) + + got := RecordFactory{ + Timestamp: now, + ObservedTimestamp: observed, + Severity: severity, + SeverityText: severityText, + Body: body, + Attributes: attrs, + TraceID: traceID, + SpanID: spanID, + TraceFlags: traceFlags, + DroppedAttributes: dropped, + InstrumentationScope: scope, + Resource: r, + }.NewRecord() + + assert.Equal(t, now, got.Timestamp()) + assert.Equal(t, observed, got.ObservedTimestamp()) + assert.Equal(t, severity, got.Severity()) + assert.Equal(t, severityText, got.SeverityText()) + assertBody(t, body, got) + assertAttributes(t, attrs, got) + assert.Equal(t, dropped, got.DroppedAttributes()) + assert.Equal(t, traceID, got.TraceID()) + assert.Equal(t, spanID, got.SpanID()) + assert.Equal(t, traceFlags, got.TraceFlags()) + assert.Equal(t, scope, got.InstrumentationScope()) + assert.Equal(t, *r, got.Resource()) +} + +func TestRecordFactoryMultiple(t *testing.T) { + now := time.Now() + attrs := []log.KeyValue{ + log.Int("int", 1), + log.String("str", "foo"), + log.Float64("flt", 3.14), + } + scope := instrumentation.Scope{ + Name: t.Name(), + } + + f := RecordFactory{ + Timestamp: now, + Attributes: attrs, + DroppedAttributes: 1, + InstrumentationScope: scope, + } + + record1 := f.NewRecord() + + f.Attributes = append(f.Attributes, log.Bool("added", true)) + f.DroppedAttributes = 2 + record2 := f.NewRecord() + + assert.Equal(t, now, record2.Timestamp()) + assertAttributes(t, append(attrs, log.Bool("added", true)), record2) + assert.Equal(t, 2, record2.DroppedAttributes()) + assert.Equal(t, scope, record2.InstrumentationScope()) + + // Previously returned record is unharmed by the builder changes. + assert.Equal(t, now, record1.Timestamp()) + assertAttributes(t, attrs, record1) + assert.Equal(t, 1, record1.DroppedAttributes()) + assert.Equal(t, scope, record1.InstrumentationScope()) +} + +func assertBody(t *testing.T, want log.Value, r sdklog.Record) { + t.Helper() + got := r.Body() + if !got.Equal(want) { + t.Errorf("Body value is not equal:\nwant: %v\ngot: %v", want, got) + } +} + +func assertAttributes(t *testing.T, want []log.KeyValue, r sdklog.Record) { + t.Helper() + var got []log.KeyValue + r.WalkAttributes(func(kv log.KeyValue) bool { + got = append(got, kv) + return true + }) + if !slices.EqualFunc(want, got, log.KeyValue.Equal) { + t.Errorf("Attributes are not equal:\nwant: %v\ngot: %v", want, got) + } +}