Repository: prometheus/statsd_exporter
Pull request #289 (2020-01-16 13:49:38)

Move escapeMetricName to mapper
https://github.com/prometheus/statsd_exporter/issues/234#issuecomment-501206903

I've copy pasted this into multiple personal projects as well, so let's export this.

We could integrate it more into mapping later.

matthiasr: the new files are lacking the license header, could you add it please?

Patch:
diff --git a/exporter.go b/exporter.go
index c70a12e..e3984b7 100644
--- a/exporter.go
+++ b/exporter.go
@@ -54,61 +54,6 @@ type Exporter struct {
 	logger   log.Logger
 }
 
-// Replace invalid characters in the metric name with "_"
-// Valid characters are a-z, A-Z, 0-9, and _
-func escapeMetricName(metricName string) string {
-	metricLen := len(metricName)
-	if metricLen == 0 {
-		return ""
-	}
-
-	escaped := false
-	var sb strings.Builder
-	// If a metric starts with a digit, allocate the memory and prepend an
-	// underscore.
-	if metricName[0] >= '0' && metricName[0] <= '9' {
-		escaped = true
-		sb.Grow(metricLen + 1)
-		sb.WriteByte('_')
-	}
-
-	// This is an character replacement method optimized for this limited
-	// use case.  It is much faster than using a regex.
-	offset := 0
-	for i, c := range metricName {
-		// Seek forward, skipping valid characters until we find one that needs
-		// to be replaced, then add all the characters we've seen so far to the
-		// string.Builder.
-		if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
-			(c >= '0' && c <= '9') || (c == '_') {
-			// Character is valid, so skip over it without doing anything.
-		} else {
-			if !escaped {
-				// Up until now we've been lazy and avoided actually allocating
-				// memory.  Unfortunately we've now determined this string needs
-				// escaping, so allocate the buffer for the whole string.
-				escaped = true
-				sb.Grow(metricLen)
-			}
-			sb.WriteString(metricName[offset:i])
-			offset = i + utf8.RuneLen(c)
-			sb.WriteByte('_')
-		}
-	}
-
-	if !escaped {
-		// This is the happy path where nothing had to be escaped, so we can
-		// avoid doing anything.
-		return metricName
-	}
-
-	if offset < metricLen {
-		sb.WriteString(metricName[offset:])
-	}
-
-	return sb.String()
-}
-
 // Listen handles all events sent to the given channel sequentially. It
 // terminates when the channel is closed.
 func (b *Exporter) Listen(e <-chan Events) {
@@ -159,14 +104,14 @@ func (b *Exporter) handleEvent(event Event) {
 			errorEventStats.WithLabelValues("empty_metric_name").Inc()
 			return
 		}
-		metricName = escapeMetricName(mapping.Name)
+		metricName = mapper.EscapeMetricName(mapping.Name)
 		for label, value := range labels {
 			prometheusLabels[label] = value
 		}
 		eventsActions.WithLabelValues(string(mapping.Action)).Inc()
 	} else {
 		eventsUnmapped.Inc()
-		metricName = escapeMetricName(event.MetricName())
+		metricName = mapper.EscapeMetricName(event.MetricName())
 	}
 
 	switch ev := event.(type) {
@@ -298,7 +243,7 @@ func parseTag(component, tag string, separator rune, labels map[string]string, l
 				tagErrors.Inc()
 				level.Debug(logger).Log("msg", "Malformed name tag", "k", k, "v", v, "component", component)
 			} else {
-				labels[escapeMetricName(k)] = v
+				labels[mapper.EscapeMetricName(k)] = v
 			}
 			return
 		}
diff --git a/pkg/mapper/escape.go b/pkg/mapper/escape.go
new file mode 100644
index 0000000..fc8d194
--- /dev/null
+++ b/pkg/mapper/escape.go
@@ -0,0 +1,74 @@
+// Copyright 2020 The Prometheus 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 mapper
+
+import (
+	"strings"
+	"unicode/utf8"
+)
+
+// EscapeMetricName replaces invalid characters in the metric name with "_"
+// Valid characters are a-z, A-Z, 0-9, and _
+func EscapeMetricName(metricName string) string {
+	metricLen := len(metricName)
+	if metricLen == 0 {
+		return ""
+	}
+
+	escaped := false
+	var sb strings.Builder
+	// If a metric starts with a digit, allocate the memory and prepend an
+	// underscore.
+	if metricName[0] >= '0' && metricName[0] <= '9' {
+		escaped = true
+		sb.Grow(metricLen + 1)
+		sb.WriteByte('_')
+	}
+
+	// This is an character replacement method optimized for this limited
+	// use case.  It is much faster than using a regex.
+	offset := 0
+	for i, c := range metricName {
+		// Seek forward, skipping valid characters until we find one that needs
+		// to be replaced, then add all the characters we've seen so far to the
+		// string.Builder.
+		if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+			(c >= '0' && c <= '9') || (c == '_') {
+			// Character is valid, so skip over it without doing anything.
+		} else {
+			if !escaped {
+				// Up until now we've been lazy and avoided actually allocating
+				// memory.  Unfortunately we've now determined this string needs
+				// escaping, so allocate the buffer for the whole string.
+				escaped = true
+				sb.Grow(metricLen)
+			}
+			sb.WriteString(metricName[offset:i])
+			offset = i + utf8.RuneLen(c)
+			sb.WriteByte('_')
+		}
+	}
+
+	if !escaped {
+		// This is the happy path where nothing had to be escaped, so we can
+		// avoid doing anything.
+		return metricName
+	}
+
+	if offset < metricLen {
+		sb.WriteString(metricName[offset:])
+	}
+
+	return sb.String()
+}


