diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d1a4f91..c99e783f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add native histogram exemplar support in `go.opentelemetry.io/otel/exporters/prometheus`. (#6772) - Add experimental self-observability log metrics in `go.opentelemetry.io/otel/sdk/log`. Check the `go.opentelemetry.io/otel/sdk/log/internal/x` package documentation for more information. (#7121) +- Add experimental self-observability trace exporter metrics in `go.opentelemetry.io/otel/exporters/stdout/stdouttrace`. + Check the `go.opentelemetry.io/otel/exporters/stdout/stdouttrace/internal/x` package documentation for more information. (#7133) ### Changed diff --git a/exporters/stdout/stdouttrace/doc.go b/exporters/stdout/stdouttrace/doc.go index eff7730cd..648bc0749 100644 --- a/exporters/stdout/stdouttrace/doc.go +++ b/exporters/stdout/stdouttrace/doc.go @@ -3,4 +3,7 @@ // Package stdouttrace contains an OpenTelemetry exporter for tracing // telemetry to be written to an output destination as JSON. +// +// See [go.opentelemetry.io/otel/exporters/stdout/stdouttrace/internal/x] for information about +// the experimental features. package stdouttrace // import "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" diff --git a/exporters/stdout/stdouttrace/go.mod b/exporters/stdout/stdouttrace/go.mod index 73bc75697..98ee486ed 100644 --- a/exporters/stdout/stdouttrace/go.mod +++ b/exporters/stdout/stdouttrace/go.mod @@ -10,7 +10,9 @@ replace ( require ( github.com/stretchr/testify v1.10.0 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 go.opentelemetry.io/otel/trace v1.37.0 ) @@ -21,7 +23,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 golang.org/x/sys v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/exporters/stdout/stdouttrace/internal/x/README.md b/exporters/stdout/stdouttrace/internal/x/README.md new file mode 100644 index 000000000..7dae5bd73 --- /dev/null +++ b/exporters/stdout/stdouttrace/internal/x/README.md @@ -0,0 +1,36 @@ +# Experimental Features + +The `stdouttrace` exporter contains features that have not yet stabilized in the OpenTelemetry specification. +These features are added to the `stdouttrace` 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 `stdouttrace` exporter provides a self-observability feature that allows you to monitor the SDK itself. + +To opt-in, set the environment variable `OTEL_GO_X_SELF_OBSERVABILITY` to `true`. + +When enabled, the SDK will create the following metrics using the global `MeterProvider`: + +- `otel.sdk.exporter.span.inflight` +- `otel.sdk.exporter.span.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/stdouttrace/internal/x/x.go b/exporters/stdout/stdouttrace/internal/x/x.go new file mode 100644 index 000000000..55bb98a96 --- /dev/null +++ b/exporters/stdout/stdouttrace/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/stdouttrace]. +package x // import "go.opentelemetry.io/otel/exporters/stdout/stdouttrace/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/stdouttrace/internal/x/x_test.go b/exporters/stdout/stdouttrace/internal/x/x_test.go new file mode 100644 index 000000000..15124ca91 --- /dev/null +++ b/exporters/stdout/stdouttrace/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") + } +} diff --git a/exporters/stdout/stdouttrace/trace.go b/exporters/stdout/stdouttrace/trace.go index 1118869d6..3b2200aba 100644 --- a/exporters/stdout/stdouttrace/trace.go +++ b/exporters/stdout/stdouttrace/trace.go @@ -6,13 +6,25 @@ package stdouttrace // import "go.opentelemetry.io/otel/exporters/stdout/stdoutt import ( "context" "encoding/json" + "fmt" "sync" + "sync/atomic" "time" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace/internal/x" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/sdk" "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" + "go.opentelemetry.io/otel/semconv/v1.36.0/otelconv" ) +// otelComponentType is a name identifying the type of the OpenTelemetry component. +const otelComponentType = "stdout_trace_exporter" + var zeroTime time.Time var _ trace.SpanExporter = &Exporter{} @@ -26,10 +38,13 @@ func New(options ...Option) (*Exporter, error) { enc.SetIndent("", "\t") } - return &Exporter{ + exporter := &Exporter{ encoder: enc, timestamps: cfg.Timestamps, - }, nil + } + exporter.initSelfObservability() + + return exporter, nil } // Exporter is an implementation of trace.SpanSyncer that writes spans to stdout. @@ -40,10 +55,65 @@ type Exporter struct { stoppedMu sync.RWMutex stopped bool + + selfObservabilityEnabled bool + selfObservabilityAttrs []attribute.KeyValue // selfObservability common attributes + spanInflightMetric otelconv.SDKExporterSpanInflight + spanExportedMetric otelconv.SDKExporterSpanExported + operationDurationMetric otelconv.SDKExporterOperationDuration +} + +// initSelfObservability initializes self-observability for the exporter if enabled. +func (e *Exporter) initSelfObservability() { + if !x.SelfObservability.Enabled() { + return + } + + e.selfObservabilityEnabled = true + e.selfObservabilityAttrs = []attribute.KeyValue{ + semconv.OTelComponentName(fmt.Sprintf("%s/%d", otelComponentType, nextExporterID())), + semconv.OTelComponentTypeKey.String(otelComponentType), + } + + mp := otel.GetMeterProvider() + m := mp.Meter("go.opentelemetry.io/otel/exporters/stdout/stdouttrace", + metric.WithInstrumentationVersion(sdk.Version()), + metric.WithSchemaURL(semconv.SchemaURL), + ) + + var err error + if e.spanInflightMetric, err = otelconv.NewSDKExporterSpanInflight(m); err != nil { + otel.Handle(err) + } + if e.spanExportedMetric, err = otelconv.NewSDKExporterSpanExported(m); err != nil { + otel.Handle(err) + } + if e.operationDurationMetric, err = otelconv.NewSDKExporterOperationDuration(m); err != nil { + otel.Handle(err) + } } // ExportSpans writes spans in json format to stdout. -func (e *Exporter) ExportSpans(ctx context.Context, spans []trace.ReadOnlySpan) error { +func (e *Exporter) ExportSpans(ctx context.Context, spans []trace.ReadOnlySpan) (err error) { + if e.selfObservabilityEnabled { + count := int64(len(spans)) + + e.spanInflightMetric.Add(context.Background(), count, e.selfObservabilityAttrs...) + defer func(starting time.Time) { + // additional attributes for self-observability, + // only spanExportedMetric and operationDurationMetric are supported + addAttrs := make([]attribute.KeyValue, len(e.selfObservabilityAttrs), len(e.selfObservabilityAttrs)+1) + copy(addAttrs, e.selfObservabilityAttrs) + if err != nil { + addAttrs = append(addAttrs, semconv.ErrorType(err)) + } + + e.spanInflightMetric.Add(context.Background(), -count, e.selfObservabilityAttrs...) + e.spanExportedMetric.Add(context.Background(), count, addAttrs...) + e.operationDurationMetric.Record(context.Background(), time.Since(starting).Seconds(), addAttrs...) + }(time.Now()) + } + if err := ctx.Err(); err != nil { return err } @@ -101,3 +171,11 @@ func (e *Exporter) MarshalLog() any { WithTimestamps: e.timestamps, } } + +var exporterIDCounter atomic.Int64 + +// nextExporterID returns a new unique ID for an exporter. +// the starting value is 0, and it increments by 1 for each call. +func nextExporterID() int64 { + return exporterIDCounter.Add(1) - 1 +} diff --git a/exporters/stdout/stdouttrace/trace_test.go b/exporters/stdout/stdouttrace/trace_test.go index a034eb401..6c609c011 100644 --- a/exporters/stdout/stdouttrace/trace_test.go +++ b/exporters/stdout/stdouttrace/trace_test.go @@ -7,18 +7,27 @@ import ( "bytes" "context" "encoding/json" + "io" "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/codes" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "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" "go.opentelemetry.io/otel/sdk/resource" tracesdk "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" + "go.opentelemetry.io/otel/semconv/v1.36.0/otelconv" "go.opentelemetry.io/otel/trace" ) @@ -227,3 +236,210 @@ func TestExporterShutdownNoError(t *testing.T) { t.Errorf("shutdown errored: expected nil, got %v", err) } } + +func TestSelfObservability(t *testing.T) { + defaultCallExportSpans := func(t *testing.T, exporter *stdouttrace.Exporter) { + require.NoError(t, exporter.ExportSpans(context.Background(), tracetest.SpanStubs{ + {Name: "/foo"}, + {Name: "/bar"}, + }.Snapshots())) + } + + tests := []struct { + name string + enabled bool + callExportSpans func(t *testing.T, exporter *stdouttrace.Exporter) + assertMetrics func(t *testing.T, rm metricdata.ResourceMetrics) + }{ + { + name: "Disabled", + enabled: false, + callExportSpans: defaultCallExportSpans, + assertMetrics: func(t *testing.T, rm metricdata.ResourceMetrics) { + assert.Empty(t, rm.ScopeMetrics) + }, + }, + { + name: "Enabled", + enabled: true, + callExportSpans: defaultCallExportSpans, + assertMetrics: func(t *testing.T, rm metricdata.ResourceMetrics) { + t.Helper() + require.Len(t, rm.ScopeMetrics, 1) + + sm := rm.ScopeMetrics[0] + require.Len(t, sm.Metrics, 3) + + assert.Equal(t, instrumentation.Scope{ + Name: "go.opentelemetry.io/otel/exporters/stdout/stdouttrace", + Version: sdk.Version(), + SchemaURL: semconv.SchemaURL, + }, sm.Scope) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: otelconv.SDKExporterSpanInflight{}.Name(), + Description: otelconv.SDKExporterSpanInflight{}.Description(), + Unit: otelconv.SDKExporterSpanInflight{}.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: attribute.NewSet( + semconv.OTelComponentName("stdout_trace_exporter/0"), + semconv.OTelComponentTypeKey.String("stdout_trace_exporter"), + ), + Value: 0, + }, + }, + }, + }, sm.Metrics[0], metricdatatest.IgnoreTimestamp()) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: otelconv.SDKExporterSpanExported{}.Name(), + Description: otelconv.SDKExporterSpanExported{}.Description(), + Unit: otelconv.SDKExporterSpanExported{}.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: attribute.NewSet( + semconv.OTelComponentName("stdout_trace_exporter/0"), + semconv.OTelComponentTypeKey.String("stdout_trace_exporter"), + ), + Value: 2, + }, + }, + }, + }, sm.Metrics[1], metricdatatest.IgnoreTimestamp()) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: otelconv.SDKExporterOperationDuration{}.Name(), + Description: otelconv.SDKExporterOperationDuration{}.Description(), + Unit: otelconv.SDKExporterOperationDuration{}.Unit(), + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Attributes: attribute.NewSet( + semconv.OTelComponentName("stdout_trace_exporter/0"), + semconv.OTelComponentTypeKey.String("stdout_trace_exporter"), + ), + }, + }, + }, + }, sm.Metrics[2], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue()) + }, + }, + { + name: "Enabled, but ExportSpans returns error", + enabled: true, + callExportSpans: func(t *testing.T, exporter *stdouttrace.Exporter) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := exporter.ExportSpans(ctx, tracetest.SpanStubs{ + {Name: "/foo"}, + {Name: "/bar"}, + }.Snapshots()) + require.Error(t, err) + }, + assertMetrics: func(t *testing.T, rm metricdata.ResourceMetrics) { + t.Helper() + require.Len(t, rm.ScopeMetrics, 1) + + sm := rm.ScopeMetrics[0] + require.Len(t, sm.Metrics, 3) + + assert.Equal(t, instrumentation.Scope{ + Name: "go.opentelemetry.io/otel/exporters/stdout/stdouttrace", + Version: sdk.Version(), + SchemaURL: semconv.SchemaURL, + }, sm.Scope) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: otelconv.SDKExporterSpanInflight{}.Name(), + Description: otelconv.SDKExporterSpanInflight{}.Description(), + Unit: otelconv.SDKExporterSpanInflight{}.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: attribute.NewSet( + semconv.OTelComponentName("stdout_trace_exporter/1"), + semconv.OTelComponentTypeKey.String("stdout_trace_exporter"), + ), + Value: 0, + }, + }, + }, + }, sm.Metrics[0], metricdatatest.IgnoreTimestamp()) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: otelconv.SDKExporterSpanExported{}.Name(), + Description: otelconv.SDKExporterSpanExported{}.Description(), + Unit: otelconv.SDKExporterSpanExported{}.Unit(), + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: attribute.NewSet( + semconv.OTelComponentName("stdout_trace_exporter/1"), + semconv.OTelComponentTypeKey.String("stdout_trace_exporter"), + semconv.ErrorType(context.Canceled), + ), + Value: 2, + }, + }, + }, + }, sm.Metrics[1], metricdatatest.IgnoreTimestamp()) + + metricdatatest.AssertEqual(t, metricdata.Metrics{ + Name: otelconv.SDKExporterOperationDuration{}.Name(), + Description: otelconv.SDKExporterOperationDuration{}.Description(), + Unit: otelconv.SDKExporterOperationDuration{}.Unit(), + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Attributes: attribute.NewSet( + semconv.OTelComponentName("stdout_trace_exporter/1"), + semconv.OTelComponentTypeKey.String("stdout_trace_exporter"), + semconv.ErrorType(context.Canceled), + ), + }, + }, + }, + }, sm.Metrics[2], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.enabled { + t.Setenv("OTEL_GO_X_SELF_OBSERVABILITY", "true") + } + + original := otel.GetMeterProvider() + defer otel.SetMeterProvider(original) + + r := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(r)) + otel.SetMeterProvider(mp) + + exporter, err := stdouttrace.New( + stdouttrace.WithWriter(io.Discard)) + require.NoError(t, err) + + tt.callExportSpans(t, exporter) + + var rm metricdata.ResourceMetrics + require.NoError(t, r.Collect(context.Background(), &rm)) + + tt.assertMetrics(t, rm) + }) + } +}