Repository: prometheus/common
Pull request #768 (2025-03-12 17:55:13)

otlptranslator: Add dependency free package that translates OTLP data into Prometheus metric/label names
Moving https://github.com/ArthurSens/otlp-prometheus-translator into this repository.

Its intended usage is to replace duplicated code between several projects, like Prometheus, OpenTelemetry-Collector, and Thanos, that accept an OTLP metric and return valid Prometheus metric/label names.

The code is modified, so we're dependency-free instead of depending on OTel libraries like the original ones.


Patch:
diff --git a/otlptranslator/constants.go b/otlptranslator/constants.go
new file mode 100644
index 0000000..d719daa
--- /dev/null
+++ b/otlptranslator/constants.go
@@ -0,0 +1,58 @@
+// Copyright 2025 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 otlptranslator
+
+const (
+	// MetricMetadataTypeKey is the key used to store the original Prometheus
+	// type in metric metadata:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/e6eccba97ebaffbbfad6d4358408a2cead0ec2df/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata
+	MetricMetadataTypeKey = "prometheus.type"
+	// ExemplarTraceIDKey is the key used to store the trace ID in Prometheus
+	// exemplars:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/e6eccba97ebaffbbfad6d4358408a2cead0ec2df/specification/compatibility/prometheus_and_openmetrics.md#exemplars
+	ExemplarTraceIDKey = "trace_id"
+	// ExemplarSpanIDKey is the key used to store the Span ID in Prometheus
+	// exemplars:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/e6eccba97ebaffbbfad6d4358408a2cead0ec2df/specification/compatibility/prometheus_and_openmetrics.md#exemplars
+	ExemplarSpanIDKey = "span_id"
+	// ScopeInfoMetricName is the name of the metric used to preserve scope
+	// attributes in Prometheus format:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/e6eccba97ebaffbbfad6d4358408a2cead0ec2df/specification/compatibility/prometheus_and_openmetrics.md#instrumentation-scope
+	ScopeInfoMetricName = "otel_scope_info"
+	// ScopeNameLabelKey is the name of the label key used to identify the name
+	// of the OpenTelemetry scope which produced the metric:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/e6eccba97ebaffbbfad6d4358408a2cead0ec2df/specification/compatibility/prometheus_and_openmetrics.md#instrumentation-scope
+	ScopeNameLabelKey = "otel_scope_name"
+	// ScopeVersionLabelKey is the name of the label key used to identify the
+	// version of the OpenTelemetry scope which produced the metric:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/e6eccba97ebaffbbfad6d4358408a2cead0ec2df/specification/compatibility/prometheus_and_openmetrics.md#instrumentation-scope
+	ScopeVersionLabelKey = "otel_scope_version"
+	// TargetInfoMetricName is the name of the metric used to preserve resource
+	// attributes in Prometheus format:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/e6eccba97ebaffbbfad6d4358408a2cead0ec2df/specification/compatibility/prometheus_and_openmetrics.md#resource-attributes-1
+	// It originates from OpenMetrics:
+	// https://github.com/OpenObservability/OpenMetrics/blob/1386544931307dff279688f332890c31b6c5de36/specification/OpenMetrics.md#supporting-target-metadata-in-both-push-based-and-pull-based-systems
+	TargetInfoMetricName = "target_info"
+)
+
+type MetricType string
+
+const (
+	MetricTypeNonMonotonicCounter  MetricType = "non-monotonic-counter"
+	MetricTypeMonotonicCounter     MetricType = "monotonic-counter"
+	MetricTypeGauge                MetricType = "gauge"
+	MetricTypeHistogram            MetricType = "histogram"
+	MetricTypeExponentialHistogram MetricType = "exponential-histogram"
+	MetricTypeSummary              MetricType = "summary"
+	MetricTypeUnknown              MetricType = "unknown"
+)
diff --git a/otlptranslator/doc.go b/otlptranslator/doc.go
new file mode 100644
index 0000000..88a7fed
--- /dev/null
+++ b/otlptranslator/doc.go
@@ -0,0 +1,24 @@
+// Copyright 2025 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.
+
+// otlptranslator is a dependency free package that contains the logic for translating information, such as metric name, unit and type,
+// from OpenTelemetry metrics to valid Prometheus metric and label names.
+//
+// Use BuildCompliantMetricName to build a metric name that complies with traditional Prometheus naming conventions.
+// Such conventions exist from a time when Prometheus didn't have support for full UTF-8 characters in metric names.
+// For more details see: https://prometheus.io/docs/practices/naming/
+//
+// Use BuildMetricName to build a metric name that will be accepted by Prometheus with full UTF-8 support.
+//
+// Use NormalizeLabel to normalize a label name to a valid format that can be used in Prometheus before UTF-8 characters were supported.
+package otlptranslator
diff --git a/otlptranslator/label_builder.go b/otlptranslator/label_builder.go
new file mode 100644
index 0000000..deb3bbd
--- /dev/null
+++ b/otlptranslator/label_builder.go
@@ -0,0 +1,54 @@
+// Copyright 2025 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 otlptranslator
+
+import (
+	"regexp"
+	"strings"
+	"unicode"
+)
+
+var invalidLabelCharRE = regexp.MustCompile(`[^a-zA-Z0-9_]`)
+
+// Normalizes the specified label to follow Prometheus label names standard.
+//
+// See rules at https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels.
+//
+// Labels that start with non-letter rune will be prefixed with "key_".
+// An exception is made for double-underscores which are allowed.
+func NormalizeLabel(label string) string {
+	// Trivial case.
+	if len(label) == 0 {
+		return label
+	}
+
+	label = SanitizeLabelName(label)
+
+	// If label starts with a number, prepend with "key_".
+	if unicode.IsDigit(rune(label[0])) {
+		label = "key_" + label
+	} else if strings.HasPrefix(label, "_") && !strings.HasPrefix(label, "__") {
+		label = "key" + label
+	}
+
+	return label
+}
+
+// SanitizeLabelName replaces anything that doesn't match
+// client_label.LabelNameRE with an underscore.
+// Note: this does not handle all Prometheus label name restrictions (such as
+// not starting with a digit 0-9), and hence should only be used if the label
+// name is prefixed with a known valid string.
+func SanitizeLabelName(name string) string {
+	return invalidLabelCharRE.ReplaceAllString(name, "_")
+}
diff --git a/otlptranslator/metric_name_builder.go b/otlptranslator/metric_name_builder.go
new file mode 100644
index 0000000..a6b7d27
--- /dev/null
+++ b/otlptranslator/metric_name_builder.go
@@ -0,0 +1,286 @@
+// Copyright 2025 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 otlptranslator
+
+import (
+	"regexp"
+	"slices"
+	"strings"
+	"unicode"
+)
+
+// The map to translate OTLP units to Prometheus units
+// OTLP metrics use the c/s notation as specified at https://ucum.org/ucum.html
+// (See also https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/README.md#instrument-units)
+// Prometheus best practices for units: https://prometheus.io/docs/practices/naming/#base-units
+// OpenMetrics specification for units: https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#units-and-base-units
+var unitMap = map[string]string{
+	// Time
+	"d":   "days",
+	"h":   "hours",
+	"min": "minutes",
+	"s":   "seconds",
+	"ms":  "milliseconds",
+	"us":  "microseconds",
+	"ns":  "nanoseconds",
+
+	// Bytes
+	"By":   "bytes",
+	"KiBy": "kibibytes",
+	"MiBy": "mebibytes",
+	"GiBy": "gibibytes",
+	"TiBy": "tibibytes",
+	"KBy":  "kilobytes",
+	"MBy":  "megabytes",
+	"GBy":  "gigabytes",
+	"TBy":  "terabytes",
+
+	// SI
+	"m": "meters",
+	"V": "volts",
+	"A": "amperes",
+	"J": "joules",
+	"W": "watts",
+	"g": "grams",
+
+	// Misc
+	"Cel": "celsius",
+	"Hz":  "hertz",
+	"1":   "",
+	"%":   "percent",
+}
+
+// The map that translates the "per" unit
+// Example: s => per second (singular)
+var perUnitMap = map[string]string{
+	"s":  "second",
+	"m":  "minute",
+	"h":  "hour",
+	"d":  "day",
+	"w":  "week",
+	"mo": "month",
+	"y":  "year",
+}
+
+var (
+	nonMetricNameCharRE = regexp.MustCompile(`[^a-zA-Z0-9:]`)
+	// Regexp for metric name characters that should be replaced with _.
+	invalidMetricCharRE   = regexp.MustCompile(`[^a-zA-Z0-9:_]`)
+	multipleUnderscoresRE = regexp.MustCompile(`__+`)
+)
+
+// BuildMetricName builds a valid metric name but without following Prometheus naming conventions.
+// It doesn't do any character transformation, it only prefixes the metric name with the namespace, if any,
+// and adds metric type suffixes, e.g. "_total" for counters and unit suffixes.
+//
+// Differently from BuildCompliantMetricName, it doesn't check for the presence of unit and type suffixes.
+// If "addMetricSuffixes" is true, it will add them anyway.
+//
+// Please use BuildCompliantMetricName for a metric name that follows Prometheus naming conventions.
+func BuildMetricName(name, unit string, metricType MetricType, addMetricSuffixes bool) string {
+	if addMetricSuffixes {
+		mainUnitSuffix, perUnitSuffix := buildUnitSuffixes(unit)
+		if mainUnitSuffix != "" {
+			name = name + "_" + mainUnitSuffix
+		}
+		if perUnitSuffix != "" {
+			name = name + "_" + perUnitSuffix
+		}
+
+		// Append _total for Counters
+		if metricType == MetricTypeMonotonicCounter {
+			name = name + "_total"
+		}
+
+		// Append _ratio for metrics with unit "1"
+		// Some OTel receivers improperly use unit "1" for counters of objects
+		// See https://github.com/open-telemetry/opentelemetry-collector-contrib/issues?q=is%3Aissue+some+metric+units+don%27t+follow+otel+semantic+conventions
+		// Until these issues have been fixed, we're appending `_ratio` for gauges ONLY
+		// Theoretically, counters could be ratios as well, but it's absurd (for mathematical reasons)
+		if unit == "1" && metricType == MetricTypeGauge {
+			name = name + "_ratio"
+		}
+	}
+	return name
+}
+
+// BuildCompliantMetricName builds a Prometheus-compliant metric name for the specified metric.
+//
+// Metric name is prefixed with specified namespace and underscore (if any).
+// Namespace is not cleaned up. Make sure specified namespace follows Prometheus
+// naming convention.
+//
+// See rules at https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels,
+// https://prometheus.io/docs/practices/naming/#metric-and-label-naming
+// and https://github.com/open-telemetry/opentelemetry-specification/blob/v1.38.0/specification/compatibility/prometheus_and_openmetrics.md#otlp-metric-points-to-prometheus.
+func BuildCompliantMetricName(name, unit string, metricType MetricType, addMetricSuffixes bool) string {
+	// Full normalization following standard Prometheus naming conventions
+	if addMetricSuffixes {
+		return normalizeName(name, unit, metricType)
+	}
+
+	// Simple case (no full normalization, no units, etc.).
+	metricName := strings.Join(strings.FieldsFunc(name, func(r rune) bool {
+		return invalidMetricCharRE.MatchString(string(r))
+	}), "_")
+
+	// Metric name starts with a digit? Prefix it with an underscore.
+	if metricName != "" && unicode.IsDigit(rune(metricName[0])) {
+		metricName = "_" + metricName
+	}
+
+	return metricName
+}
+
+// Build a normalized name for the specified metric.
+func normalizeName(metric, unit string, metricType MetricType) string {
+	// Split metric name into "tokens" (of supported metric name runes).
+	// Note that this has the side effect of replacing multiple consecutive underscores with a single underscore.
+	// This is part of the OTel to Prometheus specification: https://github.com/open-telemetry/opentelemetry-specification/blob/v1.38.0/specification/compatibility/prometheus_and_openmetrics.md#otlp-metric-points-to-prometheus.
+	nameTokens := strings.FieldsFunc(
+		metric,
+		func(r rune) bool { return nonMetricNameCharRE.MatchString(string(r)) },
+	)
+
+	mainUnitSuffix, perUnitSuffix := buildUnitSuffixes(unit)
+	nameTokens = addUnitTokens(nameTokens, CleanUpString(mainUnitSuffix), CleanUpString(perUnitSuffix))
+
+	// Append _total for Counters
+	if metricType == MetricTypeMonotonicCounter {
+		nameTokens = append(removeItem(nameTokens, "total"), "total")
+	}
+
+	// Append _ratio for metrics with unit "1"
+	// Some OTel receivers improperly use unit "1" for counters of objects
+	// See https://github.com/open-telemetry/opentelemetry-collector-contrib/issues?q=is%3Aissue+some+metric+units+don%27t+follow+otel+semantic+conventions
+	// Until these issues have been fixed, we're appending `_ratio` for gauges ONLY
+	// Theoretically, counters could be ratios as well, but it's absurd (for mathematical reasons)
+	if unit == "1" && metricType == MetricTypeGauge {
+		nameTokens = append(removeItem(nameTokens, "ratio"), "ratio")
+	}
+
+	// Build the string from the tokens, separated with underscores
+	normalizedName := strings.Join(nameTokens, "_")
+
+	// Metric name cannot start with a digit, so prefix it with "_" in this case
+	if normalizedName != "" && unicode.IsDigit(rune(normalizedName[0])) {
+		normalizedName = "_" + normalizedName
+	}
+
+	return normalizedName
+}
+
+// buildUnitSuffixes builds the main and per unit suffixes for the specified unit
+// but doesn't do any special character transformation to accommodate Prometheus naming conventions.
+// Removing trailing underscores or appending suffixes is done in the caller.
+func buildUnitSuffixes(unit string) (mainUnitSuffix, perUnitSuffix string) {
+	// Split unit at the '/' if any
+	unitTokens := strings.SplitN(unit, "/", 2)
+
+	if len(unitTokens) > 0 {
+		// Main unit
+		// Update if not blank and doesn't contain '{}'
+		mainUnitOTel := strings.TrimSpace(unitTokens[0])
+		if mainUnitOTel != "" && !strings.ContainsAny(mainUnitOTel, "{}") {
+			mainUnitSuffix = unitMapGetOrDefault(mainUnitOTel)
+		}
+
+		// Per unit
+		// Update if not blank and doesn't contain '{}'
+		if len(unitTokens) > 1 && unitTokens[1] != "" {
+			perUnitOTel := strings.TrimSpace(unitTokens[1])
+			if perUnitOTel != "" && !strings.ContainsAny(perUnitOTel, "{}") {
+				perUnitSuffix = perUnitMapGetOrDefault(perUnitOTel)
+			}
+			if perUnitSuffix != "" {
+				perUnitSuffix = "per_" + perUnitSuffix
+			}
+		}
+	}
+
+	return mainUnitSuffix, perUnitSuffix
+}
+
+// Retrieve the Prometheus "basic" unit corresponding to the specified "basic" unit
+// Returns the specified unit if not found in unitMap
+func unitMapGetOrDefault(unit string) string {
+	if promUnit, ok := unitMap[unit]; ok {
+		return promUnit
+	}
+	return unit
+}
+
+// Retrieve the Prometheus "per" unit corresponding to the specified "per" unit
+// Returns the specified unit if not found in perUnitMap
+func perUnitMapGetOrDefault(perUnit string) string {
+	if promPerUnit, ok := perUnitMap[perUnit]; ok {
+		return promPerUnit
+	}
+	return perUnit
+}
+
+// addUnitTokens will add the suffixes to the nameTokens if they are not already present.
+// It will also remove trailing underscores from the main suffix to avoid double underscores
+// when joining the tokens.
+//
+// If the 'per' unit ends with underscore, the underscore will be removed. If the per unit is just
+// 'per_', it will be entirely removed.
+func addUnitTokens(nameTokens []string, mainUnitSuffix, perUnitSuffix string) []string {
+	if slices.Contains(nameTokens, mainUnitSuffix) {
+		mainUnitSuffix = ""
+	}
+
+	if perUnitSuffix == "per_" {
+		perUnitSuffix = ""
+	} else {
+		perUnitSuffix = strings.TrimSuffix(perUnitSuffix, "_")
+		if slices.Contains(nameTokens, perUnitSuffix) {
+			perUnitSuffix = ""
+		}
+	}
+
+	if perUnitSuffix != "" {
+		mainUnitSuffix = strings.TrimSuffix(mainUnitSuffix, "_")
+	}
+
+	if mainUnitSuffix != "" {
+		nameTokens = append(nameTokens, mainUnitSuffix)
+	}
+	if perUnitSuffix != "" {
+		nameTokens = append(nameTokens, perUnitSuffix)
+	}
+	return nameTokens
+}
+
+// CleanUpString cleans up a string so it matches model.LabelNameRE.
+// CleanUpString is usually used to clean up unit strings, but can be used for any string, e.g. namespaces.
+func CleanUpString(s string) string {
+	// Multiple consecutive underscores are replaced with a single underscore.
+	// This is part of the OTel to Prometheus specification: https://github.com/open-telemetry/opentelemetry-specification/blob/v1.38.0/specification/compatibility/prometheus_and_openmetrics.md#otlp-metric-points-to-prometheus.
+	return strings.TrimPrefix(multipleUnderscoresRE.ReplaceAllString(
+		nonMetricNameCharRE.ReplaceAllString(s, "_"),
+		"_",
+	), "_")
+}
+
+// Remove the specified value from the slice
+func removeItem(slice []string, value string) []string {
+	newSlice := make([]string, 0, len(slice))
+	for _, sliceEntry := range slice {
+		if sliceEntry != value {
+			newSlice = append(newSlice, sliceEntry)
+		}
+	}
+	return newSlice
+}


