Repository: open-telemetry/opentelemetry-go-contrib (go)

Issue:
autoexport: Add metrics support
Make sure to support `OTEL_METRICS_EXPORTER` and handle `none`, `otlp`, ~~`prometheus`~~.

Reference https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/configuration/sdk-environment-variables.md#exporter-selection

Pull request:
autoexport: Add support for metrics
Fixes #4131.

Reorganizes autoexport into

*   generic registry and env lookup independent of signal type
*   metric- and trace-specific wrappers around the generic part, to preserve a simple API
*   pre-defined metric and trace factories

Also reorganizes tests to line up with these layers.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6c3680cd..75798dfaa 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/go.mod b/exporters/autoexport/go.mod
index 2eb71b70d..7be8dda7d 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 4e20bb18d..07750ed69 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 000000000..f3d44d783
--- /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/noop.go b/exporters/autoexport/noop.go
index 95797692a..31df837ab 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 9eae81e76..03eb63d11 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/signal.go b/exporters/autoexport/signal.go
new file mode 100644
index 000000000..1103a65ae
--- /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: &registry[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/exporter.go b/exporters/autoexport/spans.go
similarity index 56%
rename from exporters/autoexport/exporter.go
rename to exporters/autoexport/spans.go
index c7fbcfcac..e7de52e28 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
+	})
 }


Test patch:
diff --git a/exporters/autoexport/exporter_test.go b/exporters/autoexport/exporter_test.go
deleted file mode 100644
index 1e39d363d..000000000
--- 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/metrics_test.go b/exporters/autoexport/metrics_test.go
new file mode 100644
index 000000000..4a3ecff2f
--- /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/registry_test.go b/exporters/autoexport/registry_test.go
index 72808cee6..c1b633aed 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_test.go b/exporters/autoexport/signal_test.go
new file mode 100644
index 000000000..10bb6e62e
--- /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/spans_test.go b/exporters/autoexport/spans_test.go
new file mode 100644
index 000000000..35ec975b3
--- /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)
+}