Skip to content

Commit 95547b4

Browse files
authored
[exporter/prometheusremotewrite] feat: build prometheus unit and add to metadata in prometheusremotewr… (#38444)
…ite exporter <!--Ex. Fixing a bug - Describe the bug and how this fixes the issue. Ex. Adding a feature - Explain what this achieves.--> #### Description This change adds the logic to convert an OTLP metric unit into the corresponding Prometheus metric unit. It will attempt to map UCUM units to Prometheus units where applicable. This change also extends the metadata emitted by the `prometheusremotewrite` exporter to include the newly calculated unit. <!-- Issue number (e.g. #1234) or full URL to issue, if applicable. --> #### Link to tracking issue Fixes #29452 <!--Describe what testing was performed and which tests were added.--> #### Testing Added unit tests for the new `normalize_unit.go` file and also extended some existing unit tests to include more unit types. <!--Describe the documentation added.--> #### Documentation <!--Please delete paragraphs that you did not use before submitting.-->
1 parent ec86282 commit 95547b4

File tree

7 files changed

+247
-130
lines changed

7 files changed

+247
-130
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
7+
component: prometheusremotewriteexporter
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Adds logic to convert from the internal OTEL Metrics unit format to Prometheus unit format and emit unit as part of Prometheus metadata.
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [29452]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [user, api]

pkg/translator/prometheus/normalize_name.go

Lines changed: 8 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -11,59 +11,6 @@ import (
1111
"go.opentelemetry.io/collector/pdata/pmetric"
1212
)
1313

14-
// The map to translate OTLP units to Prometheus units
15-
// OTLP metrics use the c/s notation as specified at https://ucum.org/ucum.html
16-
// (See also https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/metrics.md#instrument-units)
17-
// Prometheus best practices for units: https://prometheus.io/docs/practices/naming/#base-units
18-
// OpenMetrics specification for units: https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#units-and-base-units
19-
var unitMap = map[string]string{
20-
// Time
21-
"d": "days",
22-
"h": "hours",
23-
"min": "minutes",
24-
"s": "seconds",
25-
"ms": "milliseconds",
26-
"us": "microseconds",
27-
"ns": "nanoseconds",
28-
29-
// Bytes
30-
"By": "bytes",
31-
"KiBy": "kibibytes",
32-
"MiBy": "mebibytes",
33-
"GiBy": "gibibytes",
34-
"TiBy": "tibibytes",
35-
"KBy": "kilobytes",
36-
"MBy": "megabytes",
37-
"GBy": "gigabytes",
38-
"TBy": "terabytes",
39-
40-
// SI
41-
"m": "meters",
42-
"V": "volts",
43-
"A": "amperes",
44-
"J": "joules",
45-
"W": "watts",
46-
"g": "grams",
47-
48-
// Misc
49-
"Cel": "celsius",
50-
"Hz": "hertz",
51-
"1": "",
52-
"%": "percent",
53-
}
54-
55-
// The map that translates the "per" unit
56-
// Example: s => per second (singular)
57-
var perUnitMap = map[string]string{
58-
"s": "second",
59-
"m": "minute",
60-
"h": "hour",
61-
"d": "day",
62-
"w": "week",
63-
"mo": "month",
64-
"y": "year",
65-
}
66-
6714
var normalizeNameGate = featuregate.GlobalRegistry().MustRegister(
6815
"pkg.translator.prometheus.NormalizeName",
6916
featuregate.StageBeta,
@@ -111,31 +58,13 @@ func normalizeName(metric pmetric.Metric, namespace string) string {
11158
func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) },
11259
)
11360

