diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a17cf4dce5..ae8c8285a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Greatly reduce the cost of recording metrics in `go.opentelemetry.io/otel/sdk/metric` using hashing for map keys. (#7175) - Add experimental observability for the prometheus exporter in `go.opentelemetry.io/otel/exporters/prometheus`. Check the `go.opentelemetry.io/otel/exporters/prometheus/internal/x` package documentation for more information. (#7345) +- Add experimental observability log exporter metrics in `go.opentelemetry.io/otel/exporters/stdout/stdoutlog`. + Check the `go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/x` package documentation for more information. (#7351) ### Fixed diff --git a/exporters/stdout/stdoutlog/doc.go b/exporters/stdout/stdoutlog/doc.go index d400ab8c587..fc721f825dd 100644 --- a/exporters/stdout/stdoutlog/doc.go +++ b/exporters/stdout/stdoutlog/doc.go @@ -9,4 +9,7 @@ // format for OpenTelemetry that is supported with any stability or // compatibility guarantees. If these are needed features, please use the OTLP // exporter instead. +// +// See [go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/x] for information about +// the experimental features. package stdoutlog // import "go.opentelemetry.io/otel/exporters/stdout/stdoutlog" diff --git a/exporters/stdout/stdoutlog/exporter.go b/exporters/stdout/stdoutlog/exporter.go index 3d48d67081e..15e97a24d7c 100644 --- a/exporters/stdout/stdoutlog/exporter.go +++ b/exporters/stdout/stdoutlog/exporter.go @@ -8,16 +8,30 @@ import ( "encoding/json" "sync/atomic" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/counter" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/observ" "go.opentelemetry.io/otel/sdk/log" ) +// otelComponentType is a name identifying the type of the OpenTelemetry component. +const ( + otelComponentType = "go.opentelemetry.io/otel/exporters/stdout/stdoutlog" + + // Version is the current version of this instrumentation. + // + // This matches the version of the exporter. + Version = internal.Version +) + var _ log.Exporter = &Exporter{} // Exporter writes JSON-encoded log records to an [io.Writer] ([os.Stdout] by default). // Exporter must be created with [New]. type Exporter struct { - encoder atomic.Pointer[json.Encoder] - timestamps bool + encoder atomic.Pointer[json.Encoder] + timestamps bool + instrumentation *observ.Instrumentation } // New creates an [Exporter]. @@ -34,29 +48,50 @@ func New(options ...Option) (*Exporter, error) { } e.encoder.Store(enc) + exporterID := counter.NextExporterID() + inst, err := observ.NewInstrumentation(otelComponentType, exporterID) + if err != nil { + return nil, err + } + e.instrumentation = inst + return &e, nil } // Export exports log records to writer. func (e *Exporter) Export(ctx context.Context, records []log.Record) error { + if inst := e.instrumentation; inst != nil { + done := inst.ExportLogs(ctx, len(records)) + exported, err := e.exportRecords(ctx, records) + done(exported, err) + return err + } + + _, err := e.exportRecords(ctx, records) + return err +} + +func (e *Exporter) exportRecords(ctx context.Context, records []log.Record) (int64, error) { enc := e.encoder.Load() if enc == nil { - return nil + return 0, nil } + var exported int64 for _, record := range records { // Honor context cancellation. if err := ctx.Err(); err != nil { - return err + return exported, err } - // Encode record, one by one. recordJSON := e.newRecordJSON(record) if err := enc.Encode(recordJSON); err != nil { - return err + return exported, err } + exported++ } - return nil + + return exported, nil } // Shutdown shuts down the Exporter. diff --git a/exporters/stdout/stdoutlog/exporter_test.go b/exporters/stdout/stdoutlog/exporter_test.go index 8772e9790bf..42cc2c0bf81 100644 --- a/exporters/stdout/stdoutlog/exporter_test.go +++ b/exporters/stdout/stdoutlog/exporter_test.go @@ -14,12 +14,18 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/counter" "go.opentelemetry.io/otel/log" - "go.opentelemetry.io/otel/sdk/instrumentation" + sdkinstrumentation "go.opentelemetry.io/otel/sdk/instrumentation" sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/log/logtest" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest" "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/semconv/v1.37.0/otelconv" "go.opentelemetry.io/otel/trace" ) @@ -321,7 +327,7 @@ func getRecord(now time.Time) sdklog.Record { "https://example.com/custom-resource-schema", attribute.String("foo", "bar"), ), - InstrumentationScope: &instrumentation.Scope{ + InstrumentationScope: &sdkinstrumentation.Scope{ Name: "name", Version: "version", SchemaURL: "https://example.com/custom-schema", @@ -456,3 +462,493 @@ func TestValueMarshalJSON(t *testing.T) { }) } } + +func TestNewSelfObservability(t *testing.T) { + testCases := []struct { + name string + enable bool + test func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) + }{ + { + name: "inflight", + enable: true, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + + rf := logtest.RecordFactory{ + Timestamp: time.Now(), + Body: log.StringValue("test log 1"), + } + record1 := rf.NewRecord() + + rf.Body = log.StringValue("test log 2") + record2 := rf.NewRecord() + + records := []sdklog.Record{record1, record2} + + err = exporter.Export(context.Background(), records) + require.NoError(t, err) + + got := scopeMetrics() + assert.NotEmpty(t, got.Metrics) + + assert.Equal(t, "go.opentelemetry.io/otel/exporters/stdout/stdoutlog", got.Scope.Name) + assert.NotEmpty(t, got.Scope.Version) + + var inflightMetric metricdata.Metrics + inflightInstrument := otelconv.SDKExporterLogInflight{} + for _, m := range got.Metrics { + if m.Name == inflightInstrument.Name() { + inflightMetric = m + break + } + } + require.NotEmpty(t, inflightMetric, "inflight metric not found") + + expected := metricdata.Metrics{ + Name: inflightInstrument.Name(), + Description: inflightInstrument.Description(), + Unit: inflightInstrument.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: false, + DataPoints: []metricdata.DataPoint[int64]{ + { + Value: 0, + Attributes: attribute.NewSet( + attribute.KeyValue{ + Key: "otel.component.name", + Value: attribute.StringValue(otelComponentType + "/0"), + }, + attribute.KeyValue{ + Key: "otel.component.type", + Value: attribute.StringValue(otelComponentType), + }, + ), + }, + }, + }, + } + + metricdatatest.AssertEqual( + t, + expected, + inflightMetric, + metricdatatest.IgnoreTimestamp(), + metricdatatest.IgnoreValue(), + ) + }, + }, + { + name: "exported", + enable: true, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + + rf := logtest.RecordFactory{ + Timestamp: time.Now(), + Body: log.StringValue("test log 1"), + } + record1 := rf.NewRecord() + + rf.Body = log.StringValue("test log 2") + record2 := rf.NewRecord() + + rf.Body = log.StringValue("test log 3") + record3 := rf.NewRecord() + + records := []sdklog.Record{record1, record2, record3} + + err = exporter.Export(context.Background(), records) + require.NoError(t, err) + + got := scopeMetrics() + assert.NotEmpty(t, got.Metrics) + + assert.Equal(t, "go.opentelemetry.io/otel/exporters/stdout/stdoutlog", got.Scope.Name) + assert.NotEmpty(t, got.Scope.Version) + + var exportedMetric metricdata.Metrics + exportedInstrument := otelconv.SDKExporterLogExported{} + for _, m := range got.Metrics { + if m.Name == exportedInstrument.Name() { + exportedMetric = m + break + } + } + require.NotEmpty(t, exportedMetric, "exported metric not found") + + expected := metricdata.Metrics{ + Name: exportedInstrument.Name(), + Description: exportedInstrument.Description(), + Unit: exportedInstrument.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + DataPoints: []metricdata.DataPoint[int64]{ + { + Value: 3, + Attributes: attribute.NewSet( + attribute.KeyValue{ + Key: "otel.component.name", + Value: attribute.StringValue(otelComponentType + "/0"), + }, + attribute.KeyValue{ + Key: "otel.component.type", + Value: attribute.StringValue(otelComponentType), + }, + ), + }, + }, + }, + } + + metricdatatest.AssertEqual( + t, + expected, + exportedMetric, + metricdatatest.IgnoreTimestamp(), + metricdatatest.IgnoreValue(), + ) + }, + }, + { + name: "duration", + enable: true, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + + rf := logtest.RecordFactory{ + Timestamp: time.Now(), + Body: log.StringValue("test log"), + } + record := rf.NewRecord() + records := []sdklog.Record{record} + + err = exporter.Export(context.Background(), records) + require.NoError(t, err) + + got := scopeMetrics() + assert.NotEmpty(t, got.Metrics) + + assert.Equal(t, "go.opentelemetry.io/otel/exporters/stdout/stdoutlog", got.Scope.Name) + assert.NotEmpty(t, got.Scope.Version) + + var durationMetric metricdata.Metrics + durationInstrument := otelconv.SDKExporterOperationDuration{} + for _, m := range got.Metrics { + if m.Name == durationInstrument.Name() { + durationMetric = m + break + } + } + require.NotEmpty(t, durationMetric, "duration metric not found") + + expected := metricdata.Metrics{ + Name: durationInstrument.Name(), + Description: durationInstrument.Description(), + Unit: durationInstrument.Unit(), + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Count: 1, + Attributes: attribute.NewSet( + attribute.KeyValue{ + Key: "otel.component.name", + Value: attribute.StringValue(otelComponentType + "/0"), + }, + attribute.KeyValue{ + Key: "otel.component.type", + Value: attribute.StringValue(otelComponentType), + }, + ), + }, + }, + }, + } + + metricdatatest.AssertEqual( + t, + expected, + durationMetric, + metricdatatest.IgnoreTimestamp(), + metricdatatest.IgnoreValue(), + ) + }, + }, + { + name: "multiple_exports", + enable: true, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + + for i := 0; i < 3; i++ { + rf := logtest.RecordFactory{ + Timestamp: time.Now(), + Body: log.StringValue("test log"), + } + record := rf.NewRecord() + records := []sdklog.Record{record} + err = exporter.Export(context.Background(), records) + require.NoError(t, err) + } + + got := scopeMetrics() + assert.NotEmpty(t, got.Metrics) + + assert.Equal(t, "go.opentelemetry.io/otel/exporters/stdout/stdoutlog", got.Scope.Name) + assert.NotEmpty(t, got.Scope.Version) + + expectedAttrs := attribute.NewSet( + attribute.KeyValue{ + Key: "otel.component.name", + Value: attribute.StringValue(otelComponentType + "/0"), + }, + attribute.KeyValue{Key: "otel.component.type", Value: attribute.StringValue(otelComponentType)}, + ) + + expected := metricdata.ScopeMetrics{ + Scope: got.Scope, + Metrics: []metricdata.Metrics{ + { + Name: otelconv.SDKExporterLogInflight{}.Name(), + Description: otelconv.SDKExporterLogInflight{}.Description(), + Unit: otelconv.SDKExporterLogInflight{}.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: false, + DataPoints: []metricdata.DataPoint[int64]{ + { + Value: 0, + Attributes: expectedAttrs, + }, + }, + }, + }, + { + Name: otelconv.SDKExporterLogExported{}.Name(), + Description: otelconv.SDKExporterLogExported{}.Description(), + Unit: otelconv.SDKExporterLogExported{}.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + DataPoints: []metricdata.DataPoint[int64]{ + { + Value: 3, + Attributes: expectedAttrs, + }, + }, + }, + }, + { + Name: otelconv.SDKExporterOperationDuration{}.Name(), + Description: otelconv.SDKExporterOperationDuration{}.Description(), + Unit: otelconv.SDKExporterOperationDuration{}.Unit(), + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Count: 3, + Attributes: expectedAttrs, + }, + }, + }, + }, + }, + } + + metricdatatest.AssertEqual( + t, + expected, + got, + metricdatatest.IgnoreTimestamp(), + metricdatatest.IgnoreValue(), + ) + }, + }, + { + name: "empty_records", + enable: true, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + err = exporter.Export(context.Background(), []sdklog.Record{}) + require.NoError(t, err) + + got := scopeMetrics() + assert.Equal(t, "go.opentelemetry.io/otel/exporters/stdout/stdoutlog", got.Scope.Name) + assert.NotEmpty(t, got.Scope.Version) + assert.NotEmpty(t, got.Metrics, "metrics should be recorded even for empty records") + + assert.Len(t, got.Metrics, 3, "should have 3 metrics for observability") + metricNames := make(map[string]bool) + for _, metric := range got.Metrics { + metricNames[metric.Name] = true + } + assert.True(t, metricNames["otel.sdk.exporter.log.exported"], "exported metric should be present") + assert.True(t, metricNames["otel.sdk.exporter.operation.duration"], "duration metric should be present") + assert.True(t, metricNames["otel.sdk.exporter.log.inflight"], "inflight metric should be present") + }, + }, + { + name: "export_with_error", + enable: true, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + rf := logtest.RecordFactory{ + Timestamp: time.Now(), + Body: log.StringValue("test log"), + } + record := rf.NewRecord() + records := []sdklog.Record{record, record} + + err = exporter.Export(ctx, records) + require.Error(t, err) + require.Equal(t, context.Canceled, err) + + got := scopeMetrics() + assert.NotEmpty(t, got.Metrics) + + var exportedMetric metricdata.Metrics + exportedInstrument := otelconv.SDKExporterLogExported{} + for _, m := range got.Metrics { + if m.Name == exportedInstrument.Name() { + exportedMetric = m + break + } + } + require.NotEmpty(t, exportedMetric, "exported metric not found") + + data := exportedMetric.Data.(metricdata.Sum[int64]) + require.Len(t, data.DataPoints, 1) + + dataPoint := data.DataPoints[0] + assert.Equal(t, int64(2), dataPoint.Value) + + attrs := dataPoint.Attributes + errorTypeAttr, found := attrs.Value(attribute.Key("error.type")) + assert.True(t, found, "error.type attribute should be present") + assert.Equal(t, "*errors.errorString", errorTypeAttr.AsString()) + }, + }, + { + name: "multiple_exports_mixed_success_failure", + enable: true, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + + rf := logtest.RecordFactory{ + Timestamp: time.Now(), + Body: log.StringValue("test log"), + } + + record1 := rf.NewRecord() + record2 := rf.NewRecord() + err = exporter.Export(context.Background(), []sdklog.Record{record1, record2}) + require.NoError(t, err) + + record3 := rf.NewRecord() + err = exporter.Export(context.Background(), []sdklog.Record{record3}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + record4 := rf.NewRecord() + record5 := rf.NewRecord() + record6 := rf.NewRecord() + err = exporter.Export(ctx, []sdklog.Record{record4, record5, record6}) + require.Error(t, err) + + got := scopeMetrics() + assert.NotEmpty(t, got.Metrics) + + var exportedMetric metricdata.Metrics + exportedInstrument := otelconv.SDKExporterLogExported{} + for _, m := range got.Metrics { + if m.Name == exportedInstrument.Name() { + exportedMetric = m + break + } + } + require.NotEmpty(t, exportedMetric, "exported metric not found") + data := exportedMetric.Data.(metricdata.Sum[int64]) + require.Len(t, data.DataPoints, 2) + + successPoint := data.DataPoints[0] + errorPoint := data.DataPoints[1] + + if _, hasError := successPoint.Attributes.Value(attribute.Key("error.type")); hasError { + successPoint, errorPoint = errorPoint, successPoint + } + + assert.Equal(t, int64(3), successPoint.Value) + _, hasError := successPoint.Attributes.Value(attribute.Key("error.type")) + assert.False(t, hasError, "success data point should not have error.type attribute") + + assert.Equal(t, int64(3), errorPoint.Value) + errorTypeAttr, hasError := errorPoint.Attributes.Value(attribute.Key("error.type")) + assert.True(t, hasError, "error data point should have error.type attribute") + assert.Equal(t, "*errors.errorString", errorTypeAttr.AsString()) + }, + }, + { + name: "self_observability_disabled", + enable: false, + test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { + exporter, err := New() + require.NoError(t, err) + + rf := logtest.RecordFactory{ + Timestamp: time.Now(), + Body: log.StringValue("test log"), + } + record := rf.NewRecord() + records := []sdklog.Record{record} + + err = exporter.Export(context.Background(), records) + require.NoError(t, err) + + got := scopeMetrics() + assert.Empty(t, got.Metrics, "no metrics should be recorded when observability is disabled") + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Reset the global exporter ID counter for deterministic tests + counter.SetExporterID(0) // First call to NextExporterID() will return 0 + + if tc.enable { + t.Setenv("OTEL_GO_X_SELF_OBSERVABILITY", "true") + } + + prev := otel.GetMeterProvider() + t.Cleanup(func() { otel.SetMeterProvider(prev) }) + r := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(r)) + otel.SetMeterProvider(mp) + + scopeMetrics := func() metricdata.ScopeMetrics { + var got metricdata.ResourceMetrics + err := r.Collect(context.Background(), &got) + require.NoError(t, err) + if len(got.ScopeMetrics) == 0 { + return metricdata.ScopeMetrics{} + } + return got.ScopeMetrics[0] + } + tc.test(t, scopeMetrics) + }) + } +} diff --git a/exporters/stdout/stdoutlog/go.mod b/exporters/stdout/stdoutlog/go.mod index 7b5850a7302..d708f754e59 100644 --- a/exporters/stdout/stdoutlog/go.mod +++ b/exporters/stdout/stdoutlog/go.mod @@ -9,9 +9,11 @@ require ( github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.38.0 go.opentelemetry.io/otel/log v0.14.0 + go.opentelemetry.io/otel/metric v1.38.0 go.opentelemetry.io/otel/sdk v1.38.0 go.opentelemetry.io/otel/sdk/log v0.14.0 go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 + go.opentelemetry.io/otel/sdk/metric v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 ) @@ -22,7 +24,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect golang.org/x/sys v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/exporters/stdout/stdoutlog/internal/counter/counter.go b/exporters/stdout/stdoutlog/internal/counter/counter.go new file mode 100644 index 00000000000..bbb4cd7ddc9 --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/counter/counter.go @@ -0,0 +1,31 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/counter/counter.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package counter provides a simple counter for generating unique IDs. +// +// This package is used to generate unique IDs while allowing testing packages +// to reset the counter. +package counter // import "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/counter" + +import "sync/atomic" + +// exporterN is a global 0-based count of the number of exporters created. +var exporterN atomic.Int64 + +// NextExporterID returns the next unique ID for an exporter. +func NextExporterID() int64 { + const inc = 1 + return exporterN.Add(inc) - inc +} + +// SetExporterID sets the exporter ID counter to v and returns the previous +// value. +// +// This function is useful for testing purposes, allowing you to reset the +// counter. It should not be used in production code. +func SetExporterID(v int64) int64 { + return exporterN.Swap(v) +} diff --git a/exporters/stdout/stdoutlog/internal/counter/counter_test.go b/exporters/stdout/stdoutlog/internal/counter/counter_test.go new file mode 100644 index 00000000000..f3e380d3325 --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/counter/counter_test.go @@ -0,0 +1,65 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/counter/counter_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package counter + +import ( + "sync" + "testing" +) + +func TestNextExporterID(t *testing.T) { + SetExporterID(0) + + var expected int64 + for range 10 { + id := NextExporterID() + if id != expected { + t.Errorf("NextExporterID() = %d; want %d", id, expected) + } + expected++ + } +} + +func TestSetExporterID(t *testing.T) { + SetExporterID(0) + + prev := SetExporterID(42) + if prev != 0 { + t.Errorf("SetExporterID(42) returned %d; want 0", prev) + } + + id := NextExporterID() + if id != 42 { + t.Errorf("NextExporterID() = %d; want 42", id) + } +} + +func TestNextExporterIDConcurrentSafe(t *testing.T) { + SetExporterID(0) + + const goroutines = 100 + const increments = 10 + + var wg sync.WaitGroup + wg.Add(goroutines) + + for range goroutines { + go func() { + defer wg.Done() + for range increments { + NextExporterID() + } + }() + } + + wg.Wait() + + expected := int64(goroutines * increments) + if id := NextExporterID(); id != expected { + t.Errorf("NextExporterID() = %d; want %d", id, expected) + } +} \ No newline at end of file diff --git a/exporters/stdout/stdoutlog/internal/gen.go b/exporters/stdout/stdoutlog/internal/gen.go new file mode 100644 index 00000000000..3baadef2304 --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/gen.go @@ -0,0 +1,9 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package internal provides internal functionality for the stdoutlog +// package. +package internal // import "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal" + +//go:generate gotmpl --body=../../../../internal/shared/counter/counter.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/counter\" }" --out=counter/counter.go +//go:generate gotmpl --body=../../../../internal/shared/counter/counter_test.go.tmpl "--data={}" --out=counter/counter_test.go diff --git a/exporters/stdout/stdoutlog/internal/observ/instrumentation.go b/exporters/stdout/stdoutlog/internal/observ/instrumentation.go new file mode 100644 index 00000000000..5df39b02d47 --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/observ/instrumentation.go @@ -0,0 +1,175 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package observ provides experimental observability instrumentation for the +// stdout log exporter. +package observ // import "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/observ" + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/x" + "go.opentelemetry.io/otel/metric" + semconv "go.opentelemetry.io/otel/semconv/v1.37.0" + "go.opentelemetry.io/otel/semconv/v1.37.0/otelconv" +) + +// InstrumentationVersion matches the stdout log exporter version. +const InstrumentationVersion = internal.Version + +var ( + attrsPool = &sync.Pool{ + New: func() any { + // component.name + component.type + error.type + const n = 1 + 1 + 1 + s := make([]attribute.KeyValue, 0, n) + return &s + }, + } + + addOptPool = &sync.Pool{ + New: func() any { + const n = 1 // WithAttributeSet + o := make([]metric.AddOption, 0, n) + return &o + }, + } + + recordOptPool = &sync.Pool{ + New: func() any { + const n = 1 // WithAttributeSet + o := make([]metric.RecordOption, 0, n) + return &o + }, + } +) + +func get[T any](p *sync.Pool) *[]T { return p.Get().(*[]T) } + +func put[T any](p *sync.Pool, s *[]T) { + *s = (*s)[:0] + p.Put(s) +} + +// Instrumentation instruments the stdout log exporter. +type Instrumentation struct { + inflight metric.Int64UpDownCounter + exported metric.Int64Counter + duration metric.Float64Histogram + + attrs []attribute.KeyValue + setOpt metric.MeasurementOption +} + +// ExportLogsDone completes an export observation. +type ExportLogsDone func(success int64, err error) + +// NewInstrumentation returns instrumentation for the stdout log exporter with +// the provided component type and exporter identifier using the global +// MeterProvider. +// +// If the experimental observability feature is disabled, nil is returned. +func NewInstrumentation(componentType string, exporterID int64) (*Instrumentation, error) { + if !x.SelfObservability.Enabled() { + return nil, nil + } + + attrs := []attribute.KeyValue{ + semconv.OTelComponentName(fmt.Sprintf("%s/%d", componentType, exporterID)), + semconv.OTelComponentTypeKey.String(componentType), + } + + inst := &Instrumentation{ + attrs: attrs, + } + + set := attribute.NewSet(attrs...) + inst.setOpt = metric.WithAttributeSet(set) + + mp := otel.GetMeterProvider() + meter := mp.Meter( + componentType, + metric.WithInstrumentationVersion(InstrumentationVersion), + metric.WithSchemaURL(semconv.SchemaURL), + ) + + var err error + + inflight, e := otelconv.NewSDKExporterLogInflight(meter) + if e != nil { + err = errors.Join(err, fmt.Errorf("failed to create log inflight metric: %w", e)) + } + inst.inflight = inflight.Inst() + + exported, e := otelconv.NewSDKExporterLogExported(meter) + if e != nil { + err = errors.Join(err, fmt.Errorf("failed to create log exported metric: %w", e)) + } + inst.exported = exported.Inst() + + duration, e := otelconv.NewSDKExporterOperationDuration(meter) + if e != nil { + err = errors.Join(err, fmt.Errorf("failed to create export duration metric: %w", e)) + } + inst.duration = duration.Inst() + + return inst, err +} + +// ExportLogs instruments the exporter Export method. It returns a callback that +// MUST be invoked when the export completes with the number of successfully +// exported records and the resulting error. +func (i *Instrumentation) ExportLogs(ctx context.Context, total int) ExportLogsDone { + start := time.Now() + + addOpt := get[metric.AddOption](addOptPool) + *addOpt = append(*addOpt, i.setOpt) + i.inflight.Add(ctx, int64(total), *addOpt...) + put(addOptPool, addOpt) + + return func(success int64, err error) { + addOpt := get[metric.AddOption](addOptPool) + defer put(addOptPool, addOpt) + *addOpt = append(*addOpt, i.setOpt) + + n := int64(total) + i.inflight.Add(ctx, -n, *addOpt...) + if success > 0 || (n == 0 && err == nil) { + i.exported.Add(ctx, success, *addOpt...) + } + + measurementOpt := i.setOpt + + if err != nil { + attrs := get[attribute.KeyValue](attrsPool) + defer put(attrsPool, attrs) + *attrs = append(*attrs, i.attrs...) + *attrs = append(*attrs, semconv.ErrorType(err)) + + set := attribute.NewSet(*attrs...) + measurementOpt = metric.WithAttributeSet(set) + + *addOpt = append((*addOpt)[:0], measurementOpt) + failures := n - success + if failures < 0 { + failures = 0 + } + if failures > 0 || (n == 0 && err != nil) { + i.exported.Add(ctx, failures, *addOpt...) + } + } + + recordOpt := get[metric.RecordOption](recordOptPool) + defer put(recordOptPool, recordOpt) + *recordOpt = append(*recordOpt, measurementOpt) + + i.duration.Record(ctx, time.Since(start).Seconds(), *recordOpt...) + } +} diff --git a/exporters/stdout/stdoutlog/internal/version.go b/exporters/stdout/stdoutlog/internal/version.go new file mode 100644 index 00000000000..6c23639cb17 --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/version.go @@ -0,0 +1,8 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package internal // import "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/version" + +// Version is the current release version of the OpenTelemetry stdoutlog +// exporter in use. +const Version = "v0.14.0" diff --git a/exporters/stdout/stdoutlog/internal/x/README.md b/exporters/stdout/stdoutlog/internal/x/README.md new file mode 100644 index 00000000000..82844005490 --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/x/README.md @@ -0,0 +1,32 @@ +# Experimental Features + +The stdout log exporter contains features that have not yet stabilized in the OpenTelemetry specification. +These features are added prior to stabilization so that users can start experimenting with them and provide feedback. + +These features may change in backwards incompatible ways as feedback is applied. +See the [Compatibility and Stability](#compatibility-and-stability) section for more information. + +## Features + +- [Observability](#observability) + +### Observability + +The exporter provides observability features that allow you to monitor the exporter itself. + +To opt-in, set the environment variable `OTEL_GO_X_SELF_OBSERVABILITY` to `true`. + +When enabled, the exporter will record metrics for: + +- Number of log records currently being processed (inflight) +- Total number of log records exported +- Duration of export operations + +## Compatibility and Stability + +Experimental features do not fall within the scope of the OpenTelemetry Go versioning and stability [policy](../../../../../VERSIONING.md). +These features may be removed or modified in successive version releases, including patch versions. + +When an experimental feature is promoted to a stable feature, a migration path will be included in the changelog entry of the release. +There is no guarantee that any environment variable feature flags that enabled the experimental feature will be supported by the stable version. +If they are supported, they may be accompanied with a deprecation notice stating a timeline for the removal of that support. diff --git a/exporters/stdout/stdoutlog/internal/x/x.go b/exporters/stdout/stdoutlog/internal/x/x.go new file mode 100644 index 00000000000..218fbf68d8d --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/x/x.go @@ -0,0 +1,63 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package x documents experimental features for [go.opentelemetry.io/otel/exporters/stdout/stdoutlog]. +package x // import "go.opentelemetry.io/otel/exporters/stdout/stdoutlog/internal/x" + +import ( + "os" + "strings" +) + +// SelfObservability is an experimental feature flag that determines if stdout +// log exporter observability metrics are enabled. +// +// To enable this feature set the OTEL_GO_X_SELF_OBSERVABILITY environment +// variable to the case-insensitive string value of "true" (i.e. "True" and +// "TRUE" will also enable this). +var SelfObservability = newFeature("SELF_OBSERVABILITY", func(v string) (string, bool) { + if strings.EqualFold(v, "true") { + return v, true + } + return "", false +}) + +// Feature is an experimental feature control flag. It provides a uniform way +// to interact with these feature flags and parse their values. +type Feature[T any] struct { + key string + parse func(v string) (T, bool) +} + +func newFeature[T any](suffix string, parse func(string) (T, bool)) Feature[T] { + const envKeyRoot = "OTEL_GO_X_" + return Feature[T]{ + key: envKeyRoot + suffix, + parse: parse, + } +} + +// Key returns the environment variable key that needs to be set to enable the +// feature. +func (f Feature[T]) Key() string { return f.key } + +// Lookup returns the user configured value for the feature and true if the +// user has enabled the feature. Otherwise, if the feature is not enabled, a +// zero-value and false are returned. +func (f Feature[T]) Lookup() (v T, ok bool) { + // https://github.com/open-telemetry/opentelemetry-specification/blob/62effed618589a0bec416a87e559c0a9d96289bb/specification/configuration/sdk-environment-variables.md#parsing-empty-value + // + // > The SDK MUST interpret an empty value of an environment variable the + // > same way as when the variable is unset. + vRaw := os.Getenv(f.key) + if vRaw == "" { + return v, ok + } + return f.parse(vRaw) +} + +// Enabled reports whether the feature is enabled. +func (f Feature[T]) Enabled() bool { + _, ok := f.Lookup() + return ok +} diff --git a/exporters/stdout/stdoutlog/internal/x/x_test.go b/exporters/stdout/stdoutlog/internal/x/x_test.go new file mode 100644 index 00000000000..d1697ea3c2d --- /dev/null +++ b/exporters/stdout/stdoutlog/internal/x/x_test.go @@ -0,0 +1,60 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package x + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObservability(t *testing.T) { + const key = "OTEL_GO_X_SELF_OBSERVABILITY" + require.Equal(t, key, SelfObservability.Key()) + + t.Run("true", run(setenv(key, "true"), assertEnabled(SelfObservability, "true"))) + t.Run("True", run(setenv(key, "True"), assertEnabled(SelfObservability, "True"))) + t.Run("TRUE", run(setenv(key, "TRUE"), assertEnabled(SelfObservability, "TRUE"))) + t.Run("false", run(setenv(key, "false"), assertDisabled(SelfObservability))) + t.Run("100", run(setenv(key, "100"), assertDisabled(SelfObservability))) + t.Run("empty", run(assertDisabled(SelfObservability))) +} + +func run(steps ...func(*testing.T)) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + for _, step := range steps { + step(t) + } + } +} + +func setenv(k, v string) func(t *testing.T) { //nolint:unparam + return func(t *testing.T) { t.Setenv(k, v) } +} + +func assertEnabled[T any](f Feature[T], want T) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + assert.True(t, f.Enabled(), "not enabled") + + v, ok := f.Lookup() + assert.True(t, ok, "Lookup state") + assert.Equal(t, want, v, "Lookup value") + } +} + +func assertDisabled[T any](f Feature[T]) func(*testing.T) { + var zero T + return func(t *testing.T) { + t.Helper() + + assert.False(t, f.Enabled(), "enabled") + + v, ok := f.Lookup() + assert.False(t, ok, "Lookup state") + assert.Equal(t, zero, v, "Lookup value") + } +} diff --git a/exporters/stdout/stdoutlog/record.go b/exporters/stdout/stdoutlog/record.go index 6cb0c8c01d6..db91ec53e0c 100644 --- a/exporters/stdout/stdoutlog/record.go +++ b/exporters/stdout/stdoutlog/record.go @@ -9,7 +9,7 @@ import ( "time" "go.opentelemetry.io/otel/log" - "go.opentelemetry.io/otel/sdk/instrumentation" + sdkinstrumentation "go.opentelemetry.io/otel/sdk/instrumentation" sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/trace" @@ -88,7 +88,7 @@ type recordJSON struct { SpanID trace.SpanID TraceFlags trace.TraceFlags Resource *resource.Resource - Scope instrumentation.Scope + Scope sdkinstrumentation.Scope DroppedAttributes int }