diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e4d0cbc88..f669468c354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ The next release will require at least [Go 1.24]. - The `go.opentelemetry.io/otel/semconv/v1.37.0` package. The package contains semantic conventions from the `v1.37.0` version of the OpenTelemetry Semantic Conventions. See the [migration documentation](./semconv/v1.37.0/MIGRATION.md) for information on how to upgrade from `go.opentelemetry.io/otel/semconv/v1.36.0.`(#7254) +- Add experimental self-observability stdoutmetric exporter metrics in `go.opentelemetry.io/otel/exporters/stdout/stdoutmetric`. + Check the `go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/x` package documentation for more information. (#7150) ### Changed diff --git a/exporters/stdout/stdoutmetric/exporter.go b/exporters/stdout/stdoutmetric/exporter.go index 07a31f82909..5f5606effe8 100644 --- a/exporters/stdout/stdoutmetric/exporter.go +++ b/exporters/stdout/stdoutmetric/exporter.go @@ -10,11 +10,20 @@ import ( "sync" "sync/atomic" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/counter" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/selfobservability" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/x" "go.opentelemetry.io/otel/internal/global" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" ) +// otelComponentType is a name identifying the type of the OpenTelemetry +// component. It is not a standardized OTel component type, so it uses the +// Go package prefixed type name to ensure uniqueness and identity. +const otelComponentType = "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric.exporter" + // exporter is an OpenTelemetry metric exporter. type exporter struct { encVal atomic.Value // encoderHolder @@ -25,6 +34,9 @@ type exporter struct { aggregationSelector metric.AggregationSelector redactTimestamps bool + + selfObservabilityEnabled bool + exporterMetric *selfobservability.ExporterMetrics } // New returns a configured metric exporter. @@ -34,12 +46,22 @@ type exporter struct { func New(options ...Option) (metric.Exporter, error) { cfg := newConfig(options...) exp := &exporter{ - temporalitySelector: cfg.temporalitySelector, - aggregationSelector: cfg.aggregationSelector, - redactTimestamps: cfg.redactTimestamps, + temporalitySelector: cfg.temporalitySelector, + aggregationSelector: cfg.aggregationSelector, + redactTimestamps: cfg.redactTimestamps, + selfObservabilityEnabled: x.SelfObservability.Enabled(), } exp.encVal.Store(*cfg.encoder) - return exp, nil + var err error + if exp.selfObservabilityEnabled { + componentName := fmt.Sprintf("%s/%d", otelComponentType, counter.NextExporterID()) + exp.exporterMetric, err = selfobservability.NewExporterMetrics( + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric", + semconv.OTelComponentName(componentName), + semconv.OTelComponentTypeKey.String(otelComponentType), + ) + } + return exp, err } func (e *exporter) Temporality(k metric.InstrumentKind) metricdata.Temporality { @@ -50,9 +72,12 @@ func (e *exporter) Aggregation(k metric.InstrumentKind) metric.Aggregation { return e.aggregationSelector(k) } -func (e *exporter) Export(ctx context.Context, data *metricdata.ResourceMetrics) error { - if err := ctx.Err(); err != nil { - return err +func (e *exporter) Export(ctx context.Context, data *metricdata.ResourceMetrics) (err error) { + trackExportFunc := e.trackExport(ctx, countDataPoints(data)) + defer func() { trackExportFunc(err) }() + err = ctx.Err() + if err != nil { + return } if e.redactTimestamps { redactTimestamps(data) @@ -63,6 +88,13 @@ func (e *exporter) Export(ctx context.Context, data *metricdata.ResourceMetrics) return e.encVal.Load().(encoderHolder).Encode(data) } +func (e *exporter) trackExport(ctx context.Context, count int64) func(err error) { + if !e.selfObservabilityEnabled { + return func(error) {} + } + return e.exporterMetric.TrackExport(ctx, count) +} + func (*exporter) ForceFlush(context.Context) error { // exporter holds no state, nothing to flush. return nil @@ -159,3 +191,37 @@ func redactDataPointTimestamps[T int64 | float64](sdp []metricdata.DataPoint[T]) } return out } + +// countDataPoints counts the total number of data points in a ResourceMetrics. +func countDataPoints(rm *metricdata.ResourceMetrics) int64 { + if rm == nil { + return 0 + } + + var total int64 + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + switch data := m.Data.(type) { + case metricdata.Gauge[int64]: + total += int64(len(data.DataPoints)) + case metricdata.Gauge[float64]: + total += int64(len(data.DataPoints)) + case metricdata.Sum[int64]: + total += int64(len(data.DataPoints)) + case metricdata.Sum[float64]: + total += int64(len(data.DataPoints)) + case metricdata.Histogram[int64]: + total += int64(len(data.DataPoints)) + case metricdata.Histogram[float64]: + total += int64(len(data.DataPoints)) + case metricdata.ExponentialHistogram[int64]: + total += int64(len(data.DataPoints)) + case metricdata.ExponentialHistogram[float64]: + total += int64(len(data.DataPoints)) + case metricdata.Summary: + total += int64(len(data.DataPoints)) + } + } + } + return total +} diff --git a/exporters/stdout/stdoutmetric/exporter_test.go b/exporters/stdout/stdoutmetric/exporter_test.go index d7f957e63e1..39e71fc99ce 100644 --- a/exporters/stdout/stdoutmetric/exporter_test.go +++ b/exporters/stdout/stdoutmetric/exporter_test.go @@ -7,16 +7,26 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" + "strconv" "testing" "time" "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/stdoutmetric" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/counter" + "go.opentelemetry.io/otel/sdk" + "go.opentelemetry.io/otel/sdk/instrumentation" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" + "go.opentelemetry.io/otel/semconv/v1.36.0/otelconv" ) func testEncoderOption() stdoutmetric.Option { @@ -25,6 +35,13 @@ func testEncoderOption() stdoutmetric.Option { return stdoutmetric.WithEncoder(enc) } +// failingEncoder always returns an error when Encode is called. +type failingEncoder struct{} + +func (failingEncoder) Encode(any) error { + return errors.New("encoding failed") +} + func testCtxErrHonored(factory func(*testing.T) func(context.Context) error) func(t *testing.T) { return func(t *testing.T) { t.Helper() @@ -178,3 +195,271 @@ func TestAggregationSelector(t *testing.T) { var unknownKind metric.InstrumentKind assert.Equal(t, metric.AggregationDrop{}, exp.Aggregation(unknownKind)) } + +func TestExporter_Export_SelfObservability(t *testing.T) { + componentNameAttr := semconv.OTelComponentName("go.opentelemetry.io/otel/exporters/stdout/stdoutmetric.exporter/0") + componentTypeAttr := semconv.OTelComponentTypeKey.String( + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric.exporter", + ) + wantErr := errors.New("encoding failed") + + tests := []struct { + name string + ctx context.Context + exporterOpts []stdoutmetric.Option + selfObservabilityEnabled bool + expectedExportedCount int64 + inflightAttrs attribute.Set + attributes attribute.Set + wantErr error + }{ + { + name: "Enabled", + ctx: context.Background(), + exporterOpts: []stdoutmetric.Option{testEncoderOption()}, + selfObservabilityEnabled: true, + expectedExportedCount: 19, + inflightAttrs: attribute.NewSet(componentNameAttr, componentTypeAttr), + attributes: attribute.NewSet(componentNameAttr, componentTypeAttr), + }, + { + name: "Disabled", + ctx: context.Background(), + exporterOpts: []stdoutmetric.Option{testEncoderOption()}, + selfObservabilityEnabled: false, + expectedExportedCount: 0, + }, + { + name: "EncodingError", + ctx: context.Background(), + exporterOpts: []stdoutmetric.Option{stdoutmetric.WithEncoder(failingEncoder{})}, + selfObservabilityEnabled: true, + expectedExportedCount: 19, + inflightAttrs: attribute.NewSet(componentNameAttr, componentTypeAttr), + attributes: attribute.NewSet( + componentNameAttr, + componentTypeAttr, + semconv.ErrorType(wantErr), + ), + wantErr: wantErr, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("OTEL_GO_X_SELF_OBSERVABILITY", strconv.FormatBool(tt.selfObservabilityEnabled)) + reader := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(reader)) + origMp := otel.GetMeterProvider() + otel.SetMeterProvider(mp) + t.Cleanup(func() { otel.SetMeterProvider(origMp) }) + + exp, err := stdoutmetric.New(tt.exporterOpts...) + require.NoError(t, err) + rm := &metricdata.ResourceMetrics{ScopeMetrics: scopeMetrics()} + + err = exp.Export(tt.ctx, rm) + if tt.wantErr != nil { + assert.EqualError(t, err, tt.wantErr.Error()) + } else { + assert.NoError(t, err) + } + + var metrics metricdata.ResourceMetrics + err = reader.Collect(tt.ctx, &metrics) + require.NoError(t, err) + + if !tt.selfObservabilityEnabled { + assert.Empty(t, metrics.ScopeMetrics) + } else { + assert.Len(t, metrics.ScopeMetrics, 1) + expectedMetrics := metricdata.ScopeMetrics{ + Scope: instrumentation.Scope{ + Name: "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric", + Version: sdk.Version(), + SchemaURL: semconv.SchemaURL, + }, + Metrics: []metricdata.Metrics{ + { + Name: otelconv.SDKExporterMetricDataPointInflight{}.Name(), + Description: otelconv.SDKExporterMetricDataPointInflight{}.Description(), + Unit: otelconv.SDKExporterMetricDataPointInflight{}.Unit(), + Data: metricdata.Sum[int64]{ + DataPoints: []metricdata.DataPoint[int64]{ + { + Value: 0, + Attributes: tt.inflightAttrs, + }, + }, + Temporality: metricdata.CumulativeTemporality, + }, + }, + { + Name: otelconv.SDKExporterMetricDataPointExported{}.Name(), + Description: otelconv.SDKExporterMetricDataPointExported{}.Description(), + Unit: otelconv.SDKExporterMetricDataPointExported{}.Unit(), + Data: metricdata.Sum[int64]{ + DataPoints: []metricdata.DataPoint[int64]{ + { + Value: tt.expectedExportedCount, + Attributes: tt.attributes, + }, + }, + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + }, + }, + { + Name: otelconv.SDKExporterOperationDuration{}.Name(), + Description: otelconv.SDKExporterOperationDuration{}.Description(), + Unit: otelconv.SDKExporterOperationDuration{}.Unit(), + Data: metricdata.Histogram[float64]{ + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Attributes: tt.attributes, + }, + }, + Temporality: metricdata.CumulativeTemporality, + }, + }, + }, + } + assert.Equal(t, expectedMetrics.Scope, metrics.ScopeMetrics[0].Scope) + metricdatatest.AssertEqual(t, expectedMetrics.Metrics[0], metrics.ScopeMetrics[0].Metrics[0], metricdatatest.IgnoreTimestamp()) + metricdatatest.AssertEqual(t, expectedMetrics.Metrics[1], metrics.ScopeMetrics[0].Metrics[1], metricdatatest.IgnoreTimestamp()) + metricdatatest.AssertEqual(t, expectedMetrics.Metrics[2], metrics.ScopeMetrics[0].Metrics[2], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue()) + } + _ = counter.SetExporterID(0) + }) + } +} + +func scopeMetrics() []metricdata.ScopeMetrics { + return []metricdata.ScopeMetrics{ + { + Metrics: []metricdata.Metrics{ + { + Name: "gauge_int64", + Data: metricdata.Gauge[int64]{ + DataPoints: []metricdata.DataPoint[int64]{{Value: 1}, {Value: 2}}, + }, + }, + { + Name: "gauge_float64", + Data: metricdata.Gauge[float64]{ + DataPoints: []metricdata.DataPoint[float64]{ + {Value: 1.0}, + {Value: 2.0}, + {Value: 3.0}, + }, + }, + }, + { + Name: "sum_int64", + Data: metricdata.Sum[int64]{ + DataPoints: []metricdata.DataPoint[int64]{{Value: 10}}, + }, + }, + { + Name: "sum_float64", + Data: metricdata.Sum[float64]{ + DataPoints: []metricdata.DataPoint[float64]{{Value: 10.5}, {Value: 20.5}}, + }, + }, + { + Name: "histogram_int64", + Data: metricdata.Histogram[int64]{ + DataPoints: []metricdata.HistogramDataPoint[int64]{ + {Count: 1}, + {Count: 2}, + {Count: 3}, + }, + }, + }, + { + Name: "histogram_float64", + Data: metricdata.Histogram[float64]{ + DataPoints: []metricdata.HistogramDataPoint[float64]{{Count: 1}}, + }, + }, + { + Name: "exponential_histogram_int64", + Data: metricdata.ExponentialHistogram[int64]{ + DataPoints: []metricdata.ExponentialHistogramDataPoint[int64]{ + {Count: 1}, + {Count: 2}, + }, + }, + }, + { + Name: "exponential_histogram_float64", + Data: metricdata.ExponentialHistogram[float64]{ + DataPoints: []metricdata.ExponentialHistogramDataPoint[float64]{ + {Count: 1}, + {Count: 2}, + {Count: 3}, + {Count: 4}, + }, + }, + }, + { + Name: "summary", + Data: metricdata.Summary{ + DataPoints: []metricdata.SummaryDataPoint{{Count: 1}}, + }, + }, + }, + }, + } +} + +func TestExporter_Export_EncodingErrorTracking(t *testing.T) { + t.Setenv("OTEL_GO_X_SELF_OBSERVABILITY", "true") + reader := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(reader)) + origMp := otel.GetMeterProvider() + otel.SetMeterProvider(mp) + t.Cleanup(func() { otel.SetMeterProvider(origMp) }) + + exp, err := stdoutmetric.New(stdoutmetric.WithEncoder(failingEncoder{})) + assert.NoError(t, err) + + rm := &metricdata.ResourceMetrics{ + ScopeMetrics: []metricdata.ScopeMetrics{ + { + Metrics: []metricdata.Metrics{ + { + Name: "test_gauge", + Data: metricdata.Gauge[int64]{ + DataPoints: []metricdata.DataPoint[int64]{{Value: 1}, {Value: 2}}, + }, + }, + }, + }, + }, + } + + ctx := context.Background() + err = exp.Export(ctx, rm) + assert.EqualError(t, err, "encoding failed") + + var metrics metricdata.ResourceMetrics + err = reader.Collect(ctx, &metrics) + require.NoError(t, err) + + var foundErrorType bool + for _, sm := range metrics.ScopeMetrics { + for _, m := range sm.Metrics { + x := otelconv.SDKExporterMetricDataPointExported{}.Name() + if m.Name == x { + if sum, ok := m.Data.(metricdata.Sum[int64]); ok { + for _, dp := range sum.DataPoints { + var attr attribute.Value + attr, foundErrorType = dp.Attributes.Value(semconv.ErrorTypeKey) + assert.Equal(t, "*errors.errorString", attr.AsString()) + } + } + } + } + } + assert.True(t, foundErrorType) +} diff --git a/exporters/stdout/stdoutmetric/go.mod b/exporters/stdout/stdoutmetric/go.mod index 61b07439f91..4de16b5199b 100644 --- a/exporters/stdout/stdoutmetric/go.mod +++ b/exporters/stdout/stdoutmetric/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require ( github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.37.0 + go.opentelemetry.io/otel/metric v1.37.0 go.opentelemetry.io/otel/sdk v1.37.0 go.opentelemetry.io/otel/sdk/metric v1.37.0 ) @@ -16,7 +17,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.1.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/sys v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/exporters/stdout/stdoutmetric/internal/counter/counter.go b/exporters/stdout/stdoutmetric/internal/counter/counter.go new file mode 100644 index 00000000000..bc002abe7e7 --- /dev/null +++ b/exporters/stdout/stdoutmetric/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/stdoutmetric/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/stdoutmetric/internal/counter/counter_test.go b/exporters/stdout/stdoutmetric/internal/counter/counter_test.go new file mode 100644 index 00000000000..f3e380d3325 --- /dev/null +++ b/exporters/stdout/stdoutmetric/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/stdoutmetric/internal/gen.go b/exporters/stdout/stdoutmetric/internal/gen.go new file mode 100644 index 00000000000..de48e168a39 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/gen.go @@ -0,0 +1,9 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package internal provides internal functionality for the stdoutmetric +// package. +package internal // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal" + +//go:generate gotmpl --body=../../../../internal/shared/counter/counter.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/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/stdoutmetric/internal/selfobservability/selfobservability.go b/exporters/stdout/stdoutmetric/internal/selfobservability/selfobservability.go new file mode 100644 index 00000000000..7f155c6f324 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/selfobservability/selfobservability.go @@ -0,0 +1,88 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package selfobservability provides self-observability metrics for stdout metric exporter. +// This is an experimental feature controlled by the x.SelfObservability feature flag. +package selfobservability // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/selfobservability" + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/sdk" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" + "go.opentelemetry.io/otel/semconv/v1.36.0/otelconv" +) + +var measureAttrsPool = sync.Pool{ + New: func() any { + // "component.name" + "component.type" + "error.type" + const n = 1 + 1 + 1 + s := make([]attribute.KeyValue, 0, n) + // Return a pointer to a slice instead of a slice itself + // to avoid allocations on every call. + return &s + }, +} + +type ExporterMetrics struct { + inflight otelconv.SDKExporterMetricDataPointInflight + exported otelconv.SDKExporterMetricDataPointExported + duration otelconv.SDKExporterOperationDuration + attrs []attribute.KeyValue +} + +func NewExporterMetrics( + name string, + componentName, componentType attribute.KeyValue, +) (*ExporterMetrics, error) { + em := &ExporterMetrics{ + attrs: []attribute.KeyValue{componentName, componentType}, + } + mp := otel.GetMeterProvider() + m := mp.Meter( + name, + metric.WithInstrumentationVersion(sdk.Version()), + metric.WithSchemaURL(semconv.SchemaURL)) + + var err, e error + if em.inflight, e = otelconv.NewSDKExporterMetricDataPointInflight(m); e != nil { + e = fmt.Errorf("failed to create metric_data_point inflight metric: %w", e) + err = errors.Join(err, e) + } + if em.exported, e = otelconv.NewSDKExporterMetricDataPointExported(m); e != nil { + e = fmt.Errorf("failed to create metric_data_point exported metric: %w", e) + err = errors.Join(err, e) + } + if em.duration, e = otelconv.NewSDKExporterOperationDuration(m); e != nil { + e = fmt.Errorf("failed to create operation duration metric: %w", e) + err = errors.Join(err, e) + } + return em, err +} + +func (em *ExporterMetrics) TrackExport(ctx context.Context, count int64) func(err error) { + begin := time.Now() + em.inflight.Add(ctx, count, em.attrs...) + return func(err error) { + durationSeconds := time.Since(begin).Seconds() + attrs := &em.attrs + em.inflight.Add(ctx, -count, *attrs...) + if err != nil { + attrs = measureAttrsPool.Get().(*[]attribute.KeyValue) + defer func() { + *attrs = (*attrs)[:0] // reset the slice for reuse + measureAttrsPool.Put(attrs) + }() + *attrs = append(*attrs, em.attrs[0], em.attrs[1], semconv.ErrorType(err)) + } + em.exported.Add(ctx, count, *attrs...) + em.duration.Record(ctx, durationSeconds, *attrs...) + } +} diff --git a/exporters/stdout/stdoutmetric/internal/selfobservability/selfobservability_test.go b/exporters/stdout/stdoutmetric/internal/selfobservability/selfobservability_test.go new file mode 100644 index 00000000000..0b5d8521250 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/selfobservability/selfobservability_test.go @@ -0,0 +1,184 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package selfobservability + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "go.opentelemetry.io/otel" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" + "go.opentelemetry.io/otel/semconv/v1.36.0/otelconv" +) + +type testSetup struct { + reader *sdkmetric.ManualReader + mp *sdkmetric.MeterProvider + ctx context.Context + em *ExporterMetrics +} + +func setupTestMeterProvider(t *testing.T) *testSetup { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + originalMP := otel.GetMeterProvider() + otel.SetMeterProvider(mp) + t.Cleanup(func() { otel.SetMeterProvider(originalMP) }) + + componentName := semconv.OTelComponentName("test") + componentType := semconv.OTelComponentTypeKey.String("exporter") + em, err := NewExporterMetrics( + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric", + componentName, + componentType, + ) + assert.NoError(t, err) + + return &testSetup{ + reader: reader, + mp: mp, + ctx: context.Background(), + em: em, + } +} + +func collectMetrics(t *testing.T, setup *testSetup) metricdata.ResourceMetrics { + var rm metricdata.ResourceMetrics + err := setup.reader.Collect(setup.ctx, &rm) + assert.NoError(t, err) + return rm +} + +func findMetric(rm metricdata.ResourceMetrics, name string) (metricdata.Metrics, bool) { + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == name { + return m, true + } + } + } + return metricdata.Metrics{}, false +} + +func TestExporterMetrics_TrackExport(t *testing.T) { + setup := setupTestMeterProvider(t) + + done1 := setup.em.TrackExport(setup.ctx, 2) + done2 := setup.em.TrackExport(setup.ctx, 3) + done3 := setup.em.TrackExport(setup.ctx, 1) + time.Sleep(5 * time.Millisecond) + done2(nil) + done1(errors.New("failed")) + done3(nil) + + rm := collectMetrics(t, setup) + assert.NotEmpty(t, rm.ScopeMetrics) + + inflight, found := findMetric(rm, otelconv.SDKExporterMetricDataPointInflight{}.Name()) + assert.True(t, found) + var totalInflightValue int64 + if sum, ok := inflight.Data.(metricdata.Sum[int64]); ok { + for _, dp := range sum.DataPoints { + totalInflightValue += dp.Value + } + } + + exported, found := findMetric(rm, otelconv.SDKExporterMetricDataPointExported{}.Name()) + assert.True(t, found) + var totalExported int64 + if sum, ok := exported.Data.(metricdata.Sum[int64]); ok { + for _, dp := range sum.DataPoints { + totalExported += dp.Value + } + } + + duration, found := findMetric(rm, otelconv.SDKExporterOperationDuration{}.Name()) + assert.True(t, found) + var operationCount uint64 + if hist, ok := duration.Data.(metricdata.Histogram[float64]); ok { + for _, dp := range hist.DataPoints { + operationCount += dp.Count + assert.Positive(t, dp.Sum) + } + } + + assert.Equal(t, int64(6), totalExported) + assert.Equal(t, uint64(3), operationCount) + assert.Equal(t, int64(0), totalInflightValue) +} + +func TestExporterMetrics_TrackExport_WithError(t *testing.T) { + setup := setupTestMeterProvider(t) + count := int64(3) + testErr := errors.New("export failed") + + done := setup.em.TrackExport(setup.ctx, count) + done(testErr) + + rm := collectMetrics(t, setup) + assert.NotEmpty(t, rm.ScopeMetrics) + + exported, found := findMetric(rm, otelconv.SDKExporterMetricDataPointExported{}.Name()) + assert.True(t, found) + if sum, ok := exported.Data.(metricdata.Sum[int64]); ok { + attr, hasErrorAttr := sum.DataPoints[0].Attributes.Value(semconv.ErrorTypeKey) + assert.True(t, hasErrorAttr) + assert.Equal(t, "*errors.errorString", attr.AsString()) + } +} + +func TestExporterMetrics_TrackExport_InflightTracking(t *testing.T) { + setup := setupTestMeterProvider(t) + count := int64(10) + + done := setup.em.TrackExport(setup.ctx, count) + rm := collectMetrics(t, setup) + inflight, found := findMetric(rm, otelconv.SDKExporterMetricDataPointInflight{}.Name()) + assert.True(t, found) + + var inflightValue int64 + if sum, ok := inflight.Data.(metricdata.Sum[int64]); ok { + for _, dp := range sum.DataPoints { + inflightValue = dp.Value + } + } + assert.Equal(t, count, inflightValue) + + done(nil) + rm = collectMetrics(t, setup) + inflight, found = findMetric(rm, otelconv.SDKExporterMetricDataPointInflight{}.Name()) + assert.True(t, found) + if sum, ok := inflight.Data.(metricdata.Sum[int64]); ok { + for _, dp := range sum.DataPoints { + assert.Equal(t, int64(0), dp.Value) + } + } +} + +func TestExporterMetrics_AttributesNotPermanentlyModified(t *testing.T) { + componentName := semconv.OTelComponentName("test-component") + componentType := semconv.OTelComponentTypeKey.String("test-exporter") + em, err := NewExporterMetrics("test", componentName, componentType) + assert.NoError(t, err) + + assert.Len(t, em.attrs, 2) + assert.Contains(t, em.attrs, componentName) + assert.Contains(t, em.attrs, componentType) + + done := em.TrackExport(context.Background(), 1) + done(errors.New("test error")) + done = em.TrackExport(context.Background(), 1) + done(nil) + + assert.Len(t, em.attrs, 2) + assert.Contains(t, em.attrs, componentName) + assert.Contains(t, em.attrs, componentType) +} diff --git a/exporters/stdout/stdoutmetric/internal/x/README.md b/exporters/stdout/stdoutmetric/internal/x/README.md new file mode 100644 index 00000000000..3a64525ae59 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/x/README.md @@ -0,0 +1,36 @@ +# Experimental Features + +The `stdoutmetric` exporter contains features that have not yet stabilized in the OpenTelemetry specification. +These features are added to the `stdoutmetric` exporter prior to stabilization in the specification so that users can start experimenting with them and provide feedback. + +These feature may change in backwards incompatible ways as feedback is applied. +See the [Compatibility and Stability](#compatibility-and-stability) section for more information. + +## Features + +- [Self-Observability](#self-observability) + +### Self-Observability + +The `stdoutmetric` exporter provides a self-observability feature that allows 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 create the following metrics using the global `MeterProvider`: + +- `otel.sdk.exporter.metric_data_point.inflight` +- `otel.sdk.exporter.metric_data_point.exported` +- `otel.sdk.exporter.operation.duration` + +Please see the [Semantic conventions for OpenTelemetry SDK metrics] documentation for more details on these metrics. + +[Semantic conventions for OpenTelemetry SDK metrics]: https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/otel/sdk-metrics.md + +## 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/stdoutmetric/internal/x/x.go b/exporters/stdout/stdoutmetric/internal/x/x.go new file mode 100644 index 00000000000..fb6dfd97564 --- /dev/null +++ b/exporters/stdout/stdoutmetric/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/stdoutmetric]. +package x // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/x" + +import ( + "os" + "strings" +) + +// SelfObservability is an experimental feature flag that determines if SDK +// self-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/stdoutmetric/internal/x/x_test.go b/exporters/stdout/stdoutmetric/internal/x/x_test.go new file mode 100644 index 00000000000..15124ca91d1 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/x/x_test.go @@ -0,0 +1,59 @@ +// 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 TestSelfObservability(t *testing.T) { + const key = "OTEL_GO_X_SELF_OBSERVABILITY" + require.Equal(t, key, SelfObservability.Key()) + + t.Run("100", run(setenv(key, "100"), assertDisabled(SelfObservability))) + 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("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 // This is a reusable test utility function. + 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") + } +}