114-
// Split unit at the '/' if any
115-
unitTokens := strings.SplitN(metric.Unit(), "/", 2)
116-
117-
// Main unit
118-
// Append if not blank, doesn't contain '{}', and is not present in metric name already
119-
if len(unitTokens) > 0 {
120-
mainUnitOtel := strings.TrimSpace(unitTokens[0])
121-
if mainUnitOtel != "" && !strings.ContainsAny(mainUnitOtel, "{}") {
122-
mainUnitProm := CleanUpString(unitMapGetOrDefault(mainUnitOtel))
123-
if mainUnitProm != "" && !contains(nameTokens, mainUnitProm) {
124-
nameTokens = append(nameTokens, mainUnitProm)
125-
}
126-
}
127-
128-
// Per unit
129-
// Append if not blank, doesn't contain '{}', and is not present in metric name already
130-
if len(unitTokens) > 1 && unitTokens[1] != "" {
131-
perUnitOtel := strings.TrimSpace(unitTokens[1])
132-
if perUnitOtel != "" && !strings.ContainsAny(perUnitOtel, "{}") {
133-
perUnitProm := CleanUpString(perUnitMapGetOrDefault(perUnitOtel))
134-
if perUnitProm != "" && !contains(nameTokens, perUnitProm) {
135-
nameTokens = append(append(nameTokens, "per"), perUnitProm)
136-
}
137-
}
138-
}
61+
// Append unit if it exists
62+
promUnit, promUnitRate := buildCompliantMainUnit(metric.Unit()), buildCompliantPerUnit(metric.Unit())
63+
if promUnit != "" && !contains(nameTokens, promUnit) {
64+
nameTokens = append(nameTokens, promUnit)
65+
}
66+
if promUnitRate != "" && !contains(nameTokens, promUnitRate) {
67+
nameTokens = append(append(nameTokens, "per"), promUnitRate)
13968
}
14069

14170
// Append _total for Counters
@@ -152,7 +81,7 @@ func normalizeName(metric pmetric.Metric, namespace string) string {
15281
nameTokens = append(removeItem(nameTokens, "ratio"), "ratio")
15382
}
15483

155-
// Namespace?
84+
// Namespace
15685
if namespace != "" {
15786
nameTokens = append([]string{namespace}, nameTokens...)
15887
}
@@ -228,33 +157,10 @@ func removeSuffix(tokens []string, suffix string) []string {
228157
return tokens
229158
}
230159

231-
// Clean up specified string so it's Prometheus compliant
232-
func CleanUpString(s string) string {
233-
return strings.Join(strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }), "_")
234-
}
235-
236160
func RemovePromForbiddenRunes(s string) string {
237161
return strings.Join(strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != ':' }), "_")
238162
}
239163

