diff --git a/exporters/prometheus/exporter.go b/exporters/prometheus/exporter.go index 789b089f3e6f..7c2d21b43669 100644 --- a/exporters/prometheus/exporter.go +++ b/exporters/prometheus/exporter.go @@ -19,9 +19,14 @@ package prometheus // import "go.opentelemetry.io/otel/exporters/prometheus" import ( "context" + "sort" + "strings" + "unicode" "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/internal/global" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/metric" @@ -67,6 +72,8 @@ func (e *exporter) Collect(ch chan<- prometheus.Metric) { otel.Handle(err) } + // TODO(damemi): 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) { if metricData.valueType == prometheus.UntypedValue { m, err := prometheus.NewConstHistogram(metricData.description, metricData.histogramCount, metricData.histogramSum, metricData.histogramBuckets, metricData.attributeValues...) @@ -86,6 +93,8 @@ func (e *exporter) Collect(ch chan<- prometheus.Metric) { // 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 description *prometheus.Desc attributeValues []string @@ -119,6 +128,13 @@ func getMetricData(metrics metricdata.ResourceMetrics) []*metricData { } func getHistogramMetricData(histogram metricdata.Histogram, m metricdata.Metrics) []*metricData { + // Drop histograms with delta aggregation temporality + if histogram.Temporality == metricdata.DeltaTemporality { + global.Info("dropping histogram with delta temporality", "name", m.Name) + return []*metricData{} + } + + // TODO(https://github.com/open-telemetry/opentelemetry-go/issues/3163): support exemplars dataPoints := make([]*metricData, 0) for _, dp := range histogram.DataPoints { keys, values := getAttrs(dp.Attributes) @@ -142,6 +158,13 @@ func getHistogramMetricData(histogram metricdata.Histogram, m metricdata.Metrics } func getSumMetricData[N int64 | float64](sum metricdata.Sum[N], m metricdata.Metrics) []*metricData { + // TODO(damemi): convert delta aggregation temporality to cumulative + // see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/data-model.md#sums-1 + if sum.Temporality == metricdata.DeltaTemporality { + global.Info("dropping sum with delta temporality", "name", m.Name) + return []*metricData{} + } + dataPoints := make([]*metricData, 0) for _, dp := range sum.DataPoints { keys, values := getAttrs(dp.Attributes) @@ -175,12 +198,36 @@ func getGaugeMetricData[N int64 | float64](gauge metricdata.Gauge[N], m metricda 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) { + keysMap := make(map[string][]string) + for _, kv := range attrs.ToSlice() { + 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 _, kv := range attrs.ToSlice() { - keys = append(keys, string(kv.Key)) - values = append(values, kv.Value.AsString()) + 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) || string(r) == ":" || string(r) == "_" { + return r + } + return '_' +} diff --git a/exporters/prometheus/exporter_test.go b/exporters/prometheus/exporter_test.go index 0edd902a763e..fe5d48a33826 100644 --- a/exporters/prometheus/exporter_test.go +++ b/exporters/prometheus/exporter_test.go @@ -84,6 +84,26 @@ func TestPrometheusExporter(t *testing.T) { histogram.Record(ctx, 105, labels...) }, }, + { + name: "sanitized attributes to labels", + expectedFile: "testdata/sanitized_labels.txt", + recordMetrics: func(ctx context.Context, meter otelmetric.Meter) { + labels := []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, labels...) + counter.Add(ctx, 10.3, labels...) + counter.Add(ctx, 9, labels...) + }, + }, } for _, tc := range testCases { diff --git a/exporters/prometheus/testdata/sanitized_labels.txt b/exporters/prometheus/testdata/sanitized_labels.txt new file mode 100755 index 000000000000..cd686cff97ec --- /dev/null +++ b/exporters/prometheus/testdata/sanitized_labels.txt @@ -0,0 +1,3 @@ +# HELP foo a sanitary counter +# TYPE foo counter +foo{A_B="Q",C_D="Y;Z"} 24.3