Test patch:
diff --git a/exporter_test.go b/exporter_test.go
index ffc1d27..c64a5a2 100644
--- a/exporter_test.go
+++ b/exporter_test.go
@@ -682,25 +682,6 @@ func (ml *mockStatsDTCPListener) handlePacket(packet []byte) {
 	ml.handleConn(sc)
 }
 
-func TestEscapeMetricName(t *testing.T) {
-	scenarios := map[string]string{
-		"clean":                   "clean",
-		"0starts_with_digit":      "_0starts_with_digit",
-		"with_underscore":         "with_underscore",
-		"with.dot":                "with_dot",
-		"with😱emoji":              "with_emoji",
-		"with.*.multiple":         "with___multiple",
-		"test.web-server.foo.bar": "test_web_server_foo_bar",
-		"":                        "",
-	}
-
-	for in, want := range scenarios {
-		if got := escapeMetricName(in); want != got {
-			t.Errorf("expected `%s` to be escaped to `%s`, got `%s`", in, want, got)
-		}
-	}
-}
-
 // TestTtlExpiration validates expiration of time series.
 // foobar metric without mapping should expire with default ttl of 1s
 // bazqux metric should expire with ttl of 2s
@@ -922,27 +903,6 @@ func getTelemetryCounterValue(counter prometheus.Counter) float64 {
 	return metric.Counter.GetValue()
 }
 
-func BenchmarkEscapeMetricName(b *testing.B) {
-	scenarios := []string{
-		"clean",
-		"0starts_with_digit",
-		"with_underscore",
-		"with.dot",
-		"with😱emoji",
-		"with.*.multiple",
-		"test.web-server.foo.bar",
-		"",
-	}
-
-	for _, s := range scenarios {
-		b.Run(s, func(b *testing.B) {
-			for n := 0; n < b.N; n++ {
-				escapeMetricName(s)
-			}
-		})
-	}
-}
-
 func BenchmarkParseDogStatsDTags(b *testing.B) {
 	scenarios := map[string]string{
 		"1 tag w/hash":         "#test:tag",
diff --git a/pkg/mapper/escape_test.go b/pkg/mapper/escape_test.go
new file mode 100644
index 0000000..336692d
--- /dev/null
+++ b/pkg/mapper/escape_test.go
@@ -0,0 +1,56 @@
+// Copyright 2020 The Prometheus 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 mapper
+
+import "testing"
+
+func TestEscapeMetricName(t *testing.T) {
+	scenarios := map[string]string{
+		"clean":                   "clean",
+		"0starts_with_digit":      "_0starts_with_digit",
+		"with_underscore":         "with_underscore",
+		"with.dot":                "with_dot",
+		"with😱emoji":              "with_emoji",
+		"with.*.multiple":         "with___multiple",
+		"test.web-server.foo.bar": "test_web_server_foo_bar",
+		"":                        "",
+	}
+
+	for in, want := range scenarios {
+		if got := EscapeMetricName(in); want != got {
+			t.Errorf("expected `%s` to be escaped to `%s`, got `%s`", in, want, got)
+		}
+	}
+}
+
+func BenchmarkEscapeMetricName(b *testing.B) {
+	scenarios := []string{
+		"clean",
+		"0starts_with_digit",
+		"with_underscore",
+		"with.dot",
+		"with😱emoji",
+		"with.*.multiple",
+		"test.web-server.foo.bar",
+		"",
+	}
+
+	for _, s := range scenarios {
+		b.Run(s, func(b *testing.B) {
+			for n := 0; n < b.N; n++ {
+				EscapeMetricName(s)
+			}
+		})
+	}
+}