From 4df571e02d2c938f28fc478e7bd7d06512541f80 Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 10:40:53 +0200
Subject: [PATCH 1/9] sanitize metric names in the prometheus exporter

---
 exporters/prometheus/exporter.go              | 22 ++++++++++++++---
 exporters/prometheus/exporter_test.go         | 10 ++++----
 .../prometheus/testdata/sanitized_names.txt   | 24 +++++++++++++++++++
 3 files changed, 48 insertions(+), 8 deletions(-)
 create mode 100644 exporters/prometheus/testdata/sanitized_names.txt

diff --git a/exporters/prometheus/exporter.go b/exporters/prometheus/exporter.go
index a548de3523c..5a32d1c6096 100644
--- a/exporters/prometheus/exporter.go
+++ b/exporters/prometheus/exporter.go
@@ -145,7 +145,7 @@ func getHistogramMetricData(histogram metricdata.Histogram, m metricdata.Metrics
 	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)
+		desc := prometheus.NewDesc(sanitizeName(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]
@@ -168,7 +168,7 @@ func getSumMetricData[N int64 | float64](sum metricdata.Sum[N], m metricdata.Met
 	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)
+		desc := prometheus.NewDesc(sanitizeName(m.Name), m.Description, keys, nil)
 		md := &metricData{
 			name:            m.Name,
 			description:     desc,
@@ -185,7 +185,7 @@ func getGaugeMetricData[N int64 | float64](gauge metricdata.Gauge[N], m metricda
 	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)
+		desc := prometheus.NewDesc(sanitizeName(m.Name), m.Description, keys, nil)
 		md := &metricData{
 			name:            m.Name,
 			description:     desc,
@@ -233,3 +233,19 @@ func sanitizeRune(r rune) rune {
 	}
 	return '_'
 }
+
+func sanitizeName(n string) string {
+	if len(n) == 0 {
+		return n
+	}
+	var sn = make([]string, len(n))
+	for i, b := range n {
+		if !((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0)) {
+			sn[i] = "_"
+		} else {
+			sn[i] = string(b)
+		}
+	}
+
+	return strings.Join(sn, "")
+}
diff --git a/exporters/prometheus/exporter_test.go b/exporters/prometheus/exporter_test.go
index 32733a7a81c..b88bf937bc1 100644
--- a/exporters/prometheus/exporter_test.go
+++ b/exporters/prometheus/exporter_test.go
@@ -106,8 +106,8 @@ func TestPrometheusExporter(t *testing.T) {
 			},
 		},
 		{
-			name:         "invalid instruments are dropped",
-			expectedFile: "testdata/gauge.txt",
+			name:         "invalid instruments are renamed",
+			expectedFile: "testdata/sanitized_names.txt",
 			recordMetrics: func(ctx context.Context, meter otelmetric.Meter) {
 				attrs := []attribute.KeyValue{
 					attribute.Key("A").String("B"),
@@ -120,15 +120,15 @@ func TestPrometheusExporter(t *testing.T) {
 				gauge.Add(ctx, -25, attrs...)
 
 				// Invalid, should be dropped.
-				gauge, err = meter.SyncFloat64().UpDownCounter("invalid.gauge.name")
+				gauge, err = meter.SyncFloat64().UpDownCounter("invalid.gauge.name", instrument.WithDescription("a gauge with an invalid name"))
 				require.NoError(t, err)
 				gauge.Add(ctx, 100, attrs...)
 
-				counter, err := meter.SyncFloat64().Counter("invalid.counter.name")
+				counter, err := meter.SyncFloat64().Counter("invalid.counter.name", instrument.WithDescription("a counter with an invalid name"))
 				require.NoError(t, err)
 				counter.Add(ctx, 100, attrs...)
 
-				histogram, err := meter.SyncFloat64().Histogram("invalid.hist.name")
+				histogram, err := meter.SyncFloat64().Histogram("invalid.hist.name", instrument.WithDescription("a histogram with an invalid name"))
 				require.NoError(t, err)
 				histogram.Record(ctx, 23, attrs...)
 			},
diff --git a/exporters/prometheus/testdata/sanitized_names.txt b/exporters/prometheus/testdata/sanitized_names.txt
new file mode 100644
index 00000000000..03bc9268f62
--- /dev/null
+++ b/exporters/prometheus/testdata/sanitized_names.txt
@@ -0,0 +1,24 @@
+# HELP bar a fun little gauge
+# TYPE bar counter
+bar{A="B",C="D"} 75
+# HELP invalid_counter_name a counter with an invalid name
+# TYPE invalid_counter_name counter
+invalid_counter_name{A="B",C="D"} 100
+# HELP invalid_gauge_name a gauge with an invalid name
+# TYPE invalid_gauge_name counter
+invalid_gauge_name{A="B",C="D"} 100
+# HELP invalid_hist_name a histogram with an invalid name
+# TYPE invalid_hist_name histogram
+invalid_hist_name_bucket{A="B",C="D",le="0"} 0
+invalid_hist_name_bucket{A="B",C="D",le="5"} 0
+invalid_hist_name_bucket{A="B",C="D",le="10"} 0
+invalid_hist_name_bucket{A="B",C="D",le="25"} 1
+invalid_hist_name_bucket{A="B",C="D",le="50"} 0
+invalid_hist_name_bucket{A="B",C="D",le="75"} 0
+invalid_hist_name_bucket{A="B",C="D",le="100"} 0
+invalid_hist_name_bucket{A="B",C="D",le="250"} 0
+invalid_hist_name_bucket{A="B",C="D",le="500"} 0
+invalid_hist_name_bucket{A="B",C="D",le="1000"} 0
+invalid_hist_name_bucket{A="B",C="D",le="+Inf"} 1
+invalid_hist_name_sum{A="B",C="D"} 23
+invalid_hist_name_count{A="B",C="D"} 1

From 3adba391e5942d574d69c8f8186ba7eab00745d3 Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 10:46:20 +0200
Subject: [PATCH 2/9] add changelog entry

---
 CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40953d25784..4a7c6b59a2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 - The metric SDK in `go.opentelemetry.io/otel/sdk/metric` is completely refactored to comply with the OpenTelemetry specification.
   Please see the package documentation for how the new SDK is initialized and configured. (#3175)
 - Update the minimum supported go version to go1.18. Removes support for go1.17 (#3179)
+- Instead of dropping metric, the Prometheus exporter will replace any invalid character in metric names with `_`. (#3212)
 
 ### Removed
 

From ddd7cc6e2f6d2b4e4208d5195c70c0afbd463c33 Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 11:09:24 +0200
Subject: [PATCH 3/9] fix now invalid comment

---
 exporters/prometheus/exporter_test.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/exporters/prometheus/exporter_test.go b/exporters/prometheus/exporter_test.go
index b88bf937bc1..1d3426fa9b3 100644
--- a/exporters/prometheus/exporter_test.go
+++ b/exporters/prometheus/exporter_test.go
@@ -119,7 +119,7 @@ func TestPrometheusExporter(t *testing.T) {
 				gauge.Add(ctx, 100, attrs...)
 				gauge.Add(ctx, -25, attrs...)
 
-				// Invalid, should be dropped.
+				// Invalid, will be renamed.
 				gauge, err = meter.SyncFloat64().UpDownCounter("invalid.gauge.name", instrument.WithDescription("a gauge with an invalid name"))
 				require.NoError(t, err)
 				gauge.Add(ctx, 100, attrs...)

From f0fa2f1bd258959ec6f8bc911a3f96bc4fce4036 Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 17:37:29 +0200
Subject: [PATCH 4/9] replace sanitizeName algorithm with one based on
 strings.Map

---
 exporters/prometheus/exporter.go | 46 +++++++++++++++++++++++++++-----
 1 file changed, 39 insertions(+), 7 deletions(-)

diff --git a/exporters/prometheus/exporter.go b/exporters/prometheus/exporter.go
index 5a32d1c6096..bc46df5bc5c 100644
--- a/exporters/prometheus/exporter.go
+++ b/exporters/prometheus/exporter.go
@@ -235,17 +235,49 @@ func sanitizeRune(r rune) rune {
 }
 
 func sanitizeName(n string) string {
-	if len(n) == 0 {
+	// This algorithm is based on strings.Map from Go 1.19.
+	const (
+		replacement = '_'
+		width       = 1
+	)
+
+	valid := func(i int, r rune) bool {
+		// Taken from
+		// https://github.com/prometheus/common/blob/dfbc25bd00225c70aca0d94c3c4bb7744f28ace0/model/metric.go#L92-L102
+		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || r == ':' || (r >= '0' && r <= '9' && i > 0) {
+			return true
+		}
+		return false
+	}
+
+	// This output buffer b is initialized on demand, the first time a character
+	// needs to be replaced.
+	var b strings.Builder
+	for i, c := range n {
+		if valid(i, c) {
+			continue
+		}
+		b.Grow(len(n))
+		b.WriteString(n[:i])
+		b.WriteRune(replacement)
+		n = n[i+width:]
+		break
+	}
+
+	// Fast path for unchanged input.
+	if b.Cap() == 0 { // b.Grow was not called above.
 		return n
 	}
-	var sn = make([]string, len(n))
-	for i, b := range n {
-		if !((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0)) {
-			sn[i] = "_"
+
+	for _, c := range n {
+		// Due to inlining, it is more performant to invoke WriteByte rather then
+		// WriteRune.
+		if valid(1, c) { // We are guaranteed to not be at the start.
+			b.WriteByte(byte(c))
 		} else {
-			sn[i] = string(b)
+			b.WriteByte(byte(replacement))
 		}
 	}
 
-	return strings.Join(sn, "")
+	return b.String()
 }

From bc12d2e9010a0215453de64d634aefbe147df675 Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 17:46:01 +0200
Subject: [PATCH 5/9] check that digits as first characters are replaced

---
 exporters/prometheus/exporter_test.go             | 2 +-
 exporters/prometheus/testdata/sanitized_names.txt | 6 +++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/exporters/prometheus/exporter_test.go b/exporters/prometheus/exporter_test.go
index 1d3426fa9b3..7b9986319b9 100644
--- a/exporters/prometheus/exporter_test.go
+++ b/exporters/prometheus/exporter_test.go
@@ -124,7 +124,7 @@ func TestPrometheusExporter(t *testing.T) {
 				require.NoError(t, err)
 				gauge.Add(ctx, 100, attrs...)
 
-				counter, err := meter.SyncFloat64().Counter("invalid.counter.name", instrument.WithDescription("a counter with an invalid name"))
+				counter, err := meter.SyncFloat64().Counter("0invalid.counter.name", instrument.WithDescription("a counter with an invalid name"))
 				require.NoError(t, err)
 				counter.Add(ctx, 100, attrs...)
 
diff --git a/exporters/prometheus/testdata/sanitized_names.txt b/exporters/prometheus/testdata/sanitized_names.txt
index 03bc9268f62..1bf66fca8b2 100644
--- a/exporters/prometheus/testdata/sanitized_names.txt
+++ b/exporters/prometheus/testdata/sanitized_names.txt
@@ -1,9 +1,9 @@
 # HELP bar a fun little gauge
 # TYPE bar counter
 bar{A="B",C="D"} 75
-# HELP invalid_counter_name a counter with an invalid name
-# TYPE invalid_counter_name counter
-invalid_counter_name{A="B",C="D"} 100
+# HELP _invalid_counter_name a counter with an invalid name
+# TYPE _invalid_counter_name counter
+_invalid_counter_name{A="B",C="D"} 100
 # HELP invalid_gauge_name a gauge with an invalid name
 # TYPE invalid_gauge_name counter
 invalid_gauge_name{A="B",C="D"} 100

From 30abe6786a1127c8543361105660f530d519ae1c Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 19:24:46 +0200
Subject: [PATCH 6/9] Update exporters/prometheus/exporter.go

Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>
---
 exporters/prometheus/exporter.go | 19 ++++++++++++-------
 1 file changed, 12 insertions(+), 7 deletions(-)

diff --git a/exporters/prometheus/exporter.go b/exporters/prometheus/exporter.go
index bc46df5bc5c..a3055c319d0 100644
--- a/exporters/prometheus/exporter.go
+++ b/exporters/prometheus/exporter.go
@@ -236,10 +236,7 @@ func sanitizeRune(r rune) rune {
 
 func sanitizeName(n string) string {
 	// This algorithm is based on strings.Map from Go 1.19.
-	const (
-		replacement = '_'
-		width       = 1
-	)
+	const replacement = '_'
 
 	valid := func(i int, r rune) bool {
 		// Taken from
@@ -250,16 +247,24 @@ func sanitizeName(n string) string {
 		return false
 	}
 
-	// This output buffer b is initialized on demand, the first time a character
-	// needs to be replaced.
+	// This output buffer b is initialized on demand, the first time a
+	// character needs to be replaced.
 	var b strings.Builder
 	for i, c := range n {
 		if valid(i, c) {
 			continue
 		}
+
+		if i == 0 && c >= '0' && c <= '9' {
+			// Prefix leading number with replacement character.
+			b.Grow(len(n) + 1)
+			b.WriteByte(byte(replacement))
+			break
+		}
 		b.Grow(len(n))
 		b.WriteString(n[:i])
-		b.WriteRune(replacement)
+		b.WriteByte(byte(replacement))
+		width := utf8.RuneLen(c)
 		n = n[i+width:]
 		break
 	}

From 0ad5975d701e358ce70fffd2714f5496b82b132e Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 19:26:42 +0200
Subject: [PATCH 7/9] add missing utf8 import

---
 exporters/prometheus/exporter.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/exporters/prometheus/exporter.go b/exporters/prometheus/exporter.go
index a3055c319d0..ab51c8f9631 100644
--- a/exporters/prometheus/exporter.go
+++ b/exporters/prometheus/exporter.go
@@ -22,6 +22,7 @@ import (
 	"sort"
 	"strings"
 	"unicode"
+	"unicode/utf8"
 
 	"github.com/prometheus/client_golang/prometheus"
 

From a38727c0156fe067f7ea6a3bedc353db8f82e123 Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 19:26:50 +0200
Subject: [PATCH 8/9] add explicit tests for sanitizeName

---
 exporters/prometheus/exporter_test.go | 30 +++++++++++++++++++++++++++
 1 file changed, 30 insertions(+)

diff --git a/exporters/prometheus/exporter_test.go b/exporters/prometheus/exporter_test.go
index 7b9986319b9..2fe3faf8816 100644
--- a/exporters/prometheus/exporter_test.go
+++ b/exporters/prometheus/exporter_test.go
@@ -158,3 +158,33 @@ func TestPrometheusExporter(t *testing.T) {
 		})
 	}
 }
+
+func TestSantitizeName(t *testing.T) {
+	tests := []struct {
+		input string
+		want  string
+	}{
+		{"nam€_with_3_width_rune", "nam__with_3_width_rune"},
+		{"`", "_"},
+		{
+			`! "#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWKYZ[]\^_abcdefghijklmnopqrstuvwkyz{|}~`,
+			`________________0123456789:______ABCDEFGHIJKLMNOPQRSTUVWKYZ_____abcdefghijklmnopqrstuvwkyz____`,
+		},
+
+		// Test cases taken from
+		// https://github.com/prometheus/common/blob/dfbc25bd00225c70aca0d94c3c4bb7744f28ace0/model/metric_test.go#L85-L136
+		{"Avalid_23name", "Avalid_23name"},
+		{"_Avalid_23name", "_Avalid_23name"},
+		{"1valid_23name", "_1valid_23name"},
+		{"avalid_23name", "avalid_23name"},
+		{"Ava:lid_23name", "Ava:lid_23name"},
+		{"a lid_23name", "a_lid_23name"},
+		{":leading_colon", ":leading_colon"},
+		{"colon:in:the:middle", "colon:in:the:middle"},
+		{"", ""},
+	}
+
+	for _, test := range tests {
+		require.Equalf(t, test.want, sanitizeName(test.input), "input: %q", test.input)
+	}
+}

From 8e8fdd87ab67803de9f7de5d947a78423a4b76ab Mon Sep 17 00:00:00 2001
From: Damien Mathieu <42@dmathieu.com>
Date: Wed, 21 Sep 2022 19:31:42 +0200
Subject: [PATCH 9/9] fix testdata with digit prepend

---
 exporters/prometheus/testdata/sanitized_names.txt | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/exporters/prometheus/testdata/sanitized_names.txt b/exporters/prometheus/testdata/sanitized_names.txt
index 1bf66fca8b2..5e9516716ae 100644
--- a/exporters/prometheus/testdata/sanitized_names.txt
+++ b/exporters/prometheus/testdata/sanitized_names.txt
@@ -1,9 +1,9 @@
 # HELP bar a fun little gauge
 # TYPE bar counter
 bar{A="B",C="D"} 75
-# HELP _invalid_counter_name a counter with an invalid name
-# TYPE _invalid_counter_name counter
-_invalid_counter_name{A="B",C="D"} 100
+# HELP _0invalid_counter_name a counter with an invalid name
+# TYPE _0invalid_counter_name counter
+_0invalid_counter_name{A="B",C="D"} 100
 # HELP invalid_gauge_name a gauge with an invalid name
 # TYPE invalid_gauge_name counter
 invalid_gauge_name{A="B",C="D"} 100