Skip to content

Commit 71b341f

Browse files
authored
Add utf8 support to the prometheus exporter (#5755)
### Changes Disable sanitization when the UTF-8 support is enabled in the Prometheus library. ### Usage To enable UTF-8 support for the Prometheus exporter after this change, set the following in your application: ```golang import "github.com/prometheus/common/model" func init() { model.NameValidationScheme = model.UTF8Validation } ``` See `exporters/prometheus/testdata/counter_utf8.txt` for an example of the text exposition format including names/labels with dots.
1 parent 506a9ba commit 71b341f

File tree

9 files changed

+111
-129
lines changed

9 files changed

+111
-129
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1313
- Support `OTEL_EXPORTER_OTLP_LOGS_INSECURE` and `OTEL_EXPORTER_OTLP_INSECURE` environments in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`. (#5739)
1414
- The `WithResource` option for `NewMeterProvider` now merges the provided resources with the ones from environment variables. (#5773)
1515
- The `WithResource` option for `NewLoggerProvider` now merges the provided resources with the ones from environment variables. (#5773)
16+
- Add UTF-8 support to `go.opentelemetry.io/otel/exporters/prometheus`. (#5755)
1617

1718
### Fixed
1819

exporters/prometheus/config.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"github.com/prometheus/client_golang/prometheus"
10+
"github.com/prometheus/common/model"
1011

1112
"go.opentelemetry.io/otel/attribute"
1213
"go.opentelemetry.io/otel/sdk/metric"
@@ -131,7 +132,10 @@ func WithoutScopeInfo() Option {
131132
// have special behavior based on their name.
132133
func WithNamespace(ns string) Option {
133134
return optionFunc(func(cfg config) config {
134-
ns = sanitizeName(ns)
135+
if model.NameValidationScheme != model.UTF8Validation {
136+
// Only sanitize if prometheus does not support UTF-8.
137+
ns = model.EscapeName(ns, model.NameEscapingScheme)
138+
}
135139
if !strings.HasSuffix(ns, "_") {
136140
// namespace and metric names should be separated with an underscore,
137141
// adds a trailing underscore if there is not one already.

exporters/prometheus/exporter.go

+35-82
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,10 @@ import (
1111
"slices"
1212
"strings"
1313
"sync"
14-
"unicode"
15-
"unicode/utf8"
1614

1715
"github.com/prometheus/client_golang/prometheus"
1816
dto "github.com/prometheus/client_model/go"
17+
"github.com/prometheus/common/model"
1918
"google.golang.org/protobuf/proto"
2019

2120
"go.opentelemetry.io/otel"
@@ -298,28 +297,38 @@ func addGaugeMetric[N int64 | float64](ch chan<- prometheus.Metric, gauge metric
298297
}
299298

300299
// getAttrs parses the attribute.Set to two lists of matching Prometheus-style
301-
// keys and values. It sanitizes invalid characters and handles duplicate keys
302-
// (due to sanitization) by sorting and concatenating the values following the spec.
300+
// keys and values.
303301
func getAttrs(attrs attribute.Set, ks, vs [2]string, resourceKV keyVals) ([]string, []string) {
304-
keysMap := make(map[string][]string)
305-
itr := attrs.Iter()
306-
for itr.Next() {
307-
kv := itr.Attribute()
308-
key := strings.Map(sanitizeRune, string(kv.Key))
309-
if _, ok := keysMap[key]; !ok {
310-
keysMap[key] = []string{kv.Value.Emit()}
311-
} else {
312-
// if the sanitized key is a duplicate, append to the list of keys
313-
keysMap[key] = append(keysMap[key], kv.Value.Emit())
314-
}
315-
}
316-
317302
keys := make([]string, 0, attrs.Len())
318303
values := make([]string, 0, attrs.Len())
319-
for key, vals := range keysMap {
320-
keys = append(keys, key)
321-
slices.Sort(vals)
322-
values = append(values, strings.Join(vals, ";"))
304+
itr := attrs.Iter()
305+
306+
if model.NameValidationScheme == model.UTF8Validation {
307+
// Do not perform sanitization if prometheus supports UTF-8.
308+
for itr.Next() {
309+
kv := itr.Attribute()
310+
keys = append(keys, string(kv.Key))
311+
values = append(values, kv.Value.Emit())
312+
}
313+
} else {
314+
// It sanitizes invalid characters and handles duplicate keys
315+
// (due to sanitization) by sorting and concatenating the values following the spec.
316+
keysMap := make(map[string][]string)
317+
for itr.Next() {
318+
kv := itr.Attribute()
319+
key := model.EscapeName(string(kv.Key), model.NameEscapingScheme)
320+
if _, ok := keysMap[key]; !ok {
321+
keysMap[key] = []string{kv.Value.Emit()}
322+
} else {
323+
// if the sanitized key is a duplicate, append to the list of keys
324+
keysMap[key] = append(keysMap[key], kv.Value.Emit())
325+
}
326+
}
327+
for key, vals := range keysMap {
328+
keys = append(keys, key)
329+
slices.Sort(vals)
330+
values = append(values, strings.Join(vals, ";"))
331+
}
323332
}
324333

325334
if ks[0] != "" {
@@ -347,13 +356,6 @@ func createScopeInfoMetric(scope instrumentation.Scope) (prometheus.Metric, erro
347356
return prometheus.NewConstMetric(desc, prometheus.GaugeValue, float64(1), scope.Name, scope.Version)
348357
}
349358

350-
func sanitizeRune(r rune) rune {
351-
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ':' || r == '_' {
352-
return r
353-
}
354-
return '_'
355-
}
356-
357359
var unitSuffixes = map[string]string{
358360
// Time
359361
"d": "_days",
@@ -392,7 +394,11 @@ var unitSuffixes = map[string]string{
392394

393395
// getName returns the sanitized name, prefixed with the namespace and suffixed with unit.
394396
func (c *collector) getName(m metricdata.Metrics, typ *dto.MetricType) string {
395-
name := sanitizeName(m.Name)
397+
name := m.Name
398+
if model.NameValidationScheme != model.UTF8Validation {
399+
// Only sanitize if prometheus does not support UTF-8.
400+
name = model.EscapeName(name, model.NameEscapingScheme)
401+
}
396402
addCounterSuffix := !c.withoutCounterSuffixes && *typ == dto.MetricType_COUNTER
397403
if addCounterSuffix {
398404
// Remove the _total suffix here, as we will re-add the total suffix
@@ -411,59 +417,6 @@ func (c *collector) getName(m metricdata.Metrics, typ *dto.MetricType) string {
411417
return name
412418
}
413419

414-
func sanitizeName(n string) string {
415-
// This algorithm is based on strings.Map from Go 1.19.
416-
const replacement = '_'
417-
418-
valid := func(i int, r rune) bool {
419-
// Taken from
420-
// https://github.com/prometheus/common/blob/dfbc25bd00225c70aca0d94c3c4bb7744f28ace0/model/metric.go#L92-L102
421-
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || r == ':' || (r >= '0' && r <= '9' && i > 0) {
422-
return true
423-
}
424-
return false
425-
}
426-
427-
// This output buffer b is initialized on demand, the first time a
428-
// character needs to be replaced.
429-
var b strings.Builder
430-
for i, c := range n {
431-
if valid(i, c) {
432-
continue
433-
}
434-
435-
if i == 0 && c >= '0' && c <= '9' {
436-
// Prefix leading number with replacement character.
437-
b.Grow(len(n) + 1)
438-
_ = b.WriteByte(byte(replacement))
439-
break
440-
}
441-
b.Grow(len(n))
442-
_, _ = b.WriteString(n[:i])
443-
_ = b.WriteByte(byte(replacement))
444-
width := utf8.RuneLen(c)
445-
n = n[i+width:]
446-
break
447-
}
448-
449-
// Fast path for unchanged input.
450-
if b.Cap() == 0 { // b.Grow was not called above.
451-
return n
452-
}
453-
454-
for _, c := range n {
455-
// Due to inlining, it is more performant to invoke WriteByte rather then
456-
// WriteRune.
457-
if valid(1, c) { // We are guaranteed to not be at the start.
458-
_ = b.WriteByte(byte(c))
459-
} else {
460-
_ = b.WriteByte(byte(replacement))
461-
}
462-
}
463-
464-
return b.String()
465-
}
466-
467420
func (c *collector) metricType(m metricdata.Metrics) *dto.MetricType {
468421
switch v := m.Data.(type) {
469422
case metricdata.Histogram[int64], metricdata.Histogram[float64]:

exporters/prometheus/exporter_test.go

+39-30
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/prometheus/client_golang/prometheus"
1515
"github.com/prometheus/client_golang/prometheus/testutil"
1616
dto "github.com/prometheus/client_model/go"
17+
"github.com/prometheus/common/model"
1718
"github.com/stretchr/testify/assert"
1819
"github.com/stretchr/testify/require"
1920

@@ -34,6 +35,7 @@ func TestPrometheusExporter(t *testing.T) {
3435
recordMetrics func(ctx context.Context, meter otelmetric.Meter)
3536
options []Option
3637
expectedFile string
38+
enableUTF8 bool
3739
}{
3840
{
3941
name: "counter",
@@ -399,10 +401,47 @@ func TestPrometheusExporter(t *testing.T) {
399401
counter.Add(ctx, 5.3, opt)
400402
},
401403
},
404+
{
405+
name: "counter utf-8",
406+
expectedFile: "testdata/counter_utf8.txt",
407+
enableUTF8: true,
408+
recordMetrics: func(ctx context.Context, meter otelmetric.Meter) {
409+
opt := otelmetric.WithAttributes(
410+
attribute.Key("A.G").String("B"),
411+
attribute.Key("C.H").String("D"),
412+
attribute.Key("E.I").Bool(true),
413+
attribute.Key("F.J").Int(42),
414+
)
415+
counter, err := meter.Float64Counter(
416+
"foo.things",
417+
otelmetric.WithDescription("a simple counter"),
418+
otelmetric.WithUnit("s"),
419+
)
420+
require.NoError(t, err)
421+
counter.Add(ctx, 5, opt)
422+
counter.Add(ctx, 10.3, opt)
423+
counter.Add(ctx, 9, opt)
424+
425+
attrs2 := attribute.NewSet(
426+
attribute.Key("A.G").String("D"),
427+
attribute.Key("C.H").String("B"),
428+
attribute.Key("E.I").Bool(true),
429+
attribute.Key("F.J").Int(42),
430+
)
431+
counter.Add(ctx, 5, otelmetric.WithAttributeSet(attrs2))
432+
},
433+
},
402434
}
403435

404436
for _, tc := range testCases {
405437
t.Run(tc.name, func(t *testing.T) {
438+
if tc.enableUTF8 {
439+
model.NameValidationScheme = model.UTF8Validation
440+
defer func() {
441+
// Reset to defaults
442+
model.NameValidationScheme = model.LegacyValidation
443+
}()
444+
}
406445
ctx := context.Background()
407446
registry := prometheus.NewRegistry()
408447
exporter, err := New(append(tc.options, WithRegisterer(registry))...)
@@ -452,36 +491,6 @@ func TestPrometheusExporter(t *testing.T) {
452491
}
453492
}
454493

455-
func TestSantitizeName(t *testing.T) {
456-
tests := []struct {
457-
input string
458-
want string
459-
}{
460-
{"name€_with_4_width_rune", "name__with_4_width_rune"},
461-
{"`", "_"},
462-
{
463-
`! "#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWKYZ[]\^_abcdefghijklmnopqrstuvwkyz{|}~`,
464-
`________________0123456789:______ABCDEFGHIJKLMNOPQRSTUVWKYZ_____abcdefghijklmnopqrstuvwkyz____`,
465-
},
466-
467-
// Test cases taken from
468-
// https://github.com/prometheus/common/blob/dfbc25bd00225c70aca0d94c3c4bb7744f28ace0/model/metric_test.go#L85-L136
469-
{"Avalid_23name", "Avalid_23name"},
470-
{"_Avalid_23name", "_Avalid_23name"},
471-
{"1valid_23name", "_1valid_23name"},
472-
{"avalid_23name", "avalid_23name"},
473-
{"Ava:lid_23name", "Ava:lid_23name"},
474-
{"a lid_23name", "a_lid_23name"},
475-
{":leading_colon", ":leading_colon"},
476-
{"colon:in:the:middle", "colon:in:the:middle"},
477-
{"", ""},
478-
}
479-
480-
for _, test := range tests {
481-
require.Equalf(t, test.want, sanitizeName(test.input), "input: %q", test.input)
482-
}
483-
}
484-
485494
func TestMultiScopes(t *testing.T) {
486495
ctx := context.Background()
487496
registry := prometheus.NewRegistry()

exporters/prometheus/go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.22
55
require (
66
github.com/prometheus/client_golang v1.20.3
77
github.com/prometheus/client_model v0.6.1
8+
github.com/prometheus/common v0.59.1
89
github.com/stretchr/testify v1.9.0
910
go.opentelemetry.io/otel v1.29.0
1011
go.opentelemetry.io/otel/metric v1.29.0
@@ -25,7 +26,6 @@ require (
2526
github.com/kylelemons/godebug v1.1.0 // indirect
2627
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
2728
github.com/pmezard/go-difflib v1.0.0 // indirect
28-
github.com/prometheus/common v0.59.1 // indirect
2929
github.com/prometheus/procfs v0.15.1 // indirect
3030
golang.org/x/sys v0.25.0 // indirect
3131
gopkg.in/yaml.v3 v3.0.1 // indirect
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# HELP "foo.things_seconds_total" a simple counter
2+
# TYPE "foo.things_seconds_total" counter
3+
{"foo.things_seconds_total","A.G"="B","C.H"="D","E.I"="true","F.J"="42",otel_scope_name="testmeter",otel_scope_version="v0.1.0"} 24.3
4+
{"foo.things_seconds_total","A.G"="D","C.H"="B","E.I"="true","F.J"="42",otel_scope_name="testmeter",otel_scope_version="v0.1.0"} 5
5+
# HELP otel_scope_info Instrumentation Scope metadata
6+
# TYPE otel_scope_info gauge
7+
otel_scope_info{otel_scope_name="testmeter",otel_scope_version="v0.1.0"} 1
8+
# HELP target_info Target metadata
9+
# TYPE target_info gauge
10+
target_info{"service.name"="prometheus_test","telemetry.sdk.language"="go","telemetry.sdk.name"="opentelemetry","telemetry.sdk.version"="latest"} 1

exporters/prometheus/testdata/sanitized_names.txt

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# HELP bar a fun little gauge
22
# TYPE bar gauge
33
bar{A="B",C="D",otel_scope_name="testmeter",otel_scope_version="v0.1.0"} 75
4-
# HELP _0invalid_counter_name_total a counter with an invalid name
5-
# TYPE _0invalid_counter_name_total counter
6-
_0invalid_counter_name_total{A="B",C="D",otel_scope_name="testmeter",otel_scope_version="v0.1.0"} 100
4+
# HELP _invalid_counter_name_total a counter with an invalid name
5+
# TYPE _invalid_counter_name_total counter
6+
_invalid_counter_name_total{A="B",C="D",otel_scope_name="testmeter",otel_scope_version="v0.1.0"} 100
77
# HELP invalid_gauge_name a gauge with an invalid name
88
# TYPE invalid_gauge_name gauge
99
invalid_gauge_name{A="B",C="D",otel_scope_name="testmeter",otel_scope_version="v0.1.0"} 100

internal/tools/go.mod

+5-4
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ require (
5151
github.com/butuzov/mirror v1.2.0 // indirect
5252
github.com/catenacyber/perfsprint v0.7.1 // indirect
5353
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
54-
github.com/cespare/xxhash/v2 v2.2.0 // indirect
54+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
5555
github.com/charithe/durationcheck v0.0.10 // indirect
5656
github.com/chavacava/garif v0.1.0 // indirect
5757
github.com/ckaznocha/intrange v0.1.2 // indirect
@@ -133,6 +133,7 @@ require (
133133
github.com/mitchellh/go-homedir v1.1.0 // indirect
134134
github.com/mitchellh/mapstructure v1.5.0 // indirect
135135
github.com/moricho/tparallel v0.3.2 // indirect
136+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
136137
github.com/nakabonne/nestif v0.3.1 // indirect
137138
github.com/nishanths/exhaustive v0.12.0 // indirect
138139
github.com/nishanths/predeclared v0.2.2 // indirect
@@ -142,10 +143,10 @@ require (
142143
github.com/pjbgf/sha1cd v0.3.0 // indirect
143144
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
144145
github.com/polyfloyd/go-errorlint v1.6.0 // indirect
145-
github.com/prometheus/client_golang v1.19.0 // indirect
146+
github.com/prometheus/client_golang v1.20.2 // indirect
146147
github.com/prometheus/client_model v0.6.1 // indirect
147-
github.com/prometheus/common v0.48.0 // indirect
148-
github.com/prometheus/procfs v0.12.0 // indirect
148+
github.com/prometheus/common v0.57.0 // indirect
149+
github.com/prometheus/procfs v0.15.1 // indirect
149150
github.com/quasilyte/go-ruleguard v0.4.2 // indirect
150151
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
151152
github.com/quasilyte/gogrep v0.5.0 // indirect

0 commit comments

Comments
 (0)