Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add back Prometheus exporter #3135

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,15 @@ updates:
schedule:
interval: weekly
day: sunday
- package-ecosystem: gomod
directory: /exporters/prometheus
labels:
- dependencies
- go
- Skip Changelog
schedule:
interval: weekly
day: sunday
- package-ecosystem: gomod
directory: /exporters/stdout/stdoutmetric
labels:
Expand Down
18 changes: 18 additions & 0 deletions exporters/prometheus/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// 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 Prometheus Exporter that converts
// OTLP metrics into the Prometheus exposition format and implements
// prometheus.Collector to provide a handler for these metrics.
package prometheus // import "go.opentelemetry.io/otel/exporters/prometheus"
233 changes: 233 additions & 0 deletions exporters/prometheus/exporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// 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.

damemi marked this conversation as resolved.
Show resolved Hide resolved
//go:build go1.18
// +build go1.18
damemi marked this conversation as resolved.
Show resolved Hide resolved

package prometheus // import "go.opentelemetry.io/otel/exporters/prometheus"

import (
"context"
"sort"
"strings"
"unicode"

"github.com/prometheus/client_golang/prometheus"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)

// Exporter is a Prometheus Exporter that embeds the OTel metric.Reader
// interface for easy instantiation with a MeterProvider.
type Exporter struct {
damemi marked this conversation as resolved.
Show resolved Hide resolved
metric.Reader
Collector prometheus.Collector
}

// collector is used to implement prometheus.Collector.
type collector struct {
metric.Reader
}

// config is added here to allow for options expansion in the future.
type config struct{}

// Option may be used in the future to apply options to a Prometheus Exporter config.
type Option interface {
apply(config) config
}

// New returns a Prometheus Exporter.
func New(_ ...Option) Exporter {
// this assumes that the default temporality selector will always return cumulative.
// we only support cumulative temporality, so building our own reader enforces this.
reader := metric.NewManualReader()
MrAlias marked this conversation as resolved.
Show resolved Hide resolved
damemi marked this conversation as resolved.
Show resolved Hide resolved
e := Exporter{
Reader: reader,
Collector: &collector{
Reader: reader,
},
}
return e
}

// Describe implements prometheus.Collector.
func (c *collector) Describe(ch chan<- *prometheus.Desc) {
metrics, err := c.Reader.Collect(context.TODO())
if err != nil {
otel.Handle(err)
}
for _, metricData := range getMetricData(metrics) {
ch <- metricData.description
}
}

// Collect implements prometheus.Collector.
func (c *collector) Collect(ch chan<- prometheus.Metric) {
metrics, err := c.Reader.Collect(context.TODO())
if err != nil {
otel.Handle(err)
}

// TODO(#3166): convert otel resource to target_info
// see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/data-model.md#resource-attributes-1
for _, metricData := range getMetricData(metrics) {
damemi marked this conversation as resolved.
Show resolved Hide resolved
if metricData.valueType == prometheus.UntypedValue {
m, err := prometheus.NewConstHistogram(metricData.description, metricData.histogramCount, metricData.histogramSum, metricData.histogramBuckets, metricData.attributeValues...)
if err != nil {
otel.Handle(err)
}
ch <- m
} else {
m, err := prometheus.NewConstMetric(metricData.description, metricData.valueType, metricData.value, metricData.attributeValues...)
if err != nil {
otel.Handle(err)
}
ch <- m
}
}
}

// metricData holds the metadata as well as values for individual data points.
type metricData struct {
// name should include the unit as a suffix (before _total on counters)
// see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/data-model.md#metric-metadata-1
name string
damemi marked this conversation as resolved.
Show resolved Hide resolved
description *prometheus.Desc
attributeValues []string
valueType prometheus.ValueType
value float64
histogramCount uint64
histogramSum float64
histogramBuckets map[float64]uint64
}

