From e5c6c89394efbfdff4c878c1dc1c5b785b6179a0 Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Mon, 21 Aug 2023 20:39:02 +0000 Subject: [PATCH] add prometheus bridge --- .github/dependabot.yml | 9 + CHANGELOG.md | 1 + CODEOWNERS | 2 + bridges/prometheus/README.md | 28 +++ bridges/prometheus/config.go | 58 +++++++ bridges/prometheus/config_test.go | 60 +++++++ bridges/prometheus/doc.go | 16 ++ bridges/prometheus/go.mod | 32 ++++ bridges/prometheus/go.sum | 59 +++++++ bridges/prometheus/producer.go | 254 ++++++++++++++++++++++++++++ bridges/prometheus/producer_test.go | 238 ++++++++++++++++++++++++++ 11 files changed, 757 insertions(+) create mode 100644 bridges/prometheus/README.md create mode 100644 bridges/prometheus/config.go create mode 100644 bridges/prometheus/config_test.go create mode 100644 bridges/prometheus/doc.go create mode 100644 bridges/prometheus/go.mod create mode 100644 bridges/prometheus/go.sum create mode 100644 bridges/prometheus/producer.go create mode 100644 bridges/prometheus/producer_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d22c4379727..208ba6b33f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -145,6 +145,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 dfc28098000..107a62b06ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - The `go.opentelemetry.io/contrib/exporters/autoexport` package to provide configuration of trace exporters with useful defaults and envar support. (#2753, #4100, #4129, #4132, #4134) - `WithRouteTag` in `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` adds HTTP route attribute to metrics. (#615) - Add `WithSpanOptions` option in `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`. (#3768) +- Add new Prometheus bridge module in `go.opentelemetry.io/contrib/bridges/prometheus`. (#TODO) ### Fixed diff --git a/CODEOWNERS b/CODEOWNERS index 7f81d1bd92d..0b887800ed0 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 00000000000..9a6ffc91f99 --- /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 := prombridge.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 00000000000..a34900a8f9f --- /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/bridge/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/config_test.go b/bridges/prometheus/config_test.go new file mode 100644 index 00000000000..15e535e7c0c --- /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/bridge/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/doc.go b/bridges/prometheus/doc.go new file mode 100644 index 00000000000..66720b82875 --- /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/bridge/prometheus" diff --git a/bridges/prometheus/go.mod b/bridges/prometheus/go.mod new file mode 100644 index 00000000000..5763261d96d --- /dev/null +++ b/bridges/prometheus/go.mod @@ -0,0 +1,32 @@ +module go.opentelemetry.io/contrib/bridge/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 00000000000..b136f326879 --- /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 00000000000..a19513e814f --- /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/bridge/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.Gatherer +} + +// 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/bridges/prometheus/producer_test.go b/bridges/prometheus/producer_test.go new file mode 100644 index 00000000000..0cf3577c1df --- /dev/null +++ b/bridges/prometheus/producer_test.go @@ -0,0 +1,238 @@ +// 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/bridge/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", + }) + 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(), + 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", + }) + 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(), + 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", + }) + 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", + }) + 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(), + 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", + }) + 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(), + 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()) + } + }) + } +}