diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8d56765ea..fe0cb8bc7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`. (#7353) - Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`. (#7459) - Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp`. (#7486) +- Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/stdout/stdoutmetric`. (#7492) ### Fixed diff --git a/exporters/stdout/stdoutmetric/exporter.go b/exporters/stdout/stdoutmetric/exporter.go index 07a31f82909..21916617a8b 100644 --- a/exporters/stdout/stdoutmetric/exporter.go +++ b/exporters/stdout/stdoutmetric/exporter.go @@ -10,6 +10,8 @@ import ( "sync" "sync/atomic" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/counter" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/observ" "go.opentelemetry.io/otel/internal/global" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" @@ -25,6 +27,8 @@ type exporter struct { aggregationSelector metric.AggregationSelector redactTimestamps bool + + inst *observ.Instrumentation } // New returns a configured metric exporter. @@ -39,7 +43,9 @@ func New(options ...Option) (metric.Exporter, error) { redactTimestamps: cfg.redactTimestamps, } exp.encVal.Store(*cfg.encoder) - return exp, nil + var err error + exp.inst, err = observ.NewInstrumentation(counter.NextExporterID()) + return exp, err } func (e *exporter) Temporality(k metric.InstrumentKind) metricdata.Temporality { @@ -50,8 +56,11 @@ 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 { +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 err } if e.redactTimestamps { @@ -63,6 +72,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.inst == nil { + return func(error) {} + } + return e.inst.TrackExport(ctx, count) +} + func (*exporter) ForceFlush(context.Context) error { // exporter holds no state, nothing to flush. return nil @@ -159,3 +175,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 3916d5befe6..a90319d0a3c 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,275 @@ func TestAggregationSelector(t *testing.T) { var unknownKind metric.InstrumentKind assert.Equal(t, metric.AggregationDrop{}, exp.Aggregation(unknownKind)) } + +func TestExporter_Export_Observability(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 + observabilityEnabled bool + expectedExportedCount int64 + inflightAttrs attribute.Set + attributes attribute.Set + wantErr error + }{ + { + name: "Enabled", + ctx: t.Context(), + exporterOpts: []stdoutmetric.Option{testEncoderOption()}, + observabilityEnabled: true, + expectedExportedCount: 19, + inflightAttrs: attribute.NewSet(componentNameAttr, componentTypeAttr), + attributes: attribute.NewSet(componentNameAttr, componentTypeAttr), + }, + { + name: "Disabled", + ctx: t.Context(), + exporterOpts: []stdoutmetric.Option{testEncoderOption()}, + observabilityEnabled: false, + expectedExportedCount: 0, + }, + { + name: "EncodingError", + ctx: t.Context(), + exporterOpts: []stdoutmetric.Option{stdoutmetric.WithEncoder(failingEncoder{})}, + observabilityEnabled: 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_OBSERVABILITY", strconv.FormatBool(tt.observabilityEnabled)) + + // Reset the exporter ID counter to ensure consistent component names + _ = counter.SetExporterID(0) + + 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.observabilityEnabled { + 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_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 := t.Context() + 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 50a558725cb..a0b19f2bbe9 100644 --- a/exporters/stdout/stdoutmetric/go.mod +++ b/exporters/stdout/stdoutmetric/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/metric v1.38.0 go.opentelemetry.io/otel/sdk v1.38.0 go.opentelemetry.io/otel/sdk/metric v1.38.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.2.1 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/sys v0.37.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..725722aa7ba --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/gen.go @@ -0,0 +1,12 @@ +// 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 + +//go:generate gotmpl --body=../../../../internal/shared/x/x.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal\" }" --out=x/x.go +//go:generate gotmpl --body=../../../../internal/shared/x/x_test.go.tmpl "--data={}" --out=x/x_test.go diff --git a/exporters/stdout/stdoutmetric/internal/observ/exporter.go b/exporters/stdout/stdoutmetric/internal/observ/exporter.go new file mode 100644 index 00000000000..070247013b9 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/observ/exporter.go @@ -0,0 +1,127 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package observ provides observability for stdout metric exporter. +// This is an experimental feature controlled by the x.Observability feature flag. +package observ // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/observ" + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/x" + "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" +) + +const ( + scope = "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" + + // componentType 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. + componentType = "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric.exporter" +) + +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 + }, +} + +// Instrumentation is the instrumentation for stdout metric exporter. +type Instrumentation struct { + inflight metric.Int64UpDownCounter + addOpts []metric.AddOption + exported otelconv.SDKExporterMetricDataPointExported + duration otelconv.SDKExporterOperationDuration + recordOpts []metric.RecordOption + attrs []attribute.KeyValue +} + +func exporterComponentName(id int64) attribute.KeyValue { + componentName := fmt.Sprintf("%s/%d", componentType, id) + return semconv.OTelComponentName(componentName) +} + +// NewInstrumentation returns a new Instrumentation for the stdout metric exporter +// with the provided ID. +// +// If the experimental observability is disabled, nil is returned. +func NewInstrumentation(id int64) (*Instrumentation, error) { + if !x.Observability.Enabled() { + return nil, nil + } + attrs := []attribute.KeyValue{ + exporterComponentName(id), + semconv.OTelComponentTypeKey.String(componentType), + } + attrOpts := metric.WithAttributeSet(attribute.NewSet(attrs...)) + addOpts := []metric.AddOption{attrOpts} + recordOpts := []metric.RecordOption{attrOpts} + em := &Instrumentation{ + attrs: attrs, + addOpts: addOpts, + recordOpts: recordOpts, + } + mp := otel.GetMeterProvider() + m := mp.Meter( + scope, + metric.WithInstrumentationVersion(sdk.Version()), + metric.WithSchemaURL(semconv.SchemaURL)) + + var err error + inflightMetric, e := otelconv.NewSDKExporterMetricDataPointInflight(m) + if e != nil { + e = fmt.Errorf("failed to create metric_data_point inflight metric: %w", e) + err = errors.Join(err, e) + } + em.inflight = inflightMetric.Int64UpDownCounter + 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 *Instrumentation) TrackExport(ctx context.Context, count int64) func(err error) { + begin := time.Now() + em.inflight.Add(ctx, count, em.addOpts...) + return func(err error) { + durationSeconds := time.Since(begin).Seconds() + em.inflight.Add(ctx, -count, em.addOpts...) + if err == nil { // short circuit in case of success to avoid allocations + em.exported.Int64Counter.Add(ctx, count, em.addOpts...) + em.duration.Float64Histogram.Record(ctx, durationSeconds, em.recordOpts...) + return + } + + attrs := measureAttrsPool.Get().(*[]attribute.KeyValue) + defer func() { + *attrs = (*attrs)[:0] // reset the slice for reuse + measureAttrsPool.Put(attrs) + }() + *attrs = append(*attrs, em.attrs...) + *attrs = append(*attrs, semconv.ErrorType(err)) + + set := attribute.NewSet(*attrs...) + em.exported.AddSet(ctx, count, set) + em.duration.RecordSet(ctx, durationSeconds, set) + } +} diff --git a/exporters/stdout/stdoutmetric/internal/observ/exporter_test.go b/exporters/stdout/stdoutmetric/internal/observ/exporter_test.go new file mode 100644 index 00000000000..c0b4a665343 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/observ/exporter_test.go @@ -0,0 +1,232 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package observ + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric/noop" + 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 *Instrumentation +} + +func setupTestMeterProvider(t *testing.T) *testSetup { + t.Setenv("OTEL_GO_X_OBSERVABILITY", "true") + + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + originalMP := otel.GetMeterProvider() + otel.SetMeterProvider(mp) + t.Cleanup(func() { otel.SetMeterProvider(originalMP) }) + + em, err := NewInstrumentation(0) + assert.NoError(t, err) + + return &testSetup{ + reader: reader, + mp: mp, + ctx: t.Context(), + 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) { + t.Setenv("OTEL_GO_X_OBSERVABILITY", "true") + + em, err := NewInstrumentation(42) + assert.NoError(t, err) + + // Should have component.name and component.type attributes + assert.Len(t, em.attrs, 2) + expectedComponentName := semconv.OTelComponentName( + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric.exporter/42", + ) + expectedComponentType := semconv.OTelComponentTypeKey.String(componentType) + assert.Contains(t, em.attrs, expectedComponentName) + assert.Contains(t, em.attrs, expectedComponentType) + + done := em.TrackExport(t.Context(), 1) + done(errors.New("test error")) + done = em.TrackExport(t.Context(), 1) + done(nil) + + // Attributes should not be modified after tracking exports + assert.Len(t, em.attrs, 2) + assert.Contains(t, em.attrs, expectedComponentName) + assert.Contains(t, em.attrs, expectedComponentType) +} + +func BenchmarkTrackExport(b *testing.B) { + b.Setenv("OTEL_GO_X_OBSERVABILITY", "true") + orig := otel.GetMeterProvider() + b.Cleanup(func() { + otel.SetMeterProvider(orig) + }) + + // Ensure deterministic benchmark by using noop meter. + otel.SetMeterProvider(noop.NewMeterProvider()) + + newExp := func(b *testing.B) *Instrumentation { + b.Helper() + em, err := NewInstrumentation(0) + require.NoError(b, err) + require.NotNil(b, em) + return em + } + + b.Run("Success", func(b *testing.B) { + em := newExp(b) + b.ResetTimer() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + done := em.TrackExport(b.Context(), 10) + done(nil) + } + }) + }) + + b.Run("WithError", func(b *testing.B) { + em := newExp(b) + testErr := errors.New("export failed") + b.ResetTimer() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + done := em.TrackExport(b.Context(), 10) + done(testErr) + } + }) + }) +} diff --git a/exporters/stdout/stdoutmetric/internal/x/README.md b/exporters/stdout/stdoutmetric/internal/x/README.md new file mode 100644 index 00000000000..fe875d42c8f --- /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 + +- [Observability](#observability) + +### Observability + +The `stdoutmetric` exporter provides an observability feature that allows you to monitor the exporter itself. + +To opt-in, set the environment variable `OTEL_GO_X_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/features.go b/exporters/stdout/stdoutmetric/internal/x/features.go new file mode 100644 index 00000000000..460d2244c16 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/x/features.go @@ -0,0 +1,22 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package x // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/x" + +import "strings" + +// Observability is an experimental feature flag that determines if SDK +// observability metrics are enabled. +// +// To enable this feature set the OTEL_GO_X_OBSERVABILITY environment variable +// to the case-insensitive string value of "true" (i.e. "True" and "TRUE" +// will also enable this). +var Observability = newFeature( + []string{"OBSERVABILITY", "SELF_OBSERVABILITY"}, + func(v string) (string, bool) { + if strings.EqualFold(v, "true") { + return v, true + } + return "", false + }, +) diff --git a/exporters/stdout/stdoutmetric/internal/x/features_test.go b/exporters/stdout/stdoutmetric/internal/x/features_test.go new file mode 100644 index 00000000000..de307257923 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/x/features_test.go @@ -0,0 +1,24 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package x + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestObservability(t *testing.T) { + const key = "OTEL_GO_X_OBSERVABILITY" + require.Contains(t, Observability.Keys(), key) + + const altKey = "OTEL_GO_X_SELF_OBSERVABILITY" + require.Contains(t, Observability.Keys(), altKey) + + t.Run("100", run(setenv(key, "100"), assertDisabled(Observability))) + t.Run("true", run(setenv(key, "true"), assertEnabled(Observability, "true"))) + t.Run("True", run(setenv(key, "True"), assertEnabled(Observability, "True"))) + t.Run("false", run(setenv(key, "false"), assertDisabled(Observability))) + t.Run("empty", run(assertDisabled(Observability))) +} diff --git a/exporters/stdout/stdoutmetric/internal/x/x.go b/exporters/stdout/stdoutmetric/internal/x/x.go new file mode 100644 index 00000000000..3a0e42897a7 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/x/x.go @@ -0,0 +1,58 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/x/x.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package x documents experimental features for [go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal]. +package x // import "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric/internal/x" + +import ( + "os" +) + +// 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 { + keys []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_" + keys := make([]string, 0, len(suffix)) + for _, s := range suffix { + keys = append(keys, envKeyRoot+s) + } + return Feature[T]{ + keys: keys, + parse: parse, + } +} + +// Keys returns the environment variable keys that can be set to enable the +// feature. +func (f Feature[T]) Keys() []string { return f.keys } + +// 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. + for _, key := range f.keys { + vRaw := os.Getenv(key) + if vRaw != "" { + return f.parse(vRaw) + } + } + return v, ok +} + +// 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..a715d7608a7 --- /dev/null +++ b/exporters/stdout/stdoutmetric/internal/x/x_test.go @@ -0,0 +1,75 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/x/x_text.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package x + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + mockKey = "OTEL_GO_X_MOCK_FEATURE" + mockKey2 = "OTEL_GO_X_MOCK_FEATURE2" +) + +var mockFeature = newFeature([]string{"MOCK_FEATURE", "MOCK_FEATURE2"}, func(v string) (string, bool) { + if strings.EqualFold(v, "true") { + return v, true + } + return "", false +}) + +func TestFeature(t *testing.T) { + require.Contains(t, mockFeature.Keys(), mockKey) + require.Contains(t, mockFeature.Keys(), mockKey2) + + t.Run("100", run(setenv(mockKey, "100"), assertDisabled(mockFeature))) + t.Run("true", run(setenv(mockKey, "true"), assertEnabled(mockFeature, "true"))) + t.Run("True", run(setenv(mockKey, "True"), assertEnabled(mockFeature, "True"))) + t.Run("false", run(setenv(mockKey, "false"), assertDisabled(mockFeature))) + t.Run("empty", run(assertDisabled(mockFeature))) +} + +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") + } +}