func getMetricData(metrics metricdata.ResourceMetrics) []*metricData {
allMetrics := make([]*metricData, 0)
for _, scopeMetrics := range metrics.ScopeMetrics {
for _, m := range scopeMetrics.Metrics {
switch v := m.Data.(type) {
case metricdata.Histogram:
allMetrics = append(allMetrics, getHistogramMetricData(v, m)...)
case metricdata.Sum[int64]:
dashpole marked this conversation as resolved.
Show resolved Hide resolved
allMetrics = append(allMetrics, getSumMetricData(v, m)...)
case metricdata.Sum[float64]:
allMetrics = append(allMetrics, getSumMetricData(v, m)...)
case metricdata.Gauge[int64]:
allMetrics = append(allMetrics, getGaugeMetricData(v, m)...)
case metricdata.Gauge[float64]:
allMetrics = append(allMetrics, getGaugeMetricData(v, m)...)
}
}
}

return allMetrics
}

func getHistogramMetricData(histogram metricdata.Histogram, m metricdata.Metrics) []*metricData {
damemi marked this conversation as resolved.
Show resolved Hide resolved
damemi marked this conversation as resolved.
Show resolved Hide resolved
// TODO(https://github.com/open-telemetry/opentelemetry-go/issues/3163): support exemplars
dataPoints := make([]*metricData, 0, len(histogram.DataPoints))
for _, dp := range histogram.DataPoints {
keys, values := getAttrs(dp.Attributes)
desc := prometheus.NewDesc(m.Name, m.Description, keys, nil)
buckets := make(map[float64]uint64, len(dp.Bounds))
for i, bound := range dp.Bounds {
buckets[bound] = dp.BucketCounts[i]
}
md := &metricData{
name: m.Name,
description: desc,
attributeValues: values,
valueType: prometheus.UntypedValue,
histogramCount: dp.Count,
histogramSum: dp.Sum,
histogramBuckets: buckets,
}
dataPoints = append(dataPoints, md)
}
return dataPoints
}

func getSumMetricData[N int64 | float64](sum metricdata.Sum[N], m metricdata.Metrics) []*metricData {
dataPoints := make([]*metricData, 0, len(sum.DataPoints))
for _, dp := range sum.DataPoints {
keys, values := getAttrs(dp.Attributes)
desc := prometheus.NewDesc(m.Name, m.Description, keys, nil)
md := &metricData{
name: m.Name,
description: desc,
attributeValues: values,
valueType: prometheus.CounterValue,
damemi marked this conversation as resolved.
Show resolved Hide resolved
value: float64(dp.Value),
}
dataPoints = append(dataPoints, md)
}
return dataPoints
}

func getGaugeMetricData[N int64 | float64](gauge metricdata.Gauge[N], m metricdata.Metrics) []*metricData {
dataPoints := make([]*metricData, 0, len(gauge.DataPoints))
for _, dp := range gauge.DataPoints {
keys, values := getAttrs(dp.Attributes)
desc := prometheus.NewDesc(m.Name, m.Description, keys, nil)
md := &metricData{
name: m.Name,
description: desc,
attributeValues: values,
valueType: prometheus.GaugeValue,
value: float64(dp.Value),
}
dataPoints = append(dataPoints, md)
}
return dataPoints
}

// getAttrs parses the attribute.Set to two lists of matching Prometheus-style
// keys and values. It sanitizes invalid characters and handles duplicate keys
// (due to sanitization) by sorting and concatenating the values following the spec.
func getAttrs(attrs attribute.Set) ([]string, []string) {
damemi marked this conversation as resolved.
Show resolved Hide resolved
keysMap := make(map[string][]string)
itr := attrs.Iter()
for itr.Next() {
kv := itr.Attribute()
key := strings.Map(sanitizeRune, string(kv.Key))
if _, ok := keysMap[key]; !ok {
keysMap[key] = []string{kv.Value.AsString()}
} else {
// if the sanitized key is a duplicate, append to the list of keys
keysMap[key] = append(keysMap[key], kv.Value.AsString())
}
}

keys := make([]string, 0, attrs.Len())
values := make([]string, 0, attrs.Len())
for key, vals := range keysMap {
keys = append(keys, key)
sort.Slice(vals, func(i, j int) bool {
return i < j
})
values = append(values, strings.Join(vals, ";"))
}
return keys, values
}

