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

Issue:
autoexport: Add support for Prometheus
* Support `OTLP_METRICS_EXPORTER=prometheus`
* Serve a Prometheus metric endpoint based on the values of `OTEL_EXPORTER_PROMETHEUS_HOST` and `OTEL_EXPORTER_PROMETHEUS_PORT` [[spec](https://github.com/open-telemetry/opentelemetry-specification/blob/563958cb2bd8529990f19fdce7a5f3643bf63091/specification/configuration/sdk-environment-variables.md?plain=1#L201)]

This was partially implemented in https://github.com/open-telemetry/opentelemetry-go-contrib/pull/4229 but then removed in favor of a full implementation in a separate PR.

Previous [discussion](https://github.com/open-telemetry/opentelemetry-go-contrib/pull/4229#discussion_r1364353029) [discussion](https://github.com/open-telemetry/opentelemetry-go-contrib/pull/4229#discussion_r1367350098)

Pull request:
Add support for Prometheus exporter
Fixes #4472

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85047db84..cff94fe55 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,7 @@ 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)
+- Add metrics support (No-op, OTLP and Prometheus) to `go.opentelemetry.io/contrib/exporters/autoexport`. (#4229, #4479)
 
 ### Changed
 
diff --git a/exporters/autoexport/go.mod b/exporters/autoexport/go.mod
index ceb334780..e2a8b5628 100644
--- a/exporters/autoexport/go.mod
+++ b/exporters/autoexport/go.mod
@@ -3,25 +3,34 @@ module go.opentelemetry.io/contrib/exporters/autoexport
 go 1.20
 
 require (
+	github.com/prometheus/client_golang v1.16.0
 	github.com/stretchr/testify v1.8.4
+	go.opentelemetry.io/otel v1.19.0
 	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/prometheus v0.42.0
 	go.opentelemetry.io/otel/sdk v1.19.0
 	go.opentelemetry.io/otel/sdk/metric v1.19.0
+	go.uber.org/goleak v1.2.1
 )
 
 require (
+	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cenkalti/backoff/v4 v4.2.1 // indirect
+	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/go-logr/logr v1.3.0 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
+	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	go.opentelemetry.io/otel v1.19.0 // indirect
+	github.com/prometheus/client_model v0.4.0 // indirect
+	github.com/prometheus/common v0.42.0 // indirect
+	github.com/prometheus/procfs v0.10.1 // 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
diff --git a/exporters/autoexport/go.sum b/exporters/autoexport/go.sum
index 78c02b337..47b028876 100644
--- a/exporters/autoexport/go.sum
+++ b/exporters/autoexport/go.sum
@@ -1,5 +1,9 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
 github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -8,6 +12,7 @@ github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
@@ -17,8 +22,18 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rH
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
+github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
+github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
+github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
+github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
+github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
+github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
+github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg=
+github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -36,6 +51,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S2
 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/prometheus v0.42.0 h1:jwV9iQdvp38fxXi8ZC+lNpxjK16MRcZlpDYvbuO1FiA=
+go.opentelemetry.io/otel/exporters/prometheus v0.42.0/go.mod h1:f3bYiqNqhoPxkvI2LrXqQVC546K7BuRDL/kKuxkujhA=
 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=
@@ -47,8 +64,10 @@ go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmY
 go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
 go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
 go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
+go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
 golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
 golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
diff --git a/exporters/autoexport/metrics.go b/exporters/autoexport/metrics.go
index d43e81fa3..d8f260cad 100644
--- a/exporters/autoexport/metrics.go
+++ b/exporters/autoexport/metrics.go
@@ -16,10 +16,20 @@ package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport"
 
 import (
 	"context"
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
 	"os"
+	"time"
 
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+
+	"go.opentelemetry.io/otel"
 	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
 	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
+	promexporter "go.opentelemetry.io/otel/exporters/prometheus"
 	"go.opentelemetry.io/otel/sdk/metric"
 )
 
@@ -38,6 +48,7 @@ func WithFallbackMetricReader(exporter metric.Reader) MetricOption {
 // 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]
+//   - "prometheus" - Prometheus exporter + HTTP server; see [go.opentelemetry.io/otel/exporters/prometheus]
 //
 // OTEL_EXPORTER_OTLP_PROTOCOL defines OTLP exporter's transport protocol;
 // supported values:
@@ -46,6 +57,10 @@ func WithFallbackMetricReader(exporter metric.Reader) MetricOption {
 //   - "http/protobuf" (default) -  protobuf-encoded data over HTTP connection;
 //     see: [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp]
 //
+// OTEL_EXPORTER_PROMETHEUS_HOST (defaulting to "localhost") and
+// OTEL_EXPORTER_PROMETHEUS_PORT (defaulting to 9464) define the host and port for the
+// Prometheus exporter's HTTP server.
+//
 // An error is returned if an environment value is set to an unhandled value.
 //
 // Use [RegisterMetricReader] to handle more values of OTEL_METRICS_EXPORTER.
@@ -94,4 +109,66 @@ func init() {
 	RegisterMetricReader("none", func(ctx context.Context) (metric.Reader, error) {
 		return newNoopMetricReader(), nil
 	})
+	RegisterMetricReader("prometheus", func(ctx context.Context) (metric.Reader, error) {
+		// create an isolated registry instead of using the global registry --
+		// the user might not want to mix OTel with non-OTel metrics
+		reg := prometheus.NewRegistry()
+
+		reader, err := promexporter.New(promexporter.WithRegisterer(reg))
+		if err != nil {
+			return nil, err
+		}
+
+		mux := http.NewServeMux()
+		mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}))
+		server := http.Server{
+			// Timeouts are necessary to make a server resilent to attacks, but ListenAndServe doesn't set any.
+			// We use values from this example: https://blog.cloudflare.com/exposing-go-on-the-internet/#:~:text=There%20are%20three%20main%20timeouts
+			ReadTimeout:  5 * time.Second,
+			WriteTimeout: 10 * time.Second,
+			IdleTimeout:  120 * time.Second,
+			Handler:      mux,
+		}
+
+		// environment variable names and defaults specified at https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#prometheus-exporter
+		host := getenv("OTEL_EXPORTER_PROMETHEUS_HOST", "localhost")
+		port := getenv("OTEL_EXPORTER_PROMETHEUS_PORT", "9464")
+		addr := host + ":" + port
+		lis, err := net.Listen("tcp", addr)
+		if err != nil {
+			return nil, errors.Join(
+				fmt.Errorf("binding address %s for Prometheus exporter: %w", addr, err),
+				reader.Shutdown(ctx),
+			)
+		}
+
+		go func() {
+			if err := server.Serve(lis); err != nil && err != http.ErrServerClosed {
+				otel.Handle(fmt.Errorf("the Prometheus HTTP server exited unexpectedly: %w", err))
+			}
+		}()
+
+		return readerWithServer{lis.Addr(), reader, &server}, nil
+	})
+}
+
+type readerWithServer struct {
+	addr net.Addr
+	metric.Reader
+	server *http.Server
+}
+
+func (rws readerWithServer) Shutdown(ctx context.Context) error {
+	return errors.Join(
+		rws.Reader.Shutdown(ctx),
+		rws.server.Shutdown(ctx),
+	)
+}
+
+func getenv(key, fallback string) string {
+	result, ok := os.LookupEnv(key)
+	if !ok {
+		return fallback
+	}
+	return result
 }


Test patch:
diff --git a/exporters/autoexport/metrics_test.go b/exporters/autoexport/metrics_test.go
index 4a3ecff2f..6624db36e 100644
--- a/exporters/autoexport/metrics_test.go
+++ b/exporters/autoexport/metrics_test.go
@@ -17,18 +17,26 @@ package autoexport // import "go.opentelemetry.io/contrib/exporters/autoexport"
 import (
 	"context"
 	"fmt"
+	"io"
+	"net/http"
 	"reflect"
+	"runtime/debug"
 	"testing"
 
+	"go.opentelemetry.io/otel"
 	"go.opentelemetry.io/otel/sdk/metric"
 
 	"github.com/stretchr/testify/assert"
+	"go.uber.org/goleak"
 )
 
 func TestMetricExporterNone(t *testing.T) {
 	t.Setenv("OTEL_METRICS_EXPORTER", "none")
 	got, err := NewMetricReader(context.Background())
 	assert.NoError(t, err)
+	t.Cleanup(func() {
+		assert.NoError(t, got.Shutdown(context.Background()))
+	})
 	assert.True(t, IsNoneMetricReader(got))
 }
 
@@ -47,6 +55,9 @@ func TestMetricExporterOTLP(t *testing.T) {
 
 			got, err := NewMetricReader(context.Background())
 			assert.NoError(t, err)
+			t.Cleanup(func() {
+				assert.NoError(t, got.Shutdown(context.Background()))
+			})
 			assert.IsType(t, &metric.PeriodicReader{}, got)
 
 			// Implementation detail hack. This may break when bumping OTLP exporter modules as it uses unexported API.
@@ -63,3 +74,47 @@ func TestMetricExporterOTLPOverInvalidProtocol(t *testing.T) {
 	_, err := NewMetricReader(context.Background())
 	assert.Error(t, err)
 }
+
+func assertNoOtelHandleErrors(t *testing.T) {
+	h := otel.GetErrorHandler()
+	t.Cleanup(func() { otel.SetErrorHandler(h) })
+
+	otel.SetErrorHandler(otel.ErrorHandlerFunc(func(cause error) {
+		t.Errorf("expected to calls to otel.Handle but got %v from %s", cause, debug.Stack())
+	}))
+}
+
+func TestMetricExporterPrometheus(t *testing.T) {
+	assertNoOtelHandleErrors(t)
+
+	t.Setenv("OTEL_METRICS_EXPORTER", "prometheus")
+	t.Setenv("OTEL_EXPORTER_PROMETHEUS_PORT", "0")
+
+	r, err := NewMetricReader(context.Background())
+	assert.NoError(t, err)
+
+	// pull-based exporters like Prometheus need to be registered
+	mp := metric.NewMeterProvider(metric.WithReader(r))
+
+	rws, ok := r.(readerWithServer)
+	if !ok {
+		t.Errorf("expected readerWithServer but got %v", r)
+	}
+
+	resp, err := http.Get(fmt.Sprintf("http://%s/metrics", rws.addr))
+	assert.NoError(t, err)
+	body, err := io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+	assert.Contains(t, string(body), "# HELP")
+
+	assert.NoError(t, mp.Shutdown(context.Background()))
+	goleak.VerifyNone(t)
+}
+
+func TestMetricExporterPrometheusInvalidPort(t *testing.T) {
+	t.Setenv("OTEL_METRICS_EXPORTER", "prometheus")
+	t.Setenv("OTEL_EXPORTER_PROMETHEUS_PORT", "invalid-port")
+
+	_, err := NewMetricReader(context.Background())
+	assert.ErrorContains(t, err, "binding")
+}
diff --git a/exporters/autoexport/spans_test.go b/exporters/autoexport/spans_test.go
index 35ec975b3..b28515492 100644
--- a/exporters/autoexport/spans_test.go
+++ b/exporters/autoexport/spans_test.go
@@ -29,6 +29,9 @@ func TestSpanExporterNone(t *testing.T) {
 	t.Setenv("OTEL_TRACES_EXPORTER", "none")
 	got, err := NewSpanExporter(context.Background())
 	assert.NoError(t, err)
+	t.Cleanup(func() {
+		assert.NoError(t, got.Shutdown(context.Background()))
+	})
 	assert.True(t, IsNoneSpanExporter(got))
 }
 
@@ -47,6 +50,9 @@ func TestSpanExporterOTLP(t *testing.T) {
 
 			got, err := NewSpanExporter(context.Background())
 			assert.NoError(t, err)
+			t.Cleanup(func() {
+				assert.NoError(t, got.Shutdown(context.Background()))
+			})
 			assert.IsType(t, &otlptrace.Exporter{}, got)
 
 			// Implementation detail hack. This may break when bumping OTLP exporter modules as it uses unexported API.