Test patch:
diff --git a/otlptranslator/label_builder_bench_test.go b/otlptranslator/label_builder_bench_test.go
new file mode 100644
index 0000000..6f2ba12
--- /dev/null
+++ b/otlptranslator/label_builder_bench_test.go
@@ -0,0 +1,35 @@
+// Copyright 2025 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 otlptranslator
+
+import "testing"
+
+var labelBenchmarkInputs = []string{
+	"",
+	"label:with:colons",
+	"LabelWithCapitalLetters",
+	"label!with&special$chars)",
+	"label_with_foreign_characters_字符",
+	"label.with.dots",
+	"123label",
+	"_label_starting_with_underscore",
+	"__label_starting_with_2underscores",
+}
+
+func BenchmarkNormalizeLabel(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		for _, input := range labelBenchmarkInputs {
+			NormalizeLabel(input)
+		}
+	}
+}
diff --git a/otlptranslator/label_builder_test.go b/otlptranslator/label_builder_test.go
new file mode 100644
index 0000000..48856a2
--- /dev/null
+++ b/otlptranslator/label_builder_test.go
@@ -0,0 +1,44 @@
+// Copyright 2025 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 otlptranslator
+
+import (
+	"fmt"
+	"testing"
+)
+
+func TestNormalizeLabel(t *testing.T) {
+	tests := []struct {
+		label    string
+		expected string
+	}{
+		{"", ""},
+		{"label:with:colons", "label_with_colons"},
+		{"LabelWithCapitalLetters", "LabelWithCapitalLetters"},
+		{"label!with&special$chars)", "label_with_special_chars_"},
+		{"label_with_foreign_characters_字符", "label_with_foreign_characters___"},
+		{"label.with.dots", "label_with_dots"},
+		{"123label", "key_123label"},
+		{"_label_starting_with_underscore", "key_label_starting_with_underscore"},
+		{"__label_starting_with_2underscores", "__label_starting_with_2underscores"},
+	}
+
+	for i, test := range tests {
+		t.Run(fmt.Sprintf("test_%d", i), func(t *testing.T) {
+			result := NormalizeLabel(test.label)
+			if test.expected != result {
+				t.Errorf("expected %s, got %s", test.expected, result)
+			}
+		})
+	}
+}
diff --git a/otlptranslator/metric_name_builder_bench_test.go b/otlptranslator/metric_name_builder_bench_test.go
new file mode 100644
index 0000000..bc0851d
--- /dev/null
+++ b/otlptranslator/metric_name_builder_bench_test.go
@@ -0,0 +1,134 @@
+// Copyright 2025 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 otlptranslator
+
+import (
+	"fmt"
+	"testing"
+)
+
+var benchmarkInputs = []struct {
+	name       string
+	metricName string
+	unit       string
+	metricType MetricType
+}{
+	{
+		name:       "simple_metric",
+		metricName: "http_requests",
+		unit:       "",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "compound_unit",
+		metricName: "request_throughput",
+		unit:       "By/s",
+		metricType: MetricTypeMonotonicCounter,
+	},
+	{
+		name:       "complex_unit",
+		metricName: "disk_usage",
+		unit:       "KiBy/m",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "ratio_metric",
+		metricName: "cpu_utilization",
+		unit:       "1",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "metric_with_dots",
+		metricName: "system.cpu.usage.idle",
+		unit:       "%",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "metric_with_unicode",
+		metricName: "メモリ使用率",
+		unit:       "By",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "metric_with_special_chars",
+		metricName: "error-rate@host{instance}/service#component",
+		unit:       "ms",
+		metricType: MetricTypeMonotonicCounter,
+	},
+	{
+		name:       "metric_with_multiple_slashes",
+		metricName: "network/throughput/total",
+		unit:       "By/s/min",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "metric_with_spaces",
+		metricName: "api   response   time   total",
+		unit:       "ms",
+		metricType: MetricTypeMonotonicCounter,
+	},
+	{
+		name:       "metric_with_curly_braces",
+		metricName: "custom_{tag}_metric",
+		unit:       "{custom}/s",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "metric_starting_with_digit",
+		metricName: "5xx_error_count",
+		unit:       "1",
+		metricType: MetricTypeMonotonicCounter,
+	},
+	{
+		name:       "empty_metric",
+		metricName: "",
+		unit:       "",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "metric_with_SI_units",
+		metricName: "power_consumption",
+		unit:       "W",
+		metricType: MetricTypeGauge,
+	},
+	{
+		name:       "metric_with_temperature",
+		metricName: "server_temperature",
+		unit:       "Cel",
+		metricType: MetricTypeGauge,
+	},
+}
+
+func BenchmarkBuildMetricName(b *testing.B) {
+	for _, addSuffixes := range []bool{true, false} {
+		b.Run(fmt.Sprintf("with_metric_suffixes=%t", addSuffixes), func(b *testing.B) {
+			for _, input := range benchmarkInputs {
+				for i := 0; i < b.N; i++ {
+					BuildMetricName(input.metricName, input.unit, input.metricType, addSuffixes)
+				}
+			}
+		})
+	}
+}
+
+func BenchmarkBuildCompliantMetricName(b *testing.B) {
+	for _, addSuffixes := range []bool{true, false} {
+		b.Run(fmt.Sprintf("with_metric_suffixes=%t", addSuffixes), func(b *testing.B) {
+			for _, input := range benchmarkInputs {
+				for i := 0; i < b.N; i++ {
+					BuildCompliantMetricName(input.metricName, input.unit, input.metricType, addSuffixes)
+				}
+			}
+		})
+	}
+}
diff --git a/otlptranslator/metric_name_builder_test.go b/otlptranslator/metric_name_builder_test.go
new file mode 100644
index 0000000..49b729b
--- /dev/null
+++ b/otlptranslator/metric_name_builder_test.go
@@ -0,0 +1,428 @@
+// Copyright 2025 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 otlptranslator
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestBuildMetricName(t *testing.T) {
+	tests := []struct {
+		name              string
+		metricName        string
+		unit              string
+		metricType        MetricType
+		addMetricSuffixes bool
+		expected          string
+	}{
+		{
+			name:              "simple metric without suffixes",
+			metricName:        "http_requests",
+			unit:              "",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: false,
+			expected:          "http_requests",
+		},
+		{
+			name:              "counter with total suffix",
+			metricName:        "http_requests",
+			unit:              "",
+			metricType:        MetricTypeMonotonicCounter,
+			addMetricSuffixes: true,
+			expected:          "http_requests_total",
+		},
+		{
+			name:              "gauge with time unit",
+			metricName:        "request_duration",
+			unit:              "s",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "request_duration_seconds",
+		},
+		{
+			name:              "counter with time unit",
+			metricName:        "request_duration",
+			unit:              "ms",
+			metricType:        MetricTypeMonotonicCounter,
+			addMetricSuffixes: true,
+			expected:          "request_duration_milliseconds_total",
+		},
+		{
+			name:              "gauge with compound unit",
+			metricName:        "throughput",
+			unit:              "By/s",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "throughput_bytes_per_second",
+		},
+		{
+			name:              "ratio metric",
+			metricName:        "cpu_utilization",
+			unit:              "1",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "cpu_utilization_ratio",
+		},
+		{
+			name:              "counter with unit 1 (no ratio suffix)",
+			metricName:        "error_count",
+			unit:              "1",
+			metricType:        MetricTypeMonotonicCounter,
+			addMetricSuffixes: true,
+			expected:          "error_count_total",
+		},
+		{
+			name:              "metric with byte units",
+			metricName:        "memory_usage",
+			unit:              "MiBy",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "memory_usage_mebibytes",
+		},
+		{
+			name:              "metric with SI units",
+			metricName:        "temperature",
+			unit:              "Cel",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "temperature_celsius",
+		},
+		{
+			name:              "metric with dots",
+			metricName:        "system.cpu.usage",
+			unit:              "1",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "system.cpu.usage_ratio",
+		},
+		{
+			name:              "metric with japanese characters (memory usage rate)",
+			metricName:        "メモリ使用率", // memori shiyouritsu (memory usage rate) xD
+			unit:              "By",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "メモリ使用率_bytes",
+		},
+		{
+			name:              "metric with mixed special characters (system.memory.usage.rate)",
+			metricName:        "system.メモリ.usage.率", // system.memory.usage.rate
+			unit:              "By/s",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "system.メモリ.usage.率_bytes_per_second",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := BuildMetricName(tt.metricName, tt.unit, tt.metricType, tt.addMetricSuffixes)
+			if tt.expected != result {
+				t.Errorf("expected %s, got %s", tt.expected, result)
+			}
+		})
+	}
+}
+
+func TestBuildUnitSuffixes(t *testing.T) {
+	tests := []struct {
+		name            string
+		unit            string
+		expectedMain    string
+		expectedPerUnit string
+	}{
+		{
+			name:            "empty unit",
+			unit:            "",
+			expectedMain:    "",
+			expectedPerUnit: "",
+		},
+		{
+			name:            "simple time unit",
+			unit:            "s",
+			expectedMain:    "seconds",
+			expectedPerUnit: "",
+		},
+		{
+			name:            "compound unit",
+			unit:            "By/s",
+			expectedMain:    "bytes",
+			expectedPerUnit: "per_second",
+		},
+		{
+			name:            "complex compound unit",
+			unit:            "KiBy/m",
+			expectedMain:    "kibibytes",
+			expectedPerUnit: "per_minute",
+		},
+		{
+			name:            "unit with spaces",
+			unit:            " ms / s ",
+			expectedMain:    "milliseconds",
+			expectedPerUnit: "per_second",
+		},
+		{
+			name:            "invalid unit",
+			unit:            "invalid",
+			expectedMain:    "invalid",
+			expectedPerUnit: "",
+		},
+		{
+			name:            "unit with curly braces",
+			unit:            "{custom}/s",
+			expectedMain:    "",
+			expectedPerUnit: "per_second",
+		},
+		{
+			name:            "multiple slashes",
+			unit:            "By/s/h",
+			expectedMain:    "bytes",
+			expectedPerUnit: "per_s/h",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mainUnit, perUnit := buildUnitSuffixes(tt.unit)
+			if tt.expectedMain != mainUnit {
+				t.Errorf("expected main unit %s, got %s", tt.expectedMain, mainUnit)
+			}
+			if tt.expectedPerUnit != perUnit {
+				t.Errorf("expected per unit %s, got %s", tt.expectedPerUnit, perUnit)
+			}
+		})
+	}
+}
+
+func TestBuildCompliantMetricName(t *testing.T) {
+	tests := []struct {
+		name              string
+		metricName        string
+		unit              string
+		metricType        MetricType
+		addMetricSuffixes bool
+		expected          string
+	}{
+		{
+			name:              "simple valid metric name",
+			metricName:        "http_requests",
+			unit:              "",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: false,
+			expected:          "http_requests",
+		},
+		{
+			name:              "metric name with invalid characters",
+			metricName:        "http-requests@in_flight",
+			unit:              "",
+			metricType:        MetricTypeNonMonotonicCounter,
+			addMetricSuffixes: false,
+			expected:          "http_requests_in_flight",
+		},
+		{
+			name:              "metric name starting with digit",
+			metricName:        "5xx_errors",
+			unit:              "",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: false,
+			expected:          "_5xx_errors",
+		},
+		{
+			name:              "metric name starting with digit, with suffixes",
+			metricName:        "5xx_errors",
+			unit:              "",
+			metricType:        MetricTypeMonotonicCounter,
+			addMetricSuffixes: true,
+			expected:          "_5xx_errors_total",
+		},
+		{
+			name:              "metric name with multiple consecutive invalid chars",
+			metricName:        "api..//request--time",
+			unit:              "",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: false,
+			expected:          "api_request_time",
+		},
+		{
+			name:              "full normalization with units and type",
+			metricName:        "system.cpu-utilization",
+			unit:              "ms/s",
+			metricType:        MetricTypeMonotonicCounter,
+			addMetricSuffixes: true,
+			expected:          "system_cpu_utilization_milliseconds_per_second_total",
+		},
+		{
+			name:              "metric with special characters and ratio",
+			metricName:        "memory.usage%rate",
+			unit:              "1",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "memory_usage_rate_ratio",
+		},
+		{
+			name:              "metric with unicode characters",
+			metricName:        "error_rate_£_€_¥",
+			unit:              "",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: false,
+			expected:          "error_rate_____",
+		},
+		{
+			name:              "metric with multiple spaces",
+			metricName:        "api   response   time",
+			unit:              "ms",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "api_response_time_milliseconds",
+		},
+		{
+			name:              "metric with colons (valid prometheus chars)",
+			metricName:        "app:request:latency",
+			unit:              "s",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: true,
+			expected:          "app:request:latency_seconds",
+		},
+		{
+			name:              "empty metric name",
+			metricName:        "",
+			unit:              "",
+			metricType:        MetricTypeGauge,
+			addMetricSuffixes: false,
+			expected:          "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := BuildCompliantMetricName(tt.metricName, tt.unit, tt.metricType, tt.addMetricSuffixes)
+			if tt.expected != result {
+				t.Errorf("expected %s, got %s", tt.expected, result)
+			}
+		})
+	}
+}
+
+func TestAddUnitTokens(t *testing.T) {
+	tests := []struct {
+		nameTokens     []string
+		mainUnitSuffix string
+		perUnitSuffix  string
+		expected       []string
+	}{
+		{[]string{}, "", "", []string{}},
+		{[]string{"token1"}, "main", "", []string{"token1", "main"}},
+		{[]string{"token1"}, "", "per", []string{"token1", "per"}},
+		{[]string{"token1"}, "main", "per", []string{"token1", "main", "per"}},
+		{[]string{"token1", "per"}, "main", "per", []string{"token1", "per", "main"}},
+		{[]string{"token1", "main"}, "main", "per", []string{"token1", "main", "per"}},
+		{[]string{"token1"}, "main_", "per", []string{"token1", "main", "per"}},
+		{[]string{"token1"}, "main_unit", "per_seconds_", []string{"token1", "main_unit", "per_seconds"}}, // trailing underscores are removed
+		{[]string{"token1"}, "main_unit", "per_", []string{"token1", "main_unit"}},                        // 'per_' is removed entirely
+	}
+
+	for _, test := range tests {
+		result := addUnitTokens(test.nameTokens, test.mainUnitSuffix, test.perUnitSuffix)
+		if !reflect.DeepEqual(test.expected, result) {
+			t.Errorf("expected %v, got %v", test.expected, result)
+		}
+	}
+}
+
+func TestRemoveItem(t *testing.T) {
+	if !reflect.DeepEqual([]string{}, removeItem([]string{}, "test")) {
+		t.Errorf("expected %v, got %v", []string{}, removeItem([]string{}, "test"))
+	}
+	if !reflect.DeepEqual([]string{}, removeItem([]string{}, "")) {
+		t.Errorf("expected %v, got %v", []string{}, removeItem([]string{}, ""))
+	}
+	if !reflect.DeepEqual([]string{"a", "b", "c"}, removeItem([]string{"a", "b", "c"}, "d")) {
+		t.Errorf("expected %v, got %v", []string{"a", "b", "c"}, removeItem([]string{"a", "b", "c"}, "d"))
+	}
+	if !reflect.DeepEqual([]string{"a", "b", "c"}, removeItem([]string{"a", "b", "c"}, "")) {
+		t.Errorf("expected %v, got %v", []string{"a", "b", "c"}, removeItem([]string{"a", "b", "c"}, ""))
+	}
+	if !reflect.DeepEqual([]string{"a", "b"}, removeItem([]string{"a", "b", "c"}, "c")) {
+		t.Errorf("expected %v, got %v", []string{"a", "b"}, removeItem([]string{"a", "b", "c"}, "c"))
+	}
+	if !reflect.DeepEqual([]string{"a", "c"}, removeItem([]string{"a", "b", "c"}, "b")) {
+		t.Errorf("expected %v, got %v", []string{"a", "c"}, removeItem([]string{"a", "b", "c"}, "b"))
+	}
+	if !reflect.DeepEqual([]string{"b", "c"}, removeItem([]string{"a", "b", "c"}, "a")) {
+		t.Errorf("expected %v, got %v", []string{"b", "c"}, removeItem([]string{"a", "b", "c"}, "a"))
+	}
+}
+
+func TestCleanUpStrings(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "already valid string",
+			input:    "valid_metric_name",
+			expected: "valid_metric_name",
+		},
+		{
+			name:     "invalid characters",
+			input:    "metric-name@with#special$chars",
+			expected: "metric_name_with_special_chars",
+		},
+		{
+			name:     "multiple consecutive invalid chars",
+			input:    "metric---name###special",
+			expected: "metric_name_special",
+		},
+		{
+			name:     "leading invalid chars",
+			input:    "@#$metric_name",
+			expected: "metric_name",
+		},
+		{
+			name:     "trailing invalid chars",
+			input:    "metric_name@#$",
+			expected: "metric_name_",
+		},
+		{
+			name:     "multiple consecutive underscores",
+			input:    "metric___name____test",
+			expected: "metric_name_test",
+		},
+		{
+			name:     "empty string",
+			input:    "",
+			expected: "",
+		},
+		{
+			name:     "only invalid chars",
+			input:    "@#$%^&",
+			expected: "",
+		},
+		{
+			name:     "colons are valid",
+			input:    "system.cpu:usage.rate",
+			expected: "system_cpu:usage_rate",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := CleanUpString(tt.input)
+			if result != tt.expected {
+				t.Errorf("expected %q, got %q", tt.expected, result)
+			}
+		})
+	}
+}