Repository: open-telemetry/opentelemetry-go-contrib
Pull request #4227 (2023-08-21 20:41:30)

Add prometheus bridge
Migrated from https://github.com/open-telemetry/opentelemetry-go/pull/4351

This allows applications instrumented with the prometheus client library to use OpenTelemetry exporters (e.g. OTLP).

Limitations:

* Summary metrics are dropped by the bridge. This could be fixed if we added the summary types to the Go SDK data model, or we could convert the summary to individual gauge and counter series.
* Start times for histograms and counters are set to the process start time. This would only not be correct when using custom collectors, which is relatively uncommon. That will be fixed when the prometheus client allows recording start times for their instruments.
* Exponential histograms are not yet supported.

Despite the limitations, I think this still has a lot of value to users.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/pull/4227?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) Report
> Merging [#4227](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/pull/4227?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) (d4391a1) into [main](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/commit/7d9626dfc0aa9eea74a0fa1920643795345ca06f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) (7d9626d) will **increase** coverage by `0.0%`.
> Report is 1 commits behind head on main.
> The diff coverage is `84.9%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/pull/4227/graphs/tree.svg?width=650&height=150&src=pr&token=P6F3W9WA7Q&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry)](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/pull/4227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry)

```diff
@@          Coverage Diff           @@
##            main   #4227    +/-   ##
======================================
  Coverage   79.4%   79.5%            
======================================
  Files        166     168     +2     
  Lines      10360   10546   +186     
======================================
+ Hits        8230    8388   +158     
- Misses      1996    2017    +21     
- Partials     134     141     +7     
```


| [Files Changed](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/pull/4227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) | Coverage Δ | |
|---|---|---|
| [bridges/prometheus/producer.go](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/pull/4227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry#diff-YnJpZGdlcy9wcm9tZXRoZXVzL3Byb2R1Y2VyLmdv) | `83.5% <83.5%> (ø)` | |
| [bridges/prometheus/config.go](https://app.codecov.io/gh/open-telemetry/opentelemetry-go-contrib/pull/4227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry#diff-YnJpZGdlcy9wcm9tZXRoZXVzL2NvbmZpZy5nbw==) | `100.0% <100.0%> (ø)` | |

</details>
dashpole: I've addressed open comments on the original PR.
pirgeo: Is there not a summary metric type in OTLP for exactly that reason? Or is it missing in the Go SDK data model?
dashpole: It is missing in the Go SDK data model.  Adding it to the data model would mean in-process exporters would have to handle summary-typed metrics.  But maybe it is better to add it, and expect most exporters other than OTLP to drop it...
dashpole: Rebased.  I think this is good to go
dashpole: I'd prefer to merge as-is.  I'll open a follow-up with the new docs.

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e470ef817..43756176f 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -100,6 +100,15 @@ updates:
     schedule:
       interval: weekly
       day: sunday
+  - package-ecosystem: gomod
+    directory: /bridges/prometheus
+    labels:
+      - dependencies
+      - go
+      - Skip Changelog
+    schedule:
+      interval: weekly
+      day: sunday
   - package-ecosystem: gomod
     directory: /detectors/aws/ec2
     labels:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65a1ceafa..a03dabbc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 - Add the new `go.opentelemetry.io/contrib/instrgen` package to provide auto-generated source code instrumentation. (#3068, #3108)
 - Set the description for the `rpc.server.duration` metric in `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`. (#4302)
 - Add `NewServerHandler` and `NewClientHandler` that return a `grpc.StatsHandler` used for gRPC instrumentation in `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`. (#3002)
+- Add new Prometheus bridge module in `go.opentelemetry.io/contrib/bridges/prometheus`. (#4227)
 
 ### Changed
 
diff --git a/CODEOWNERS b/CODEOWNERS
index 5151257ee..accc87c2b 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -22,6 +22,8 @@
 
 CODEOWNERS @MrAlias @MadVikingGod @pellared
 
+bridges/prometheus/                                                     @open-telemetry/go-approvers @dashpole
+
 detectors/aws/                                                          @open-telemetry/go-approvers @Aneurysm9
 detectors/gcp/                                                          @open-telemetry/go-approvers @dashpole
 
diff --git a/bridges/prometheus/README.md b/bridges/prometheus/README.md
new file mode 100644
index 000000000..8c2e71b3b
--- /dev/null
+++ b/bridges/prometheus/README.md
@@ -0,0 +1,28 @@
+# Prometheus Bridge
+
+Status: Experimental
+
+The Prometheus Bridge allows using the Prometheus Golang client library
+(github.com/prometheus/client_golang) with the OpenTelemetry SDK.
+
+## Usage
+
+```golang
+// Make a Promethes bridge "Metric Producer" that adds metrics from the
+// Prometheus DefaultGatherer. Add the WithGatherer(registry) option to add
+// metrics from other registries.
+bridge := prometheus.NewMetricProducer()
+// Make a Periodic Reader to periodically gather metrics from the bridge, and
+// push to an OpenTelemetry exporter.
+reader := metric.NewPeriodicReader(otelExporter, metric.WithProducer(bridge))
+// Create an OTel MeterProvider with our reader. Metrics from OpenTelemetry
+// instruments are combined with metrics from Prometheus instruments in
+// exported batches of metrics.
+mp := metric.NewMeterProvider(metric.WithReader(reader))
+```
+
+## Limitations
+
+* Summary metrics are dropped by the bridge.
+* Start times for histograms and counters are set to the process start time.
+* It does not currently support exponential histograms.
diff --git a/bridges/prometheus/config.go b/bridges/prometheus/config.go
new file mode 100644
index 000000000..677597948
--- /dev/null
+++ b/bridges/prometheus/config.go
@@ -0,0 +1,58 @@
+// 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 prometheus // import "go.opentelemetry.io/contrib/bridges/prometheus"
+
+import (
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+// config contains options for the producer.
+type config struct {
+	gatherers []prometheus.Gatherer
+}
+
+// newConfig creates a validated config configured with options.
+func newConfig(opts ...Option) config {
+	cfg := config{}
+	for _, opt := range opts {
+		cfg = opt.apply(cfg)
+	}
+
+	if len(cfg.gatherers) == 0 {
+		cfg.gatherers = []prometheus.Gatherer{prometheus.DefaultGatherer}
+	}
+
+	return cfg
+}
+
+// Option sets producer option values.
+type Option interface {
+	apply(config) config
+}
+
+type optionFunc func(config) config
+
+func (fn optionFunc) apply(cfg config) config {
+	return fn(cfg)
+}
+
+// WithGatherer configures which prometheus Gatherer the Bridge will gather
+// from. If no registerer is used the prometheus DefaultGatherer is used.
+func WithGatherer(gatherer prometheus.Gatherer) Option {
+	return optionFunc(func(cfg config) config {
+		cfg.gatherers = append(cfg.gatherers, gatherer)
+		return cfg
+	})
+}
diff --git a/bridges/prometheus/doc.go b/bridges/prometheus/doc.go
new file mode 100644
index 000000000..58d8a0c6b
--- /dev/null
+++ b/bridges/prometheus/doc.go
@@ -0,0 +1,16 @@
+// 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 prometheus provides a bridge from Prometheus to OpenTelemetry.
+package prometheus // import "go.opentelemetry.io/contrib/bridges/prometheus"
diff --git a/bridges/prometheus/go.mod b/bridges/prometheus/go.mod
new file mode 100644
index 000000000..fd1a53126
--- /dev/null
+++ b/bridges/prometheus/go.mod
@@ -0,0 +1,32 @@
+module go.opentelemetry.io/contrib/bridges/prometheus
+
+go 1.19
+
+require (
+	github.com/prometheus/client_golang v1.16.0
+	github.com/prometheus/client_model v0.4.1-0.20230719122841-95a0733b38a0
+	github.com/stretchr/testify v1.8.4
+	go.opentelemetry.io/otel v1.16.0
+	go.opentelemetry.io/otel/sdk v1.16.0
+	go.opentelemetry.io/otel/sdk/metric v0.39.0
+)
+
+require (
+	github.com/beorn7/perks v1.0.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.2.4 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/golang/protobuf v1.5.3 // indirect
+	github.com/kr/text v0.2.0 // indirect
+	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/prometheus/common v0.42.0 // indirect
+	github.com/prometheus/procfs v0.10.1 // indirect
+	github.com/rogpeppe/go-internal v1.11.0 // indirect
+	go.opentelemetry.io/otel/metric v1.16.0 // indirect
+	go.opentelemetry.io/otel/trace v1.16.0 // indirect
+	golang.org/x/sys v0.10.0 // indirect
+	google.golang.org/protobuf v1.30.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/bridges/prometheus/go.sum b/bridges/prometheus/go.sum
new file mode 100644
index 000000000..b136f3268
--- /dev/null
+++ b/bridges/prometheus/go.sum
@@ -0,0 +1,59 @@
+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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+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=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+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/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=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+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.1-0.20230719122841-95a0733b38a0 h1:0+/NUBQSANv2NUdj4G9gW+2gy10v89mZB5WMa9/fWPs=
+github.com/prometheus/client_model v0.4.1-0.20230719122841-95a0733b38a0/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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
+github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
+go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
+go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
+go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
+go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE=
+go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4=
+go.opentelemetry.io/otel/sdk/metric v0.39.0 h1:Kun8i1eYf48kHH83RucG93ffz0zGV1sh46FAScOTuDI=
+go.opentelemetry.io/otel/sdk/metric v0.39.0/go.mod h1:piDIRgjcK7u0HCL5pCA4e74qpK/jk3NiUoAHATVAmiI=
+go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
+go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
+golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/bridges/prometheus/producer.go b/bridges/prometheus/producer.go
new file mode 100644
index 000000000..6c27a29b1
--- /dev/null
+++ b/bridges/prometheus/producer.go
@@ -0,0 +1,254 @@
+// 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 prometheus // import "go.opentelemetry.io/contrib/bridges/prometheus"
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/prometheus/client_golang/prometheus"
+	dto "github.com/prometheus/client_model/go"
+
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/sdk/instrumentation"
+	"go.opentelemetry.io/otel/sdk/metric"
+	"go.opentelemetry.io/otel/sdk/metric/metricdata"
+)
+
+const (
+	scopeName    = "go.opentelemetry.io/otel/bridge/prometheus"
+	traceIDLabel = "trace_id"
+	spanIDLabel  = "span_id"
+)
+
+var (
+	errUnsupportedType = errors.New("unsupported metric type")
+	processStartTime   = time.Now()
+)
+
+type producer struct {
+	gatherers prometheus.Gatherers
+}
+
+// NewMetricProducer returns a metric.Producer that fetches metrics from
+// Prometheus. This can be used to allow Prometheus instrumentation to be
+// added to an OpenTelemetry export pipeline.
+func NewMetricProducer(opts ...Option) metric.Producer {
+	cfg := newConfig(opts...)
+	return &producer{
+		gatherers: cfg.gatherers,
+	}
+}
+
+func (p *producer) Produce(context.Context) ([]metricdata.ScopeMetrics, error) {
+	now := time.Now()
+	var errs multierr
+	otelMetrics := make([]metricdata.Metrics, 0)
+	for _, gatherer := range p.gatherers {
+		promMetrics, err := gatherer.Gather()
+		if err != nil {
+			errs = append(errs, err)
+			continue
+		}
+		m, err := convertPrometheusMetricsInto(promMetrics, now)
+		otelMetrics = append(otelMetrics, m...)
+		if err != nil {
+			errs = append(errs, err)
+		}
+	}
+	if errs.errOrNil() != nil {
+		otel.Handle(errs.errOrNil())
+	}
+	if len(otelMetrics) == 0 {
+		return nil, nil
+	}
+	return []metricdata.ScopeMetrics{{
+		Scope: instrumentation.Scope{
+			Name: scopeName,
+		},
+		Metrics: otelMetrics,
+	}}, nil
+}
+
+func convertPrometheusMetricsInto(promMetrics []*dto.MetricFamily, now time.Time) ([]metricdata.Metrics, error) {
+	var errs multierr
+	otelMetrics := make([]metricdata.Metrics, 0)
+	for _, pm := range promMetrics {
+		newMetric := metricdata.Metrics{
+			Name:        pm.GetName(),
+			Description: pm.GetHelp(),
+		}
+		switch pm.GetType() {
+		case dto.MetricType_GAUGE:
+			newMetric.Data = convertGauge(pm.GetMetric(), now)
+		case dto.MetricType_COUNTER:
+			newMetric.Data = convertCounter(pm.GetMetric(), now)
+		case dto.MetricType_HISTOGRAM:
+			newMetric.Data = convertHistogram(pm.GetMetric(), now)
+		default:
+			// MetricType_GAUGE_HISTOGRAM, MetricType_SUMMARY, MetricType_UNTYPED
+			errs = append(errs, fmt.Errorf("%w: %v for metric %v", errUnsupportedType, pm.GetType(), pm.GetName()))
+			continue
+		}
+		otelMetrics = append(otelMetrics, newMetric)
+	}
+	return otelMetrics, errs.errOrNil()
+}
+
+func convertGauge(metrics []*dto.Metric, now time.Time) metricdata.Gauge[float64] {
+	otelGauge := metricdata.Gauge[float64]{
+		DataPoints: make([]metricdata.DataPoint[float64], len(metrics)),
+	}
+	for i, m := range metrics {
+		dp := metricdata.DataPoint[float64]{
+			Attributes: convertLabels(m.GetLabel()),
+			Time:       now,
+			Value:      m.GetGauge().GetValue(),
+		}
+		if m.GetTimestampMs() != 0 {
+			dp.Time = time.UnixMilli(m.GetTimestampMs())
+		}
+		otelGauge.DataPoints[i] = dp
+	}
+	return otelGauge
+}
+
+func convertCounter(metrics []*dto.Metric, now time.Time) metricdata.Sum[float64] {
+	otelCounter := metricdata.Sum[float64]{
+		DataPoints:  make([]metricdata.DataPoint[float64], len(metrics)),
+		Temporality: metricdata.CumulativeTemporality,
+		IsMonotonic: true,
+	}
+	for i, m := range metrics {
+		dp := metricdata.DataPoint[float64]{
+			Attributes: convertLabels(m.GetLabel()),
+			StartTime:  processStartTime,
+			Time:       now,
+			Value:      m.GetCounter().GetValue(),
+			Exemplars:  []metricdata.Exemplar[float64]{convertExemplar(m.GetCounter().GetExemplar())},
+		}
+		createdTs := m.GetCounter().GetCreatedTimestamp()
+		if createdTs.IsValid() {
+			dp.StartTime = createdTs.AsTime()
+		}
+		if m.GetTimestampMs() != 0 {
+			dp.Time = time.UnixMilli(m.GetTimestampMs())
+		}
+		otelCounter.DataPoints[i] = dp
+	}
+	return otelCounter
+}
+
+func convertHistogram(metrics []*dto.Metric, now time.Time) metricdata.Histogram[float64] {
+	// TODO: support converting Prometheus "native" histograms to OTel exponential histograms.
+	otelHistogram := metricdata.Histogram[float64]{
+		DataPoints:  make([]metricdata.HistogramDataPoint[float64], len(metrics)),
+		Temporality: metricdata.CumulativeTemporality,
+	}
+	for i, m := range metrics {
+		bounds, bucketCounts, exemplars := convertBuckets(m.GetHistogram().GetBucket())
+		dp := metricdata.HistogramDataPoint[float64]{
+			Attributes:   convertLabels(m.GetLabel()),
+			StartTime:    processStartTime,
+			Time:         now,
+			Count:        m.GetHistogram().GetSampleCount(),
+			Sum:          m.GetHistogram().GetSampleSum(),
+			Bounds:       bounds,
+			BucketCounts: bucketCounts,
+			Exemplars:    exemplars,
+		}
+		createdTs := m.GetHistogram().GetCreatedTimestamp()
+		if createdTs.IsValid() {
+			dp.StartTime = createdTs.AsTime()
+		}
+		if m.GetTimestampMs() != 0 {
+			dp.Time = time.UnixMilli(m.GetTimestampMs())
+		}
+		otelHistogram.DataPoints[i] = dp
+	}
+	return otelHistogram
+}
+
+func convertBuckets(buckets []*dto.Bucket) ([]float64, []uint64, []metricdata.Exemplar[float64]) {
+	bounds := make([]float64, len(buckets)-1)
+	bucketCounts := make([]uint64, len(buckets))
+	exemplars := make([]metricdata.Exemplar[float64], 0)
+	for i, bucket := range buckets {
+		// The last bound is the +Inf bound, which is implied in OTel, but is
+		// explicit in Prometheus. Skip the last boundary, and assume it is the
+		// +Inf bound.
+		if i < len(bounds) {
+			bounds[i] = bucket.GetUpperBound()
+		}
+		bucketCounts[i] = bucket.GetCumulativeCount()
+		if bucket.GetExemplar() != nil {
+			exemplars = append(exemplars, convertExemplar(bucket.GetExemplar()))
+		}
+	}
+	return bounds, bucketCounts, exemplars
+}
+
+func convertLabels(labels []*dto.LabelPair) attribute.Set {
+	kvs := make([]attribute.KeyValue, len(labels))
+	for i, l := range labels {
+		kvs[i] = attribute.String(l.GetName(), l.GetValue())
+	}
+	return attribute.NewSet(kvs...)
+}
+
+func convertExemplar(exemplar *dto.Exemplar) metricdata.Exemplar[float64] {
+	attrs := make([]attribute.KeyValue, 0)
+	var traceID, spanID []byte
+	// find the trace ID and span ID in attributes, if it exists
+	for _, label := range exemplar.GetLabel() {
+		if label.GetName() == traceIDLabel {
+			traceID = []byte(label.GetValue())
+		} else if label.GetName() == spanIDLabel {
+			spanID = []byte(label.GetValue())
+		} else {
+			attrs = append(attrs, attribute.String(label.GetName(), label.GetValue()))
+		}
+	}
+	return metricdata.Exemplar[float64]{
+		Value:              exemplar.GetValue(),
+		Time:               exemplar.GetTimestamp().AsTime(),
+		TraceID:            traceID,
+		SpanID:             spanID,
+		FilteredAttributes: attrs,
+	}
+}
+
+type multierr []error
+
+func (e multierr) errOrNil() error {
+	if len(e) == 0 {
+		return nil
+	} else if len(e) == 1 {
+		return e[0]
+	}
+	return e
+}
+
+func (e multierr) Error() string {
+	es := make([]string, len(e))
+	for i, err := range e {
+		es[i] = fmt.Sprintf("* %s", err)
+	}
+	return strings.Join(es, "\n\t")
+}
diff --git a/versions.yaml b/versions.yaml
index 38890ef07..4fb65f04e 100644
--- a/versions.yaml
+++ b/versions.yaml
@@ -29,6 +29,7 @@ module-sets:
   experimental-instrumentation:
     version: v0.44.0
     modules:
+      - go.opentelemetry.io/contrib/bridges/prometheus
       - go.opentelemetry.io/contrib/detectors/aws/lambda
       - go.opentelemetry.io/contrib/exporters/autoexport
       - go.opentelemetry.io/contrib/propagators/autoprop


Test patch:
diff --git a/bridges/prometheus/config_test.go b/bridges/prometheus/config_test.go
new file mode 100644
index 000000000..534af62a7
--- /dev/null
+++ b/bridges/prometheus/config_test.go
@@ -0,0 +1,60 @@
+// 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 prometheus // import "go.opentelemetry.io/contrib/bridges/prometheus"
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewConfig(t *testing.T) {
+	otherRegistry := prometheus.NewRegistry()
+
+	testCases := []struct {
+		name       string
+		options    []Option
+		wantConfig config
+	}{
+		{
+			name:    "Default",
+			options: nil,
+			wantConfig: config{
+				gatherers: []prometheus.Gatherer{prometheus.DefaultGatherer},
+			},
+		},
+		{
+			name:    "With a different gatherer",
+			options: []Option{WithGatherer(otherRegistry)},
+			wantConfig: config{
+				gatherers: []prometheus.Gatherer{otherRegistry},
+			},
+		},
+		{
+			name:    "Multiple gatherers",
+			options: []Option{WithGatherer(otherRegistry), WithGatherer(prometheus.DefaultGatherer)},
+			wantConfig: config{
+				gatherers: []prometheus.Gatherer{otherRegistry, prometheus.DefaultGatherer},
+			},
+		},
+	}
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg := newConfig(tt.options...)
+			assert.Equal(t, tt.wantConfig, cfg)
+		})
+	}
+}
diff --git a/bridges/prometheus/producer_test.go b/bridges/prometheus/producer_test.go
new file mode 100644
index 000000000..41b48806e
--- /dev/null
+++ b/bridges/prometheus/producer_test.go
@@ -0,0 +1,253 @@
+// 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 prometheus // import "go.opentelemetry.io/contrib/bridges/prometheus"
+
+import (
+	"context"
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/sdk/instrumentation"
+	"go.opentelemetry.io/otel/sdk/metric/metricdata"
+	"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
+)
+
+const (
+	traceIDStr = "4bf92f3577b34da6a3ce929d0e0e4736"
+	spanIDStr  = "00f067aa0ba902b7"
+)
+
+func TestProduce(t *testing.T) {
+	testCases := []struct {
+		name     string
+		testFn   func(*prometheus.Registry)
+		expected []metricdata.ScopeMetrics
+		wantErr  error
+	}{
+		{
+			name:   "no metrics registered",
+			testFn: func(*prometheus.Registry) {},
+		},
+		{
+			name: "gauge",
+			testFn: func(reg *prometheus.Registry) {
+				metric := prometheus.NewGauge(prometheus.GaugeOpts{
+					Name: "test_gauge_metric",
+					Help: "A gauge metric for testing",
+					ConstLabels: prometheus.Labels(map[string]string{
+						"foo": "bar",
+					}),
+				})
+				reg.MustRegister(metric)
+				metric.Set(123.4)
+			},
+			expected: []metricdata.ScopeMetrics{{
+				Scope: instrumentation.Scope{
+					Name: scopeName,
+				},
+				Metrics: []metricdata.Metrics{
+					{
+						Name:        "test_gauge_metric",
+						Description: "A gauge metric for testing",
+						Data: metricdata.Gauge[float64]{
+							DataPoints: []metricdata.DataPoint[float64]{
+								{
+									Attributes: attribute.NewSet(attribute.String("foo", "bar")),
+									Value:      123.4,
+								},
+							},
+						},
+					},
+				},
+			}},
+		},
+		{
+			name: "counter",
+			testFn: func(reg *prometheus.Registry) {
+				metric := prometheus.NewCounter(prometheus.CounterOpts{
+					Name: "test_counter_metric",
+					Help: "A counter metric for testing",
+					ConstLabels: prometheus.Labels(map[string]string{
+						"foo": "bar",
+					}),
+				})
+				reg.MustRegister(metric)
+				metric.(prometheus.ExemplarAdder).AddWithExemplar(
+					245.3, prometheus.Labels{
+						"trace_id":        traceIDStr,
+						"span_id":         spanIDStr,
+						"other_attribute": "abcd",
+					},
+				)
+			},
+			expected: []metricdata.ScopeMetrics{{
+				Scope: instrumentation.Scope{
+					Name: scopeName,
+				},
+				Metrics: []metricdata.Metrics{
+					{
+						Name:        "test_counter_metric",
+						Description: "A counter metric for testing",
+						Data: metricdata.Sum[float64]{
+							Temporality: metricdata.CumulativeTemporality,
+							IsMonotonic: true,
+							DataPoints: []metricdata.DataPoint[float64]{
+								{
+									Attributes: attribute.NewSet(attribute.String("foo", "bar")),
+									Value:      245.3,
+									Exemplars: []metricdata.Exemplar[float64]{
+										{
+											Value:              245.3,
+											TraceID:            []byte(traceIDStr),
+											SpanID:             []byte(spanIDStr),
+											FilteredAttributes: []attribute.KeyValue{attribute.String("other_attribute", "abcd")},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}},
+		},
+		{
+			name: "summary dropped",
+			testFn: func(reg *prometheus.Registry) {
+				metric := prometheus.NewSummary(prometheus.SummaryOpts{
+					Name: "test_summary_metric",
+					Help: "A summary metric for testing",
+					ConstLabels: prometheus.Labels(map[string]string{
+						"foo": "bar",
+					}),
+				})
+				reg.MustRegister(metric)
+				metric.Observe(15.0)
+			},
+			wantErr: errUnsupportedType,
+		},
+		{
+			name: "histogram",
+			testFn: func(reg *prometheus.Registry) {
+				metric := prometheus.NewHistogram(prometheus.HistogramOpts{
+					Name: "test_histogram_metric",
+					Help: "A histogram metric for testing",
+					ConstLabels: prometheus.Labels(map[string]string{
+						"foo": "bar",
+					}),
+				})
+				reg.MustRegister(metric)
+				metric.(prometheus.ExemplarObserver).ObserveWithExemplar(
+					578.3, prometheus.Labels{
+						"trace_id":        traceIDStr,
+						"span_id":         spanIDStr,
+						"other_attribute": "efgh",
+					},
+				)
+			},
+			expected: []metricdata.ScopeMetrics{{
+				Scope: instrumentation.Scope{
+					Name: scopeName,
+				},
+				Metrics: []metricdata.Metrics{
+					{
+						Name:        "test_histogram_metric",
+						Description: "A histogram metric for testing",
+						Data: metricdata.Histogram[float64]{
+							Temporality: metricdata.CumulativeTemporality,
+							DataPoints: []metricdata.HistogramDataPoint[float64]{
+								{
+									Count:        1,
+									Sum:          578.3,
+									Bounds:       []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
+									BucketCounts: []uint64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
+									Attributes:   attribute.NewSet(attribute.String("foo", "bar")),
+									Exemplars: []metricdata.Exemplar[float64]{
+										{
+											Value:   578.3,
+											TraceID: []byte(traceIDStr),
+											SpanID:  []byte(spanIDStr),
+											FilteredAttributes: []attribute.KeyValue{
+												attribute.String("other_attribute", "efgh"),
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}},
+		},
+		{
+			name: "partial success",
+			testFn: func(reg *prometheus.Registry) {
+				metric := prometheus.NewGauge(prometheus.GaugeOpts{
+					Name: "test_gauge_metric",
+					Help: "A gauge metric for testing",
+					ConstLabels: prometheus.Labels(map[string]string{
+						"foo": "bar",
+					}),
+				})
+				reg.MustRegister(metric)
+				metric.Set(123.4)
+				unsupportedMetric := prometheus.NewSummary(prometheus.SummaryOpts{
+					Name: "test_summary_metric",
+					Help: "A summary metric for testing",
+				})
+				reg.MustRegister(unsupportedMetric)
+				unsupportedMetric.Observe(15.0)
+			},
+			expected: []metricdata.ScopeMetrics{{
+				Scope: instrumentation.Scope{
+					Name: scopeName,
+				},
+				Metrics: []metricdata.Metrics{
+					{
+						Name:        "test_gauge_metric",
+						Description: "A gauge metric for testing",
+						Data: metricdata.Gauge[float64]{
+							DataPoints: []metricdata.DataPoint[float64]{
+								{
+									Attributes: attribute.NewSet(attribute.String("foo", "bar")),
+									Value:      123.4,
+								},
+							},
+						},
+					},
+				},
+			}},
+			wantErr: errUnsupportedType,
+		},
+	}
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			reg := prometheus.NewRegistry()
+			tt.testFn(reg)
+			p := NewMetricProducer(WithGatherer(reg))
+			output, err := p.Produce(context.Background())
+			if tt.wantErr == nil {
+				assert.Nil(t, err)
+			}
+			require.Equal(t, len(output), len(tt.expected))
+			for i := range output {
+				metricdatatest.AssertEqual(t, tt.expected[i], output[i], metricdatatest.IgnoreTimestamp())
+			}
+		})
+	}
+}