diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c3680cdd5..75798dfaa49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,17 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `"go.opentelemetry.io/contrib/samplers/jaegerremote".WithSamplingStrategyFetcher` which sets custom fetcher implementation. (#4045) - Add `"go.opentelemetry.io/contrib/config"` package that includes configuration models generated via go-jsonschema. (#4376) - Add `NewSDK` function to `"go.opentelemetry.io/contrib/config"`. The initial implementation only returns noop providers. (#4414) +- Add metrics support to `go.opentelemetry.io/contrib/exporters/autoexport`. (#4229) ### Changed - Dropped compatibility testing for [Go 1.19]. The project no longer guarantees support for this version of Go. (#4352) +### Deprecated + +- In `go.opentelemetry.io/contrib/exporters/autoexport`, `Option` was renamed to `SpanOption`. The old name is deprecated but continues to be supported as an alias. (#4229) + ### Fixed - The `go.opentelemetry.io/contrib/samplers/jaegerremote` sampler does not panic when the default HTTP round-tripper (`http.DefaultTransport`) is not `*http.Transport`. (#4045) diff --git a/exporters/autoexport/exporter_test.go b/exporters/autoexport/exporter_test.go deleted file mode 100644 index 1e39d363d6e..00000000000 --- a/exporters/autoexport/exporter_test.go +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright The OpenTelemetry Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package autoexport - -import ( - "context" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - - "go.opentelemetry.io/otel/exporters/otlp/otlptrace" - "go.opentelemetry.io/otel/sdk/trace" -) - -func TestOTLPExporterReturnedWhenNoEnvOrFallbackExporterConfigured(t *testing.T) { - exporter, err := NewSpanExporter(context.Background()) - assert.NoError(t, err) - assertOTLPHTTPExporter(t, exporter) -} - -func TestFallbackExporterReturnedWhenNoEnvExporterConfigured(t *testing.T) { - testExporter := &testExporter{} - exporter, err := NewSpanExporter( - context.Background(), - WithFallbackSpanExporter(testExporter), - ) - assert.NoError(t, err) - assert.Equal(t, testExporter, exporter) - assert.False(t, IsNoneSpanExporter(exporter)) -} - -func TestEnvExporterIsPreferredOverFallbackExporter(t *testing.T) { - t.Setenv("OTEL_TRACES_EXPORTER", "otlp") - - testExporter := &testExporter{} - exporter, err := NewSpanExporter( - context.Background(), - WithFallbackSpanExporter(testExporter), - ) - assert.NoError(t, err) - assertOTLPHTTPExporter(t, exporter) -} - -func TestEnvExporterOTLPOverHTTP(t *testing.T) { - t.Setenv("OTEL_TRACES_EXPORTER", "otlp") - t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") - - exporter, err := NewSpanExporter(context.Background()) - assert.NoError(t, err) - assertOTLPHTTPExporter(t, exporter) -} - -func TestEnvExporterOTLPOverGRPC(t *testing.T) { - t.Setenv("OTEL_TRACES_EXPORTER", "otlp") - t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") - - exporter, err := NewSpanExporter(context.Background()) - assert.NoError(t, err) - assertOTLPGRPCExporter(t, exporter) -} - -func TestEnvExporterOTLPOverGRPCOnlyProtocol(t *testing.T) { - t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") - - exporter, err := NewSpanExporter(context.Background()) - assert.NoError(t, err) - assertOTLPGRPCExporter(t, exporter) -} - -func TestEnvExporterOTLPInvalidProtocol(t *testing.T) { - t.Setenv("OTEL_TRACES_EXPORTER", "otlp") - t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "invalid") - - exporter, err := NewSpanExporter(context.Background()) - assert.Error(t, err) - assert.Nil(t, exporter) -} - -func TestEnvExporterNone(t *testing.T) { - t.Setenv("OTEL_TRACES_EXPORTER", "none") - - exporter, err := NewSpanExporter(context.Background()) - assert.NoError(t, err) - assert.True(t, IsNoneSpanExporter(exporter)) -} - -func assertOTLPHTTPExporter(t *testing.T, got trace.SpanExporter) { - t.Helper() - - if !assert.IsType(t, &otlptrace.Exporter{}, got) { - return - } - - // Implementation detail hack. This may break when bumping OTLP exporter modules as it uses unexported API. - clientType := reflect.Indirect(reflect.ValueOf(got)).FieldByName("client").Elem().Type().String() - assert.Equal(t, "*otlptracehttp.client", clientType) - - assert.False(t, IsNoneSpanExporter(got)) -} - -func assertOTLPGRPCExporter(t *testing.T, got trace.SpanExporter) { - t.Helper() - - if !assert.IsType(t, &otlptrace.Exporter{}, got) { - return - } - - // Implementation detail hack. This may break when bumping OTLP exporter modules as it uses unexported API. - clientType := reflect.Indirect(reflect.ValueOf(got)).FieldByName("client").Elem().Type().String() - assert.Equal(t, "*otlptracegrpc.client", clientType) - - assert.False(t, IsNoneSpanExporter(got)) -} - -type testExporter struct{} - -func (e *testExporter) ExportSpans(ctx context.Context, ss []trace.ReadOnlySpan) error { - return nil -} - -func (e *testExporter) Shutdown(ctx context.Context) error { - return nil -} diff --git a/exporters/autoexport/go.mod b/exporters/autoexport/go.mod index 2eb71b70de9..7be8dda7d04 100644 --- a/exporters/autoexport/go.mod +++ b/exporters/autoexport/go.mod @@ -4,11 +4,13 @@ go 1.20 require ( github.com/stretchr/testify v1.8.4 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.42.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.19.0 go.opentelemetry.io/otel/sdk v1.19.0 + go.opentelemetry.io/otel/sdk/metric v1.19.0 ) require ( @@ -20,6 +22,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.42.0 // indirect go.opentelemetry.io/otel/metric v1.19.0 // indirect go.opentelemetry.io/otel/trace v1.19.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect diff --git a/exporters/autoexport/go.sum b/exporters/autoexport/go.sum index 4e20bb18d77..07750ed6914 100644 --- a/exporters/autoexport/go.sum +++ b/exporters/autoexport/go.sum @@ -24,18 +24,24 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.42.0 h1:ZtfnDL+tUrs1F0Pzfwbg2d59Gru9NCH3bgSHBM6LDwU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.42.0/go.mod h1:hG4Fj/y8TR/tlEDREo8tWstl9fO9gcFkn4xrx0Io8xU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.42.0 h1:NmnYCiR0qNufkldjVvyQfZTHSdzeHoZ41zggMsdMcLM= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.42.0/go.mod h1:UVAO61+umUsHLtYb8KXXRoHtxUkdOPkYidzW3gipRLQ= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 h1:wNMDy/LVGLj2h3p6zg4d0gypKfWKSWI14E1C4smOgl8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0/go.mod h1:YfbDdXAAkemWJK3H/DshvlrxqFB2rtW4rY6ky/3x/H0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.19.0 h1:Nw7Dv4lwvGrI68+wULbcq7su9K2cebeCUrDjVrUJHxM= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.19.0/go.mod h1:1MsF6Y7gTqosgoZvHlzcaaM8DIMNZgJh87ykokoNH7Y= go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/sdk/metric v1.19.0 h1:EJoTO5qysMsYCa+w4UghwFV/ptQgqSL/8Ni+hx+8i1k= +go.opentelemetry.io/otel/sdk/metric v1.19.0/go.mod h1:XjG0jQyFJrv2PbMvwND7LwCEhsJzCzV5210euduKcKY= go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= diff --git a/exporters/autoexport/metrics.go b/exporters/autoexport/metrics.go new file mode 100644 index 00000000000..f3d44d783d1 --- /dev/null +++ b/exporters/autoexport/metrics.go @@ -0,0 +1,97 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport" + +import ( + "context" + "os" + + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/sdk/metric" +) + +// MetricOption applies an autoexport configuration option. +type MetricOption = option[metric.Reader] + +// WithFallbackMetricReader sets the fallback exporter to use when no exporter +// is configured through the OTEL_METRICS_EXPORTER environment variable. +func WithFallbackMetricReader(exporter metric.Reader) MetricOption { + return withFallback[metric.Reader](exporter) +} + +// NewMetricReader returns a configured [go.opentelemetry.io/otel/sdk/metric.Reader] +// defined using the environment variables described below. +// +// OTEL_METRICS_EXPORTER defines the metrics exporter; supported values: +// - "none" - "no operation" exporter +// - "otlp" (default) - OTLP exporter; see [go.opentelemetry.io/otel/exporters/otlp/otlpmetric] +// +// OTEL_EXPORTER_OTLP_PROTOCOL defines OTLP exporter's transport protocol; +// supported values: +// - "grpc" - protobuf-encoded data using gRPC wire format over HTTP/2 connection; +// see: [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc] +// - "http/protobuf" (default) - protobuf-encoded data over HTTP connection; +// see: [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp] +// +// An error is returned if an environment value is set to an unhandled value. +// +// Use [RegisterMetricReader] to handle more values of OTEL_METRICS_EXPORTER. +// +// Use [WithFallbackMetricReader] option to change the returned exporter +// when OTEL_TRACES_EXPORTER is unset or empty. +// +// Use [IsNoneMetricReader] to check if the retured exporter is a "no operation" exporter. +func NewMetricReader(ctx context.Context, opts ...MetricOption) (metric.Reader, error) { + return metricsSignal.create(ctx, opts...) +} + +// RegisterMetricReader sets the MetricReader factory to be used when the +// OTEL_METRICS_EXPORTERS environment variable contains the exporter name. This +// will panic if name has already been registered. +func RegisterMetricReader(name string, factory func(context.Context) (metric.Reader, error)) { + must(metricsSignal.registry.store(name, factory)) +} + +var metricsSignal = newSignal[metric.Reader]("OTEL_METRICS_EXPORTER") + +func init() { + RegisterMetricReader("otlp", func(ctx context.Context) (metric.Reader, error) { + proto := os.Getenv(otelExporterOTLPProtoEnvKey) + if proto == "" { + proto = "http/protobuf" + } + + switch proto { + case "grpc": + r, err := otlpmetricgrpc.New(ctx) + if err != nil { + return nil, err + } + return metric.NewPeriodicReader(r), nil + case "http/protobuf": + r, err := otlpmetrichttp.New(ctx) + if err != nil { + return nil, err + } + return metric.NewPeriodicReader(r), nil + default: + return nil, errInvalidOTLPProtocol + } + }) + RegisterMetricReader("none", func(ctx context.Context) (metric.Reader, error) { + return newNoopMetricReader(), nil + }) +} diff --git a/exporters/autoexport/metrics_test.go b/exporters/autoexport/metrics_test.go new file mode 100644 index 00000000000..4a3ecff2f68 --- /dev/null +++ b/exporters/autoexport/metrics_test.go @@ -0,0 +1,65 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport" + +import ( + "context" + "fmt" + "reflect" + "testing" + + "go.opentelemetry.io/otel/sdk/metric" + + "github.com/stretchr/testify/assert" +) + +func TestMetricExporterNone(t *testing.T) { + t.Setenv("OTEL_METRICS_EXPORTER", "none") + got, err := NewMetricReader(context.Background()) + assert.NoError(t, err) + assert.True(t, IsNoneMetricReader(got)) +} + +func TestMetricExporterOTLP(t *testing.T) { + t.Setenv("OTEL_METRICS_EXPORTER", "otlp") + + for _, tc := range []struct { + protocol, exporterType string + }{ + {"http/protobuf", "*otlpmetrichttp.Exporter"}, + {"", "*otlpmetrichttp.Exporter"}, + {"grpc", "*otlpmetricgrpc.Exporter"}, + } { + t.Run(fmt.Sprintf("protocol=%q", tc.protocol), func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", tc.protocol) + + got, err := NewMetricReader(context.Background()) + assert.NoError(t, err) + assert.IsType(t, &metric.PeriodicReader{}, got) + + // Implementation detail hack. This may break when bumping OTLP exporter modules as it uses unexported API. + exporterType := reflect.Indirect(reflect.ValueOf(got)).FieldByName("exporter").Elem().Type() + assert.Equal(t, tc.exporterType, exporterType.String()) + }) + } +} + +func TestMetricExporterOTLPOverInvalidProtocol(t *testing.T) { + t.Setenv("OTEL_METRICS_EXPORTER", "otlp") + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "invalid-protocol") + + _, err := NewMetricReader(context.Background()) + assert.Error(t, err) +} diff --git a/exporters/autoexport/noop.go b/exporters/autoexport/noop.go index 95797692a01..31df837aba6 100644 --- a/exporters/autoexport/noop.go +++ b/exporters/autoexport/noop.go @@ -17,27 +17,43 @@ package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport" import ( "context" + "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/trace" ) -// noop is an implementation of trace.SpanExporter that performs no operations. -type noop struct{} +// noopSpanExporter is an implementation of trace.SpanExporter that performs no operations. +type noopSpanExporter struct{} -var _ trace.SpanExporter = noop{} +var _ trace.SpanExporter = noopSpanExporter{} // ExportSpans is part of trace.SpanExporter interface. -func (e noop) ExportSpans(ctx context.Context, spans []trace.ReadOnlySpan) error { +func (e noopSpanExporter) ExportSpans(ctx context.Context, spans []trace.ReadOnlySpan) error { return nil } // Shutdown is part of trace.SpanExporter interface. -func (e noop) Shutdown(ctx context.Context) error { +func (e noopSpanExporter) Shutdown(ctx context.Context) error { return nil } // IsNoneSpanExporter returns true for the exporter returned by [NewSpanExporter] // when OTEL_TRACES_EXPORTER environment variable is set to "none". func IsNoneSpanExporter(e trace.SpanExporter) bool { - _, ok := e.(noop) + _, ok := e.(noopSpanExporter) + return ok +} + +type noopMetricReader struct { + *metric.ManualReader +} + +func newNoopMetricReader() noopMetricReader { + return noopMetricReader{metric.NewManualReader()} +} + +// IsNoneMetricReader returns true for the exporter returned by [NewMetricReader] +// when OTEL_METRICS_EXPORTER environment variable is set to "none". +func IsNoneMetricReader(e metric.Reader) bool { + _, ok := e.(noopMetricReader) return ok } diff --git a/exporters/autoexport/registry.go b/exporters/autoexport/registry.go index 9eae81e76f8..03eb63d11a3 100644 --- a/exporters/autoexport/registry.go +++ b/exporters/autoexport/registry.go @@ -18,41 +18,20 @@ import ( "context" "errors" "fmt" - "os" "sync" - - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/otel/sdk/trace" ) -const ( - otelExporterOTLPProtoEnvKey = "OTEL_EXPORTER_OTLP_PROTOCOL" -) +const otelExporterOTLPProtoEnvKey = "OTEL_EXPORTER_OTLP_PROTOCOL" -// registry maintains a map of exporter names to SpanExporter factories -// func(context.Context) (trace.SpanExporter, error) that is safe for concurrent use by multiple +// registry maintains a map of exporter names to exporter factories +// func(context.Context) (T, error) that is safe for concurrent use by multiple // goroutines without additional locking or coordination. -type registry struct { +type registry[T any] struct { mu sync.Mutex - names map[string]func(context.Context) (trace.SpanExporter, error) -} - -func newRegistry() registry { - return registry{ - names: map[string]func(context.Context) (trace.SpanExporter, error){ - "": buildOTLPExporter, - "otlp": buildOTLPExporter, - "none": func(ctx context.Context) (trace.SpanExporter, error) { return noop{}, nil }, - }, - } + names map[string]func(context.Context) (T, error) } var ( - // envRegistry is the package level registry of exporter registrations - // and their mapping to a SpanExporter factory func(context.Context) (trace.SpanExporter, error). - envRegistry = newRegistry() - // errUnknownExporter is returned when an unknown exporter name is used in // the OTEL_*_EXPORTER environment variables. errUnknownExporter = errors.New("unknown exporter") @@ -65,23 +44,24 @@ var ( errDuplicateRegistration = errors.New("duplicate registration") ) -// load returns tries to find the SpanExporter factory with the key and +// load returns tries to find the exporter factory with the key and // then execute the factory, returning the created SpanExporter. // errUnknownExporter is returned if the registration is missing and the error from // executing the factory if not nil. -func (r *registry) load(ctx context.Context, key string) (trace.SpanExporter, error) { +func (r *registry[T]) load(ctx context.Context, key string) (T, error) { r.mu.Lock() defer r.mu.Unlock() factory, ok := r.names[key] if !ok { - return nil, errUnknownExporter + var zero T + return zero, errUnknownExporter } return factory(ctx) } // store sets the factory for a key if is not already in the registry. errDuplicateRegistration // is returned if the registry already contains key. -func (r *registry) store(key string, factory func(context.Context) (trace.SpanExporter, error)) error { +func (r *registry[T]) store(key string, factory func(context.Context) (T, error)) error { r.mu.Lock() defer r.mu.Unlock() if _, ok := r.names[key]; ok { @@ -91,59 +71,8 @@ func (r *registry) store(key string, factory func(context.Context) (trace.SpanEx return nil } -// drop removes key from the registry if it exists, otherwise nothing. -func (r *registry) drop(key string) { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.names, key) -} - -// RegisterSpanExporter sets the SpanExporter factory to be used when the -// OTEL_TRACES_EXPORTERS environment variable contains the exporter name. This -// will panic if name has already been registered. -func RegisterSpanExporter(name string, factory func(context.Context) (trace.SpanExporter, error)) { - if err := envRegistry.store(name, factory); err != nil { - // envRegistry.store will return errDuplicateRegistration if name is already - // registered. Panic here so the user is made aware of the duplicate - // registration, which could be done by malicious code trying to - // intercept cross-cutting concerns. - // - // Panic for all other errors as well. At this point there should not - // be any other errors returned from the store operation. If there - // are, alert the developer that adding them as soon as possible that - // they need to be handled here. - panic(err) - } -} - -// spanExporter returns a span exporter using the passed in name -// from the list of registered SpanExporters. Each name must match an -// already registered SpanExporter. A default OTLP exporter is registered -// under both an empty string "" and "otlp". -// An error is returned for any unknown exporters. -func spanExporter(ctx context.Context, name string) (trace.SpanExporter, error) { - exp, err := envRegistry.load(ctx, name) +func must(err error) { if err != nil { - return nil, err - } - return exp, nil -} - -// buildOTLPExporter creates an OTLP exporter using the environment variable -// OTEL_EXPORTER_OTLP_PROTOCOL to determine the exporter protocol. -// Defaults to http/protobuf protocol. -func buildOTLPExporter(ctx context.Context) (trace.SpanExporter, error) { - proto := os.Getenv(otelExporterOTLPProtoEnvKey) - if proto == "" { - proto = "http/protobuf" - } - - switch proto { - case "grpc": - return otlptracegrpc.New(ctx) - case "http/protobuf": - return otlptracehttp.New(ctx) - default: - return nil, errInvalidOTLPProtocol + panic(err) } } diff --git a/exporters/autoexport/registry_test.go b/exporters/autoexport/registry_test.go index 72808cee65f..c1b633aed3b 100644 --- a/exporters/autoexport/registry_test.go +++ b/exporters/autoexport/registry_test.go @@ -16,112 +16,89 @@ package autoexport import ( "context" + "errors" "fmt" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" - "go.opentelemetry.io/otel/sdk/trace" ) -var stdoutFactory = func(ctx context.Context) (trace.SpanExporter, error) { - exp, err := stdouttrace.New() - if err != nil { - return nil, err +type testType struct{ string } + +func factory(val string) func(ctx context.Context) (*testType, error) { + return func(ctx context.Context) (*testType, error) { return &testType{val}, nil } +} + +func newTestRegistry() registry[*testType] { + return registry[*testType]{ + names: make(map[string]func(context.Context) (*testType, error)), } - return exp, nil } func TestCanStoreExporterFactory(t *testing.T) { - r := newRegistry() - assert.NotPanics(t, func() { - require.NoError(t, r.store("first", stdoutFactory)) - }) + r := newTestRegistry() + require.NoError(t, r.store("first", factory("first"))) } func TestLoadOfUnknownExporterReturnsError(t *testing.T) { - r := newRegistry() - assert.NotPanics(t, func() { - exp, err := r.load(context.Background(), "non-existent") - assert.Equal(t, err, errUnknownExporter, "empty registry should hold nothing") - assert.Nil(t, exp, "non-nil exporter returned") - }) + r := newTestRegistry() + exp, err := r.load(context.Background(), "non-existent") + assert.Equal(t, err, errUnknownExporter, "empty registry should hold nothing") + assert.Nil(t, exp, "non-nil exporter returned") } func TestRegistryIsConcurrentSafe(t *testing.T) { const exporterName = "stdout" - r := newRegistry() - assert.NotPanics(t, func() { - require.NoError(t, r.store(exporterName, stdoutFactory)) - }) + r := newTestRegistry() + require.NoError(t, r.store(exporterName, factory("stdout"))) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() - assert.NotPanics(t, func() { - require.ErrorIs(t, r.store(exporterName, stdoutFactory), errDuplicateRegistration) - }) + require.ErrorIs(t, r.store(exporterName, factory("stdout")), errDuplicateRegistration) }() wg.Add(1) go func() { defer wg.Done() - assert.NotPanics(t, func() { - exp, err := r.load(context.Background(), exporterName) - assert.NoError(t, err, "missing exporter in registry") - assert.IsType(t, &stdouttrace.Exporter{}, exp) - }) + _, err := r.load(context.Background(), exporterName) + assert.NoError(t, err, "missing exporter in registry") }() wg.Wait() } func TestSubsequentCallsToGetExporterReturnsNewInstances(t *testing.T) { - const exporterType = "otlp" - exp1, err := spanExporter(context.Background(), exporterType) + r := newTestRegistry() + + const key = "key" + assert.NoError(t, r.store(key, factory(key))) + + exp1, err := r.load(context.Background(), key) assert.NoError(t, err) - assertOTLPHTTPExporter(t, exp1) - exp2, err := spanExporter(context.Background(), exporterType) + exp2, err := r.load(context.Background(), key) assert.NoError(t, err) - assertOTLPHTTPExporter(t, exp2) assert.NotSame(t, exp1, exp2) } -func TestDefaultOTLPExporterFactoriesAreAutomaticallyRegistered(t *testing.T) { - exp1, err := spanExporter(context.Background(), "") - assert.Nil(t, err) - assertOTLPHTTPExporter(t, exp1) +func TestRegistryErrorsOnDuplicateRegisterCalls(t *testing.T) { + r := newTestRegistry() - exp2, err := spanExporter(context.Background(), "otlp") - assert.Nil(t, err) - assertOTLPHTTPExporter(t, exp2) -} - -func TestEnvRegistryCanRegisterExporterFactory(t *testing.T) { const exporterName = "custom" - RegisterSpanExporter(exporterName, stdoutFactory) - t.Cleanup(func() { envRegistry.drop(exporterName) }) + assert.NoError(t, r.store(exporterName, factory(exporterName))) - exp, err := envRegistry.load(context.Background(), exporterName) - assert.Nil(t, err, "missing exporter in envRegistry") - assert.IsType(t, &stdouttrace.Exporter{}, exp) + errString := fmt.Sprintf("%s: %q", errDuplicateRegistration, exporterName) + assert.ErrorContains(t, r.store(exporterName, factory(exporterName)), errString) } -func TestEnvRegistryPanicsOnDuplicateRegisterCalls(t *testing.T) { - const exporterName = "custom" - RegisterSpanExporter(exporterName, stdoutFactory) - t.Cleanup(func() { envRegistry.drop(exporterName) }) - - errString := fmt.Sprintf("%s: %q", errDuplicateRegistration, exporterName) - assert.PanicsWithError(t, errString, func() { - RegisterSpanExporter(exporterName, stdoutFactory) - }) +func TestMust(t *testing.T) { + assert.Panics(t, func() { must(errors.New("test")) }) + assert.NotPanics(t, func() { must(nil) }) } diff --git a/exporters/autoexport/signal.go b/exporters/autoexport/signal.go new file mode 100644 index 00000000000..1103a65ae66 --- /dev/null +++ b/exporters/autoexport/signal.go @@ -0,0 +1,74 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport" + +import ( + "context" + "os" +) + +type signal[T any] struct { + envKey string + registry *registry[T] +} + +func newSignal[T any](envKey string) signal[T] { + return signal[T]{ + envKey: envKey, + registry: ®istry[T]{ + names: make(map[string]func(context.Context) (T, error)), + }, + } +} + +func (s signal[T]) create(ctx context.Context, opts ...option[T]) (T, error) { + var cfg config[T] + for _, opt := range opts { + opt.apply(&cfg) + } + + expType := os.Getenv(s.envKey) + if expType == "" { + if cfg.hasFallback { + return cfg.fallback, nil + } + expType = "otlp" + } + + return s.registry.load(ctx, expType) +} + +type config[T any] struct { + hasFallback bool + fallback T +} + +type option[T any] interface { + apply(cfg *config[T]) +} + +type optionFunc[T any] func(cfg *config[T]) + +//lint:ignore U1000 https://github.com/dominikh/go-tools/issues/1440 +func (fn optionFunc[T]) apply(cfg *config[T]) { + fn(cfg) +} + +func withFallback[T any](fallback T) option[T] { + return optionFunc[T](func(cfg *config[T]) { + cfg.hasFallback = true + cfg.fallback = fallback + }) +} diff --git a/exporters/autoexport/signal_test.go b/exporters/autoexport/signal_test.go new file mode 100644 index 00000000000..10bb6e62e96 --- /dev/null +++ b/exporters/autoexport/signal_test.go @@ -0,0 +1,52 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport" + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOTLPExporterReturnedWhenNoEnvOrFallbackExporterConfigured(t *testing.T) { + ts := newSignal[*testType]("TEST_TYPE_KEY") + assert.NoError(t, ts.registry.store("otlp", factory("test-otlp-exporter"))) + exp, err := ts.create(context.Background()) + assert.NoError(t, err) + assert.Equal(t, exp.string, "test-otlp-exporter") +} + +func TestFallbackExporterReturnedWhenNoEnvExporterConfigured(t *testing.T) { + ts := newSignal[*testType]("TEST_TYPE_KEY") + fallback := testType{"test-fallback-exporter"} + exp, err := ts.create(context.Background(), withFallback(&fallback)) + assert.NoError(t, err) + assert.Same(t, &fallback, exp) +} + +func TestEnvExporterIsPreferredOverFallbackExporter(t *testing.T) { + envVariable := "TEST_TYPE_KEY" + ts := newSignal[*testType](envVariable) + + expName := "test-env-exporter-name" + t.Setenv(envVariable, expName) + fallback := testType{"test-fallback-exporter"} + assert.NoError(t, ts.registry.store(expName, factory("test-env-exporter"))) + + exp, err := ts.create(context.Background(), withFallback(&fallback)) + assert.NoError(t, err) + assert.Equal(t, exp.string, "test-env-exporter") +} diff --git a/exporters/autoexport/exporter.go b/exporters/autoexport/spans.go similarity index 56% rename from exporters/autoexport/exporter.go rename to exporters/autoexport/spans.go index c7fbcfcac62..e7de52e28a7 100644 --- a/exporters/autoexport/exporter.go +++ b/exporters/autoexport/spans.go @@ -18,52 +18,23 @@ import ( "context" "os" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/sdk/trace" ) -const ( - otelTracesExportersEnvKey = "OTEL_TRACES_EXPORTER" -) - -type config struct { - fallbackExporter trace.SpanExporter -} - -func newConfig(ctx context.Context, opts ...Option) (config, error) { - cfg := config{} - for _, opt := range opts { - cfg = opt.apply(cfg) - } - - // if no fallback exporter is configured, use otlp exporter - if cfg.fallbackExporter == nil { - exp, err := spanExporter(context.Background(), "otlp") - if err != nil { - return cfg, err - } - cfg.fallbackExporter = exp - } - return cfg, nil -} +// SpanOption applies an autoexport configuration option. +type SpanOption = option[trace.SpanExporter] // Option applies an autoexport configuration option. -type Option interface { - apply(config) config -} - -type optionFunc func(config) config - -func (fn optionFunc) apply(cfg config) config { - return fn(cfg) -} +// +// Deprecated: Use SpanOption. +type Option = SpanOption // WithFallbackSpanExporter sets the fallback exporter to use when no exporter // is configured through the OTEL_TRACES_EXPORTER environment variable. -func WithFallbackSpanExporter(exporter trace.SpanExporter) Option { - return optionFunc(func(cfg config) config { - cfg.fallbackExporter = exporter - return cfg - }) +func WithFallbackSpanExporter(exporter trace.SpanExporter) SpanOption { + return withFallback[trace.SpanExporter](exporter) } // NewSpanExporter returns a configured [go.opentelemetry.io/otel/sdk/trace.SpanExporter] @@ -88,30 +59,36 @@ func WithFallbackSpanExporter(exporter trace.SpanExporter) Option { // when OTEL_TRACES_EXPORTER is unset or empty. // // Use [IsNoneSpanExporter] to check if the retured exporter is a "no operation" exporter. -func NewSpanExporter(ctx context.Context, opts ...Option) (trace.SpanExporter, error) { - // prefer exporter configured via environment variables over exporter - // passed in via exporter parameter - envExporter, err := makeExporterFromEnv(ctx) - if err != nil { - return nil, err - } - if envExporter != nil { - return envExporter, nil - } - config, err := newConfig(ctx, opts...) - if err != nil { - return nil, err - } - return config.fallbackExporter, nil +func NewSpanExporter(ctx context.Context, opts ...SpanOption) (trace.SpanExporter, error) { + return tracesSignal.create(ctx, opts...) } -// makeExporterFromEnv returns a configured SpanExporter defined by the OTEL_TRACES_EXPORTER -// environment variable. -// nil is returned if no exporter is defined for the environment variable. -func makeExporterFromEnv(ctx context.Context) (trace.SpanExporter, error) { - expType := os.Getenv(otelTracesExportersEnvKey) - if expType == "" { - return nil, nil - } - return spanExporter(ctx, expType) +// RegisterSpanExporter sets the SpanExporter factory to be used when the +// OTEL_TRACES_EXPORTERS environment variable contains the exporter name. This +// will panic if name has already been registered. +func RegisterSpanExporter(name string, factory func(context.Context) (trace.SpanExporter, error)) { + must(tracesSignal.registry.store(name, factory)) +} + +var tracesSignal = newSignal[trace.SpanExporter]("OTEL_TRACES_EXPORTER") + +func init() { + RegisterSpanExporter("otlp", func(ctx context.Context) (trace.SpanExporter, error) { + proto := os.Getenv(otelExporterOTLPProtoEnvKey) + if proto == "" { + proto = "http/protobuf" + } + + switch proto { + case "grpc": + return otlptracegrpc.New(ctx) + case "http/protobuf": + return otlptracehttp.New(ctx) + default: + return nil, errInvalidOTLPProtocol + } + }) + RegisterSpanExporter("none", func(ctx context.Context) (trace.SpanExporter, error) { + return noopSpanExporter{}, nil + }) } diff --git a/exporters/autoexport/spans_test.go b/exporters/autoexport/spans_test.go new file mode 100644 index 00000000000..35ec975b301 --- /dev/null +++ b/exporters/autoexport/spans_test.go @@ -0,0 +1,65 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport" + +import ( + "context" + "fmt" + "reflect" + "testing" + + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + + "github.com/stretchr/testify/assert" +) + +func TestSpanExporterNone(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "none") + got, err := NewSpanExporter(context.Background()) + assert.NoError(t, err) + assert.True(t, IsNoneSpanExporter(got)) +} + +func TestSpanExporterOTLP(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "otlp") + + for _, tc := range []struct { + protocol, clientType string + }{ + {"http/protobuf", "*otlptracehttp.client"}, + {"", "*otlptracehttp.client"}, + {"grpc", "*otlptracegrpc.client"}, + } { + t.Run(fmt.Sprintf("protocol=%q", tc.protocol), func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", tc.protocol) + + got, err := NewSpanExporter(context.Background()) + assert.NoError(t, err) + assert.IsType(t, &otlptrace.Exporter{}, got) + + // Implementation detail hack. This may break when bumping OTLP exporter modules as it uses unexported API. + clientType := reflect.Indirect(reflect.ValueOf(got)).FieldByName("client").Elem().Type() + assert.Equal(t, tc.clientType, clientType.String()) + }) + } +} + +func TestSpanExporterOTLPOverInvalidProtocol(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "otlp") + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "invalid-protocol") + + _, err := NewSpanExporter(context.Background()) + assert.Error(t, err) +}