240-
// Retrieve the Prometheus "basic" unit corresponding to the specified "basic" unit
241-
// Returns the specified unit if not found in unitMap
242-
func unitMapGetOrDefault(unit string) string {
243-
if promUnit, ok := unitMap[unit]; ok {
244-
return promUnit
245-
}
246-
return unit
247-
}
248-
249-
// Retrieve the Prometheus "per" unit corresponding to the specified "per" unit
250-
// Returns the specified unit if not found in perUnitMap
251-
func perUnitMapGetOrDefault(perUnit string) string {
252-
if promPerUnit, ok := perUnitMap[perUnit]; ok {
253-
return promPerUnit
254-
}
255-
return perUnit
256-
}
257-
258164
// Returns whether the slice contains the specified value
259165
func contains(slice []string, value string) bool {
260166
for _, sliceEntry := range slice {

pkg/translator/prometheus/normalize_name_test.go

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -137,27 +137,6 @@ func TestNamespace(t *testing.T) {
137137
require.Equal(t, "space_test", normalizeName(createGauge("#test", ""), "space"))
138138
}
139139

140-
func TestCleanUpString(t *testing.T) {
141-
require.Equal(t, "", CleanUpString(""))
142-
require.Equal(t, "a_b", CleanUpString("a b"))
143-
require.Equal(t, "hello_world", CleanUpString("hello, world!"))
144-
require.Equal(t, "hello_you_2", CleanUpString("hello you 2"))
145-
require.Equal(t, "1000", CleanUpString("$1000"))
146-
require.Equal(t, "", CleanUpString("*+$^=)"))
147-
}
148-
149-
func TestUnitMapGetOrDefault(t *testing.T) {
150-
require.Equal(t, "", unitMapGetOrDefault(""))
151-
require.Equal(t, "seconds", unitMapGetOrDefault("s"))
152-
require.Equal(t, "invalid", unitMapGetOrDefault("invalid"))
153-
}
154-
155-
func TestPerUnitMapGetOrDefault(t *testing.T) {
156-
require.Equal(t, "", perUnitMapGetOrDefault(""))
157-
require.Equal(t, "second", perUnitMapGetOrDefault("s"))
158-
require.Equal(t, "invalid", perUnitMapGetOrDefault("invalid"))
159-
}
160-
161140
func TestRemoveItem(t *testing.T) {
162141
require.Equal(t, []string{}, removeItem([]string{}, "test"))
163142
require.Equal(t, []string{}, removeItem([]string{}, ""))
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package prometheus // import "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/prometheus"
5+
6+
import (
7+
"strings"
8+
"unicode"
9+
)
10+
11+
// The map to translate OTLP units to Prometheus units
12+
// OTLP metrics use the c/s notation as specified at https://ucum.org/ucum.html
13+
// (See also https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/metrics.md#instrument-units)
14+
// Prometheus best practices for units: https://prometheus.io/docs/practices/naming/#base-units
15+
// OpenMetrics specification for units: https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#units-and-base-units
16+
var unitMap = map[string]string{
17+
// Time
18+
"d": "days",
19+
"h": "hours",
20+
"min": "minutes",
21+
"s": "seconds",
22+
"ms": "milliseconds",
23+
"us": "microseconds",
24+
"ns": "nanoseconds",
25+
26+
// Bytes
27+
"By": "bytes",
28+
"KiBy": "kibibytes",
29+
"MiBy": "mebibytes",
30+
"GiBy": "gibibytes",
31+
"TiBy": "tibibytes",
32+
"KBy": "kilobytes",
33+
"MBy": "megabytes",
34+
"GBy": "gigabytes",
35+
"TBy": "terabytes",
36+
37+
// SI
38+
"m": "meters",
39+
"V": "volts",
40+
"A": "amperes",
41+
"J": "joules",
42+
"W": "watts",
43+
"g": "grams",
44+
45+
// Misc
46+
"Cel": "celsius",
47+
"Hz": "hertz",
48+
"1": "",
49+
"%": "percent",
50+
}
51+
52+
// The map that translates the "per" unit
53+
// Example: s => per second (singular)
54+
var perUnitMap = map[string]string{
55+
"s": "second",
56+
"m": "minute",
57+
"h": "hour",
58+
"d": "day",
59+
"w": "week",
60+
"mo": "month",
61+
"y": "year",
62+
}
63+
64+
func BuildCompliantPrometheusUnit(unit string) string {
65+
promUnitTokens := make([]string, 0, 3)
66+
promMainUnit, promPerUnit := buildCompliantMainUnit(unit), buildCompliantPerUnit(unit)
67+
if promMainUnit != "" {
68+
promUnitTokens = append(promUnitTokens, promMainUnit)
69+
}
70+
if promPerUnit != "" {
71+
promUnitTokens = append(promUnitTokens, "per", promPerUnit)
72+
}
73+
return strings.Join(promUnitTokens, "_")
74+
}
75+
76+
// Extract the main unit from an OTLP unit and convert to Prometheus base unit
77+
// Returns an empty string if the unit is not found in the map
78+
func buildCompliantMainUnit(unit string) string {
79+
unitTokens := strings.SplitN(unit, "/", 2)
80+
if len(unitTokens) > 0 {
81+
mainUnitOtel := strings.TrimSpace(unitTokens[0])
82+
if mainUnitOtel != "" && !strings.ContainsAny(mainUnitOtel, "{}") {
83+
mainUnitProm := CleanUpString(unitMapGetOrDefault(mainUnitOtel))
84+
if mainUnitProm != "" {
85+
return mainUnitProm
86+
}
87+
}
88+
}
89+
return ""
90+
}
91+
92+
// Extract the rate unit from an OTLP unit and convert to Prometheus base unit
93+
// Returns an empty string if the unit is not found in the map
94+
func buildCompliantPerUnit(unit string) string {
95+
unitTokens := strings.SplitN(unit, "/", 2)
96+
if len(unitTokens) > 1 && unitTokens[1] != "" {
97+
perUnitOtel := strings.TrimSpace(unitTokens[1])
98+
if perUnitOtel != "" && !strings.ContainsAny(perUnitOtel, "{}") {
99+
perUnitProm := CleanUpString(perUnitMapGetOrDefault(perUnitOtel))
100+
if perUnitProm != "" {
101+
return perUnitProm
102+
}
103+
}
104+
}
105+
return ""
106+
}
107+
108+
// Retrieve the Prometheus "basic" unit corresponding to the specified "basic" unit
109+
// Returns the specified unit if not found in unitMap
110+
func unitMapGetOrDefault(unit string) string {
111+
if promUnit, ok := unitMap[unit]; ok {
112+
return promUnit
113+
}
114+
return unit
115+
}
116+
117+
// Retrieve the Prometheus "per" unit corresponding to the specified "per" unit
118+
// Returns the specified unit if not found in perUnitMap
119+
func perUnitMapGetOrDefault(perUnit string) string {
120+
if promPerUnit, ok := perUnitMap[perUnit]; ok {
121+
return promPerUnit
122+
}
123+
return perUnit
124+
}
125+
126+
// Clean up specified string so it's Prometheus compliant
127+
func CleanUpString(s string) string {
128+
return strings.Join(strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }), "_")
129+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package prometheus // import "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/prometheus"
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestBuildCompliantPrometheusUnit(t *testing.T) {
13+
require.Equal(t, "bytes", BuildCompliantPrometheusUnit("By"))
14+
require.Equal(t, "microseconds", BuildCompliantPrometheusUnit("us"))
15+
require.Equal(t, "connections", BuildCompliantPrometheusUnit("connections"))
16+
require.Equal(t, "gibibytes_per_hour", BuildCompliantPrometheusUnit("GiBy/h"))
17+
require.Equal(t, "", BuildCompliantPrometheusUnit("{objects}"))
18+
require.Equal(t, "", BuildCompliantPrometheusUnit("{scanned}/{returned}"))
19+
require.Equal(t, "per_second", BuildCompliantPrometheusUnit("{objects}/s"))
20+
require.Equal(t, "percent", BuildCompliantPrometheusUnit("%"))
21+
require.Equal(t, "", BuildCompliantPrometheusUnit("1"))
22+
}
23+
24+
func TestBuildCompliantMainUnit(t *testing.T) {
25+
require.Equal(t, "bytes", buildCompliantMainUnit("By"))
26+
require.Equal(t, "microseconds", buildCompliantMainUnit("us"))
27+
require.Equal(t, "connections", buildCompliantMainUnit("connections"))
28+
require.Equal(t, "gibibytes", buildCompliantMainUnit("GiBy/h"))
29+
require.Equal(t, "", buildCompliantMainUnit("{objects}"))
30+
require.Equal(t, "", buildCompliantMainUnit("{scanned}/{returned}"))
31+
require.Equal(t, "", buildCompliantMainUnit("{objects}/s"))
32+
require.Equal(t, "percent", buildCompliantMainUnit("%"))
33+
require.Equal(t, "", buildCompliantMainUnit("1"))
34+
}
35+
36+
func TestBuildCompliantPerUnit(t *testing.T) {
37+
require.Equal(t, "", buildCompliantPerUnit("By"))
38+
require.Equal(t, "", buildCompliantPerUnit("us"))
39+
require.Equal(t, "", buildCompliantPerUnit("connections"))
40+
require.Equal(t, "hour", buildCompliantPerUnit("GiBy/h"))
41+
require.Equal(t, "", buildCompliantPerUnit("{objects}"))
42+
require.Equal(t, "", buildCompliantPerUnit("{scanned}/{returned}"))
43+
require.Equal(t, "second", buildCompliantPerUnit("{objects}/s"))
44+
require.Equal(t, "", buildCompliantPerUnit("%"))
45+
require.Equal(t, "", buildCompliantPerUnit("1"))
46+
}
47+
48+
func TestUnitMapGetOrDefault(t *testing.T) {
49+
require.Equal(t, "", unitMapGetOrDefault(""))
50+
require.Equal(t, "seconds", unitMapGetOrDefault("s"))
51+
require.Equal(t, "invalid", unitMapGetOrDefault("invalid"))
52+
}
53+
54+
func TestPerUnitMapGetOrDefault(t *testing.T) {
55+
require.Equal(t, "", perUnitMapGetOrDefault(""))
56+
require.Equal(t, "second", perUnitMapGetOrDefault("s"))
57+
require.Equal(t, "invalid", perUnitMapGetOrDefault("invalid"))
58+
}
59+
60+
func TestCleanUpString(t *testing.T) {
61+
require.Equal(t, "", CleanUpString(""))
62+
require.Equal(t, "a_b", CleanUpString("a b"))
63+
require.Equal(t, "hello_world", CleanUpString("hello, world!"))
64+
require.Equal(t, "hello_you_2", CleanUpString("hello you 2"))
65+
require.Equal(t, "1000", CleanUpString("$1000"))
66+
require.Equal(t, "", CleanUpString("*+$^=)"))
67+
}

0 commit comments

Comments
 (0)