func sanitizeRune(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ':' || r == '_' {
return r
}
return '_'
}
130 changes: 130 additions & 0 deletions exporters/prometheus/exporter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// 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.

damemi marked this conversation as resolved.
Show resolved Hide resolved
//go:build go1.18
// +build go1.18

package prometheus

import (
"context"
"os"
"testing"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"

"go.opentelemetry.io/otel/attribute"
otelmetric "go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/instrument"
"go.opentelemetry.io/otel/sdk/metric"
)

func TestPrometheusExporter(t *testing.T) {
testCases := []struct {
name string
recordMetrics func(ctx context.Context, meter otelmetric.Meter)
expectedFile string
}{
{
name: "counter",
expectedFile: "testdata/counter.txt",
recordMetrics: func(ctx context.Context, meter otelmetric.Meter) {
attrs := []attribute.KeyValue{
attribute.Key("A").String("B"),
attribute.Key("C").String("D"),
}
counter, err := meter.SyncFloat64().Counter("foo", instrument.WithDescription("a simple counter"))
require.NoError(t, err)
counter.Add(ctx, 5, attrs...)
counter.Add(ctx, 10.3, attrs...)
counter.Add(ctx, 9, attrs...)
},
},
{
name: "gauge",
expectedFile: "testdata/gauge.txt",
recordMetrics: func(ctx context.Context, meter otelmetric.Meter) {
attrs := []attribute.KeyValue{
attribute.Key("A").String("B"),
attribute.Key("C").String("D"),
}
gauge, err := meter.SyncFloat64().UpDownCounter("bar", instrument.WithDescription("a fun little gauge"))
require.NoError(t, err)
gauge.Add(ctx, 100, attrs...)
gauge.Add(ctx, -25, attrs...)
},
},
{
name: "histogram",
expectedFile: "testdata/histogram.txt",
recordMetrics: func(ctx context.Context, meter otelmetric.Meter) {
attrs := []attribute.KeyValue{
attribute.Key("A").String("B"),
attribute.Key("C").String("D"),
}
histogram, err := meter.SyncFloat64().Histogram("baz", instrument.WithDescription("a very nice histogram"))
require.NoError(t, err)
histogram.Record(ctx, 23, attrs...)
histogram.Record(ctx, 7, attrs...)
histogram.Record(ctx, 101, attrs...)
histogram.Record(ctx, 105, attrs...)
},
},
{
name: "sanitized attributes to labels",
expectedFile: "testdata/sanitized_labels.txt",
recordMetrics: func(ctx context.Context, meter otelmetric.Meter) {
attrs := []attribute.KeyValue{
// exact match, value should be overwritten
attribute.Key("A.B").String("X"),
attribute.Key("A.B").String("Q"),

// unintended match due to sanitization, values should be concatenated
attribute.Key("C.D").String("Y"),
attribute.Key("C/D").String("Z"),
}
counter, err := meter.SyncFloat64().Counter("foo", instrument.WithDescription("a sanitary counter"))
require.NoError(t, err)
counter.Add(ctx, 5, attrs...)
counter.Add(ctx, 10.3, attrs...)
counter.Add(ctx, 9, attrs...)
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()

exporter := New()
provider := metric.NewMeterProvider(metric.WithReader(exporter))
meter := provider.Meter("testmeter")

registry := prometheus.NewRegistry()
err := registry.Register(exporter.Collector)
require.NoError(t, err)

tc.recordMetrics(ctx, meter)

file, err := os.Open(tc.expectedFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, file.Close()) })

err = testutil.GatherAndCompare(registry, file)
require.NoError(t, err)
})
}
}
Loading