1
0
mirror of https://github.com/open-telemetry/opentelemetry-go.git synced 2026-06-03 18:35:08 +02:00

feat(stdouttrace): add experimental self-observability metrics (#7133)

Fixes https://github.com/open-telemetry/opentelemetry-go/issues/7008
This commit is contained in:
Flc゛
2025-08-11 22:05:02 +08:00
committed by GitHub
parent 68841fa6db
commit 0a834865f6
8 changed files with 462 additions and 4 deletions
+2
View File
@@ -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
+3
View File
@@ -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"
+2 -1
View File
@@ -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
)
@@ -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.
@@ -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
}
@@ -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")
}
}
+81 -3
View File
@@ -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
}
+216
View File
@@ -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)
})
}
}