From 8e6e28f9620cadc1ada813f3d123a07692acf96a Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Mon, 21 Jul 2025 11:58:28 -0400 Subject: [PATCH] Migrate prometheus exporter to otlptranslator (#7044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/open-telemetry/opentelemetry-go/issues/7039 Fixes https://github.com/open-telemetry/opentelemetry-go/issues/6704 This uses the common prometheus/otlptranslator library to handle name conversion. It was a little tricky to work around the fact that the library only lets us configure whether all suffixes are added or not. But we want to keep supporting WithoutUnit and WithoutCounterSuffixes for a while longer. Those will eventually be deprecated and replaced after https://github.com/open-telemetry/opentelemetry-specification/pull/4533 is released. We decided to go ahead with the changes despite it being a small behavioral change when UTF8 is enabled. See: https://github.com/prometheus/otlptranslator/issues/44 for the rationale. This adds a unit test to verify that it properly handles bracketed units. The test fails on main with: ``` --- FAIL: TestPrometheusExporter (0.01s) --- FAIL: TestPrometheusExporter/counter_with_bracketed_unit (0.00s) exporter_test.go:646: Error Trace: /usr/local/google/home/dashpole/go/src/go.opentelemetry.io/opentelemetry-go/exporters/prometheus/exporter_test.go:646 Error: Received unexpected error: -# HELP "foo_{spans}_total" a simple counter -# TYPE "foo_{spans}_total" counter -{"foo_{spans}_total",A="B",C="D",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 24.3 -{"foo_{spans}_total",A="D",C="B",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 5 +# HELP foo_total a simple counter +# TYPE foo_total counter +foo_total{A="B",C="D",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 24.3 +foo_total{A="D",C="B",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 5 # HELP target_info Target metadata # TYPE target_info gauge target_info{"service.name"="prometheus_test","telemetry.sdk.language"="go","telemetry.sdk.name"="opentelemetry","telemetry.sdk.version"="latest"} 1 Test: TestPrometheusExporter/counter_with_bracketed_unit 2025/07/18 15:07:47 internal_logging.go:50: "msg"="Using existing type definition." "error"="instrument type conflict" "instrument"="foo_total" "existing"="COUNTER" "dropped"="GAUGE" 2025/07/18 15:07:47 internal_logging.go:50: "msg"="Using existing type definition." "error"="instrument type conflict" "instrument"="foo_bytes" "existing"="GAUGE" "dropped"="HISTOGRAM" FAIL FAIL go.opentelemetry.io/otel/exporters/prometheus 0.054s FAIL ``` cc @TylerHelmuth @ywwg @ArthurSens --------- Co-authored-by: Tyler Yahn Co-authored-by: Robert PajÄ…k --- CHANGELOG.md | 7 + exporters/prometheus/config.go | 13 -- exporters/prometheus/config_test.go | 8 +- exporters/prometheus/exporter.go | 213 ++++++++---------- exporters/prometheus/exporter_test.go | 114 ++++++++-- exporters/prometheus/go.mod | 2 + exporters/prometheus/go.sum | 4 + exporters/prometheus/testdata/counter.txt | 2 +- .../prometheus/testdata/counter_no_unit.txt | 7 + .../counter_noutf8_with_unit_suffix.txt | 7 + 10 files changed, 220 insertions(+), 157 deletions(-) create mode 100755 exporters/prometheus/testdata/counter_no_unit.txt create mode 100755 exporters/prometheus/testdata/counter_noutf8_with_unit_suffix.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 609a966f6..bf3c7adce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,13 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Change `AssertEqual` in `go.opentelemetry.io/otel/log/logtest` to accept `TestingT` in order to support benchmarks and fuzz tests. (#6908) - Change `SDKProcessorLogQueueCapacity`, `SDKProcessorLogQueueSize`, `SDKProcessorSpanQueueSize`, and `SDKProcessorSpanQueueCapacity` in `go.opentelemetry.io/otel/semconv/v1.36.0/otelconv` to use a `Int64ObservableUpDownCounter`. (#7041) +- Change `go.opentelemetry.io/otel/exporters/prometheus` to no longer deduplicate suffixes when UTF8 is enabled. + It is recommended to disable unit and counter suffixes in the exporter, and manually add suffixes if you rely on the existing behavior. (#7044) + +### Fixed + +- Fix `go.opentelemetry.io/otel/exporters/prometheus` to properly handle unit suffixes when the unit is in brackets. + E.g. `{spans}`. (#7044) diff --git a/exporters/prometheus/config.go b/exporters/prometheus/config.go index 521838840..4757b793d 100644 --- a/exporters/prometheus/config.go +++ b/exporters/prometheus/config.go @@ -4,11 +4,9 @@ package prometheus // import "go.opentelemetry.io/otel/exporters/prometheus" import ( - "strings" "sync" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/common/model" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/internal/global" @@ -139,17 +137,6 @@ func WithoutScopeInfo() Option { // have special behavior based on their name. func WithNamespace(ns string) Option { return optionFunc(func(cfg config) config { - if model.NameValidationScheme != model.UTF8Validation { // nolint:staticcheck // We need this check to keep supporting the legacy scheme. - logDeprecatedLegacyScheme() - // Only sanitize if prometheus does not support UTF-8. - ns = model.EscapeName(ns, model.NameEscapingScheme) - } - if !strings.HasSuffix(ns, "_") { - // namespace and metric names should be separated with an underscore, - // adds a trailing underscore if there is not one already. - ns = ns + "_" - } - cfg.namespace = ns return cfg }) diff --git a/exporters/prometheus/config_test.go b/exporters/prometheus/config_test.go index c24ccd72e..68fae27cb 100644 --- a/exporters/prometheus/config_test.go +++ b/exporters/prometheus/config_test.go @@ -113,17 +113,17 @@ func TestNewConfig(t *testing.T) { }, wantConfig: config{ registerer: prometheus.DefaultRegisterer, - namespace: "test_", + namespace: "test", }, }, { name: "with namespace with trailing underscore", options: []Option{ - WithNamespace("test_"), + WithNamespace("test"), }, wantConfig: config{ registerer: prometheus.DefaultRegisterer, - namespace: "test_", + namespace: "test", }, }, { @@ -133,7 +133,7 @@ func TestNewConfig(t *testing.T) { }, wantConfig: config{ registerer: prometheus.DefaultRegisterer, - namespace: "test/_", + namespace: "test/", }, }, } diff --git a/exporters/prometheus/exporter.go b/exporters/prometheus/exporter.go index 7b44c12c5..9f3e5414a 100644 --- a/exporters/prometheus/exporter.go +++ b/exporters/prometheus/exporter.go @@ -16,6 +16,7 @@ import ( "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/model" + "github.com/prometheus/otlptranslator" "google.golang.org/protobuf/proto" "go.opentelemetry.io/otel" @@ -27,16 +28,12 @@ import ( ) const ( - targetInfoMetricName = "target_info" targetInfoDescription = "Target metadata" scopeLabelPrefix = "otel_scope_" scopeNameLabel = scopeLabelPrefix + "name" scopeVersionLabel = scopeLabelPrefix + "version" scopeSchemaLabel = scopeLabelPrefix + "schema_url" - - traceIDExemplarKey = "trace_id" - spanIDExemplarKey = "span_id" ) var metricsPool = sync.Pool{ @@ -93,12 +90,11 @@ type collector struct { targetInfo prometheus.Metric metricFamilies map[string]*dto.MetricFamily resourceKeyVals keyVals + metricNamer otlptranslator.MetricNamer + labelNamer otlptranslator.LabelNamer + unitNamer otlptranslator.UnitNamer } -// prometheus counters MUST have a _total suffix by default: -// https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/compatibility/prometheus_and_openmetrics.md -const counterSuffix = "total" - // New returns a Prometheus Exporter. func New(opts ...Option) (*Exporter, error) { cfg := newConfig(opts...) @@ -108,6 +104,12 @@ func New(opts ...Option) (*Exporter, error) { // TODO (#3244): Enable some way to configure the reader, but not change temporality. reader := metric.NewManualReader(cfg.readerOpts...) + utf8Allowed := model.NameValidationScheme == model.UTF8Validation // nolint:staticcheck // We need this check to keep supporting the legacy scheme. + if !utf8Allowed { + // Only sanitize if prometheus does not support UTF-8. + logDeprecatedLegacyScheme() + } + labelNamer := otlptranslator.LabelNamer{UTF8Allowed: utf8Allowed} collector := &collector{ reader: reader, disableTargetInfo: cfg.disableTargetInfo, @@ -115,8 +117,18 @@ func New(opts ...Option) (*Exporter, error) { withoutCounterSuffixes: cfg.withoutCounterSuffixes, disableScopeInfo: cfg.disableScopeInfo, metricFamilies: make(map[string]*dto.MetricFamily), - namespace: cfg.namespace, + namespace: labelNamer.Build(cfg.namespace), resourceAttributesFilter: cfg.resourceAttributesFilter, + metricNamer: otlptranslator.MetricNamer{ + Namespace: cfg.namespace, + // We decide whether to pass type and unit to the netricNamer based + // on whether units or counter suffixes are enabled, and keep this + // always enabled. + WithMetricSuffixes: true, + UTF8Allowed: utf8Allowed, + }, + unitNamer: otlptranslator.UnitNamer{UTF8Allowed: utf8Allowed}, + labelNamer: labelNamer, } if err := cfg.registerer.Register(collector); err != nil { @@ -164,7 +176,11 @@ func (c *collector) Collect(ch chan<- prometheus.Metric) { defer c.mu.Unlock() if c.targetInfo == nil && !c.disableTargetInfo { - targetInfo, err := createInfoMetric(targetInfoMetricName, targetInfoDescription, metrics.Resource) + targetInfo, err := c.createInfoMetric( + otlptranslator.TargetInfoMetricName, + targetInfoDescription, + metrics.Resource, + ) if err != nil { // If the target info metric is invalid, disable sending it. c.disableTargetInfo = true @@ -195,7 +211,7 @@ func (c *collector) Collect(ch chan<- prometheus.Metric) { kv.keys = append(kv.keys, scopeNameLabel, scopeVersionLabel, scopeSchemaLabel) kv.vals = append(kv.vals, scopeMetrics.Scope.Name, scopeMetrics.Scope.Version, scopeMetrics.Scope.SchemaURL) - attrKeys, attrVals := getAttrs(scopeMetrics.Scope.Attributes) + attrKeys, attrVals := getAttrs(scopeMetrics.Scope.Attributes, c.labelNamer) for i := range attrKeys { attrKeys[i] = scopeLabelPrefix + attrKeys[i] } @@ -211,7 +227,7 @@ func (c *collector) Collect(ch chan<- prometheus.Metric) { if typ == nil { continue } - name := c.getName(m, typ) + name := c.getName(m) drop, help := c.validateMetrics(name, m.Description, typ) if drop { @@ -224,21 +240,21 @@ func (c *collector) Collect(ch chan<- prometheus.Metric) { switch v := m.Data.(type) { case metricdata.Histogram[int64]: - addHistogramMetric(ch, v, m, name, kv) + addHistogramMetric(ch, v, m, name, kv, c.labelNamer) case metricdata.Histogram[float64]: - addHistogramMetric(ch, v, m, name, kv) + addHistogramMetric(ch, v, m, name, kv, c.labelNamer) case metricdata.ExponentialHistogram[int64]: - addExponentialHistogramMetric(ch, v, m, name, kv) + addExponentialHistogramMetric(ch, v, m, name, kv, c.labelNamer) case metricdata.ExponentialHistogram[float64]: - addExponentialHistogramMetric(ch, v, m, name, kv) + addExponentialHistogramMetric(ch, v, m, name, kv, c.labelNamer) case metricdata.Sum[int64]: - addSumMetric(ch, v, m, name, kv) + addSumMetric(ch, v, m, name, kv, c.labelNamer) case metricdata.Sum[float64]: - addSumMetric(ch, v, m, name, kv) + addSumMetric(ch, v, m, name, kv, c.labelNamer) case metricdata.Gauge[int64]: - addGaugeMetric(ch, v, m, name, kv) + addGaugeMetric(ch, v, m, name, kv, c.labelNamer) case metricdata.Gauge[float64]: - addGaugeMetric(ch, v, m, name, kv) + addGaugeMetric(ch, v, m, name, kv, c.labelNamer) } } } @@ -303,9 +319,10 @@ func addExponentialHistogramMetric[N int64 | float64]( m metricdata.Metrics, name string, kv keyVals, + labelNamer otlptranslator.LabelNamer, ) { for _, dp := range histogram.DataPoints { - keys, values := getAttrs(dp.Attributes) + keys, values := getAttrs(dp.Attributes, labelNamer) keys = append(keys, kv.keys...) values = append(values, kv.vals...) @@ -377,9 +394,10 @@ func addHistogramMetric[N int64 | float64]( m metricdata.Metrics, name string, kv keyVals, + labelNamer otlptranslator.LabelNamer, ) { for _, dp := range histogram.DataPoints { - keys, values := getAttrs(dp.Attributes) + keys, values := getAttrs(dp.Attributes, labelNamer) keys = append(keys, kv.keys...) values = append(values, kv.vals...) @@ -396,7 +414,7 @@ func addHistogramMetric[N int64 | float64]( otel.Handle(err) continue } - m = addExemplars(m, dp.Exemplars) + m = addExemplars(m, dp.Exemplars, labelNamer) ch <- m } } @@ -407,6 +425,7 @@ func addSumMetric[N int64 | float64]( m metricdata.Metrics, name string, kv keyVals, + labelNamer otlptranslator.LabelNamer, ) { valueType := prometheus.CounterValue if !sum.IsMonotonic { @@ -414,7 +433,7 @@ func addSumMetric[N int64 | float64]( } for _, dp := range sum.DataPoints { - keys, values := getAttrs(dp.Attributes) + keys, values := getAttrs(dp.Attributes, labelNamer) keys = append(keys, kv.keys...) values = append(values, kv.vals...) @@ -427,7 +446,7 @@ func addSumMetric[N int64 | float64]( // GaugeValues don't support Exemplars at this time // https://github.com/prometheus/client_golang/blob/aef8aedb4b6e1fb8ac1c90790645169125594096/prometheus/metric.go#L199 if valueType != prometheus.GaugeValue { - m = addExemplars(m, dp.Exemplars) + m = addExemplars(m, dp.Exemplars, labelNamer) } ch <- m } @@ -439,9 +458,10 @@ func addGaugeMetric[N int64 | float64]( m metricdata.Metrics, name string, kv keyVals, + labelNamer otlptranslator.LabelNamer, ) { for _, dp := range gauge.DataPoints { - keys, values := getAttrs(dp.Attributes) + keys, values := getAttrs(dp.Attributes, labelNamer) keys = append(keys, kv.keys...) values = append(values, kv.vals...) @@ -457,12 +477,12 @@ func addGaugeMetric[N int64 | float64]( // getAttrs converts the attribute.Set to two lists of matching Prometheus-style // keys and values. -func getAttrs(attrs attribute.Set) ([]string, []string) { +func getAttrs(attrs attribute.Set, labelNamer otlptranslator.LabelNamer) ([]string, []string) { keys := make([]string, 0, attrs.Len()) values := make([]string, 0, attrs.Len()) itr := attrs.Iter() - if model.NameValidationScheme == model.UTF8Validation { // nolint:staticcheck // We need this check to keep supporting the legacy scheme. + if labelNamer.UTF8Allowed { // Do not perform sanitization if prometheus supports UTF-8. for itr.Next() { kv := itr.Attribute() @@ -475,7 +495,7 @@ func getAttrs(attrs attribute.Set) ([]string, []string) { keysMap := make(map[string][]string) for itr.Next() { kv := itr.Attribute() - key := model.EscapeName(string(kv.Key), model.NameEscapingScheme) + key := labelNamer.Build(string(kv.Key)) if _, ok := keysMap[key]; !ok { keysMap[key] = []string{kv.Value.Emit()} } else { @@ -492,91 +512,22 @@ func getAttrs(attrs attribute.Set) ([]string, []string) { return keys, values } -func createInfoMetric(name, description string, res *resource.Resource) (prometheus.Metric, error) { - keys, values := getAttrs(*res.Set()) +func (c *collector) createInfoMetric(name, description string, res *resource.Resource) (prometheus.Metric, error) { + keys, values := getAttrs(*res.Set(), c.labelNamer) desc := prometheus.NewDesc(name, description, keys, nil) return prometheus.NewConstMetric(desc, prometheus.GaugeValue, float64(1), values...) } -func unitMapGetOrDefault(unit string) string { - if promUnit, ok := unitSuffixes[unit]; ok { - return promUnit - } - return unit -} - -var unitSuffixes = 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": "ratio", - "%": "percent", -} - // getName returns the sanitized name, prefixed with the namespace and suffixed with unit. -func (c *collector) getName(m metricdata.Metrics, typ *dto.MetricType) string { - name := m.Name - if model.NameValidationScheme != model.UTF8Validation { // nolint:staticcheck // We need this check to keep supporting the legacy scheme. - // Only sanitize if prometheus does not support UTF-8. - logDeprecatedLegacyScheme() - name = model.EscapeName(name, model.NameEscapingScheme) +func (c *collector) getName(m metricdata.Metrics) string { + translatorMetric := otlptranslator.Metric{ + Name: m.Name, + Type: c.namingMetricType(m), } - addCounterSuffix := !c.withoutCounterSuffixes && *typ == dto.MetricType_COUNTER - if addCounterSuffix { - // Remove the _total suffix here, as we will re-add the total suffix - // later, and it needs to come after the unit suffix. - name = strings.TrimSuffix(name, counterSuffix) - // If the last character is an underscore, or would be converted to an underscore, trim it from the name. - // an underscore will be added back in later. - if convertsToUnderscore(rune(name[len(name)-1])) { - name = name[:len(name)-1] - } + if !c.withoutUnits { + translatorMetric.Unit = m.Unit } - if c.namespace != "" { - name = c.namespace + name - } - if suffix := unitMapGetOrDefault(m.Unit); suffix != "" && !c.withoutUnits && !strings.HasSuffix(name, suffix) { - name += "_" + suffix - } - if addCounterSuffix { - name += "_" + counterSuffix - } - return name -} - -// convertsToUnderscore returns true if the character would be converted to an -// underscore when the escaping scheme is underscore escaping. This is meant to -// capture any character that should be considered a "delimiter". -func convertsToUnderscore(b rune) bool { - return (b < 'a' || b > 'z') && (b < 'A' || b > 'Z') && b != ':' && (b < '0' || b > '9') + return c.metricNamer.Build(translatorMetric) } func (c *collector) metricType(m metricdata.Metrics) *dto.MetricType { @@ -601,12 +552,41 @@ func (c *collector) metricType(m metricdata.Metrics) *dto.MetricType { return nil } +// namingMetricType provides the metric type for naming purposes. +func (c *collector) namingMetricType(m metricdata.Metrics) otlptranslator.MetricType { + switch v := m.Data.(type) { + case metricdata.ExponentialHistogram[int64], metricdata.ExponentialHistogram[float64]: + return otlptranslator.MetricTypeHistogram + case metricdata.Histogram[int64], metricdata.Histogram[float64]: + return otlptranslator.MetricTypeHistogram + case metricdata.Sum[float64]: + // If counter suffixes are disabled, treat them like non-monotonic + // suffixes for the purposes of naming. + if v.IsMonotonic && !c.withoutCounterSuffixes { + return otlptranslator.MetricTypeMonotonicCounter + } + return otlptranslator.MetricTypeNonMonotonicCounter + case metricdata.Sum[int64]: + // If counter suffixes are disabled, treat them like non-monotonic + // suffixes for the purposes of naming. + if v.IsMonotonic && !c.withoutCounterSuffixes { + return otlptranslator.MetricTypeMonotonicCounter + } + return otlptranslator.MetricTypeNonMonotonicCounter + case metricdata.Gauge[int64], metricdata.Gauge[float64]: + return otlptranslator.MetricTypeGauge + case metricdata.Summary: + return otlptranslator.MetricTypeSummary + } + return otlptranslator.MetricTypeUnknown +} + func (c *collector) createResourceAttributes(res *resource.Resource) { c.mu.Lock() defer c.mu.Unlock() resourceAttrs, _ := res.Set().Filter(c.resourceAttributesFilter) - resourceKeys, resourceValues := getAttrs(resourceAttrs) + resourceKeys, resourceValues := getAttrs(resourceAttrs, c.labelNamer) c.resourceKeyVals = keyVals{keys: resourceKeys, vals: resourceValues} } @@ -648,16 +628,20 @@ func (c *collector) validateMetrics(name, description string, metricType *dto.Me return false, "" } -func addExemplars[N int64 | float64](m prometheus.Metric, exemplars []metricdata.Exemplar[N]) prometheus.Metric { +func addExemplars[N int64 | float64]( + m prometheus.Metric, + exemplars []metricdata.Exemplar[N], + labelNamer otlptranslator.LabelNamer, +) prometheus.Metric { if len(exemplars) == 0 { return m } promExemplars := make([]prometheus.Exemplar, len(exemplars)) for i, exemplar := range exemplars { - labels := attributesToLabels(exemplar.FilteredAttributes) + labels := attributesToLabels(exemplar.FilteredAttributes, labelNamer) // Overwrite any existing trace ID or span ID attributes - labels[traceIDExemplarKey] = hex.EncodeToString(exemplar.TraceID[:]) - labels[spanIDExemplarKey] = hex.EncodeToString(exemplar.SpanID[:]) + labels[otlptranslator.ExemplarTraceIDKey] = hex.EncodeToString(exemplar.TraceID[:]) + labels[otlptranslator.ExemplarSpanIDKey] = hex.EncodeToString(exemplar.SpanID[:]) promExemplars[i] = prometheus.Exemplar{ Value: float64(exemplar.Value), Timestamp: exemplar.Time, @@ -674,11 +658,10 @@ func addExemplars[N int64 | float64](m prometheus.Metric, exemplars []metricdata return metricWithExemplar } -func attributesToLabels(attrs []attribute.KeyValue) prometheus.Labels { +func attributesToLabels(attrs []attribute.KeyValue, labelNamer otlptranslator.LabelNamer) prometheus.Labels { labels := make(map[string]string) for _, attr := range attrs { - key := model.EscapeName(string(attr.Key), model.NameEscapingScheme) - labels[key] = attr.Value.Emit() + labels[labelNamer.Build(string(attr.Key))] = attr.Value.Emit() } return labels } diff --git a/exporters/prometheus/exporter_test.go b/exporters/prometheus/exporter_test.go index be993fd89..3dbfe94cd 100644 --- a/exporters/prometheus/exporter_test.go +++ b/exporters/prometheus/exporter_test.go @@ -16,6 +16,7 @@ import ( "github.com/prometheus/client_golang/prometheus/testutil" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/model" + "github.com/prometheus/otlptranslator" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,6 +44,7 @@ func TestPrometheusExporter(t *testing.T) { { name: "counter", expectedFile: "testdata/counter.txt", + disableUTF8: true, recordMetrics: func(ctx context.Context, meter otelmetric.Meter) { opt := otelmetric.WithAttributes( attribute.Key("A").String("B"), @@ -71,7 +73,8 @@ func TestPrometheusExporter(t *testing.T) { }, { name: "counter that already has the unit suffix", - expectedFile: "testdata/counter_with_unit_suffix.txt", + expectedFile: "testdata/counter_noutf8_with_unit_suffix.txt", + disableUTF8: true, recordMetrics: func(ctx context.Context, meter otelmetric.Meter) { opt := otelmetric.WithAttributes( attribute.Key("A").String("B"), @@ -127,9 +130,39 @@ func TestPrometheusExporter(t *testing.T) { counter.Add(ctx, 5, otelmetric.WithAttributeSet(attrs2)) }, }, + { + name: "counter with bracketed unit", + expectedFile: "testdata/counter_no_unit.txt", + recordMetrics: func(ctx context.Context, meter otelmetric.Meter) { + opt := otelmetric.WithAttributes( + attribute.Key("A").String("B"), + attribute.Key("C").String("D"), + attribute.Key("E").Bool(true), + attribute.Key("F").Int(42), + ) + counter, err := meter.Float64Counter( + "foo", + otelmetric.WithDescription("a simple counter"), + otelmetric.WithUnit("{spans}"), + ) + require.NoError(t, err) + counter.Add(ctx, 5, opt) + counter.Add(ctx, 10.3, opt) + counter.Add(ctx, 9, opt) + + attrs2 := attribute.NewSet( + attribute.Key("A").String("D"), + attribute.Key("C").String("B"), + attribute.Key("E").Bool(true), + attribute.Key("F").Int(42), + ) + counter.Add(ctx, 5, otelmetric.WithAttributeSet(attrs2)) + }, + }, { name: "counter that already has a total suffix", expectedFile: "testdata/counter.txt", + disableUTF8: true, recordMetrics: func(ctx context.Context, meter otelmetric.Meter) { opt := otelmetric.WithAttributes( attribute.Key("A").String("B"), @@ -194,14 +227,13 @@ func TestPrometheusExporter(t *testing.T) { attribute.Key("A").String("B"), attribute.Key("C").String("D"), ) - gauge, err := meter.Float64UpDownCounter( + gauge, err := meter.Float64Gauge( "bar", otelmetric.WithDescription("a fun little gauge"), otelmetric.WithUnit("1"), ) require.NoError(t, err) - gauge.Add(ctx, 1.0, opt) - gauge.Add(ctx, -.25, opt) + gauge.Record(ctx, .75, opt) }, }, { @@ -398,14 +430,13 @@ func TestPrometheusExporter(t *testing.T) { attribute.Key("A").String("B"), attribute.Key("C").String("D"), ) - gauge, err := meter.Int64UpDownCounter( + gauge, err := meter.Int64Gauge( "bar", otelmetric.WithDescription("a fun little gauge"), otelmetric.WithUnit("1"), ) require.NoError(t, err) - gauge.Add(ctx, 2, opt) - gauge.Add(ctx, -1, opt) + gauge.Record(ctx, 1, opt) }, }, { @@ -1011,20 +1042,20 @@ func TestExemplars(t *testing.T) { attribute.Key("F.4").Int(42), ) expectedNonEscapedLabels := map[string]string{ - traceIDExemplarKey: "01000000000000000000000000000000", - spanIDExemplarKey: "0100000000000000", - "A.1": "B", - "C.2": "D", - "E.3": "true", - "F.4": "42", + otlptranslator.ExemplarTraceIDKey: "01000000000000000000000000000000", + otlptranslator.ExemplarSpanIDKey: "0100000000000000", + "A.1": "B", + "C.2": "D", + "E.3": "true", + "F.4": "42", } expectedEscapedLabels := map[string]string{ - traceIDExemplarKey: "01000000000000000000000000000000", - spanIDExemplarKey: "0100000000000000", - "A_1": "B", - "C_2": "D", - "E_3": "true", - "F_4": "42", + otlptranslator.ExemplarTraceIDKey: "01000000000000000000000000000000", + otlptranslator.ExemplarSpanIDKey: "0100000000000000", + "A_1": "B", + "C_2": "D", + "E_3": "true", + "F_4": "42", } for _, tc := range []struct { name string @@ -1241,7 +1272,14 @@ func TestExponentialHistogramScaleValidation(t *testing.T) { Description: "test", } - addExponentialHistogramMetric(ch, histogram, m, "test_histogram", keyVals{}) + addExponentialHistogramMetric( + ch, + histogram, + m, + "test_histogram", + keyVals{}, + otlptranslator.LabelNamer{}, + ) assert.Error(t, capturedError) assert.Contains(t, capturedError.Error(), "scale -5 is below minimum") select { @@ -1398,7 +1436,14 @@ func TestExponentialHistogramHighScaleDownscaling(t *testing.T) { } // This should not produce any errors and should properly downscale buckets - addExponentialHistogramMetric(ch, histogram, m, "test_high_scale_histogram", keyVals{}) + addExponentialHistogramMetric( + ch, + histogram, + m, + "test_high_scale_histogram", + keyVals{}, + otlptranslator.LabelNamer{}, + ) // Verify a metric was produced select { @@ -1453,7 +1498,14 @@ func TestExponentialHistogramHighScaleDownscaling(t *testing.T) { } // This should not produce any errors and should properly downscale buckets - addExponentialHistogramMetric(ch, histogram, m, "test_very_high_scale_histogram", keyVals{}) + addExponentialHistogramMetric( + ch, + histogram, + m, + "test_very_high_scale_histogram", + keyVals{}, + otlptranslator.LabelNamer{}, + ) // Verify a metric was produced select { @@ -1508,7 +1560,14 @@ func TestExponentialHistogramHighScaleDownscaling(t *testing.T) { } // This should handle negative buckets correctly - addExponentialHistogramMetric(ch, histogram, m, "test_histogram_with_negative_buckets", keyVals{}) + addExponentialHistogramMetric( + ch, + histogram, + m, + "test_histogram_with_negative_buckets", + keyVals{}, + otlptranslator.LabelNamer{}, + ) // Verify a metric was produced select { @@ -1557,7 +1616,14 @@ func TestExponentialHistogramHighScaleDownscaling(t *testing.T) { } // This should handle int64 exponential histograms correctly - addExponentialHistogramMetric(ch, histogram, m, "test_int64_exponential_histogram", keyVals{}) + addExponentialHistogramMetric( + ch, + histogram, + m, + "test_int64_exponential_histogram", + keyVals{}, + otlptranslator.LabelNamer{}, + ) // Verify a metric was produced select { diff --git a/exporters/prometheus/go.mod b/exporters/prometheus/go.mod index 8840b9439..b8366662d 100644 --- a/exporters/prometheus/go.mod +++ b/exporters/prometheus/go.mod @@ -10,6 +10,7 @@ require ( github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.65.0 + github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f github.com/stretchr/testify v1.10.0 go.opentelemetry.io/otel v1.37.0 go.opentelemetry.io/otel/metric v1.37.0 @@ -26,6 +27,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/exporters/prometheus/go.sum b/exporters/prometheus/go.sum index bbf0e7e5f..9e01dcfa9 100644 --- a/exporters/prometheus/go.sum +++ b/exporters/prometheus/go.sum @@ -13,6 +13,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= +github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -29,6 +31,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f h1:QQB6SuvGZjK8kdc2YaLJpYhV8fxauOsjE6jgcL6YJ8Q= +github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f/go.mod h1:P8AwMgdD7XEr6QRUJ2QWLpiAZTgTE2UYgjlu3svompI= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= diff --git a/exporters/prometheus/testdata/counter.txt b/exporters/prometheus/testdata/counter.txt index 87893ad2a..878231f4b 100755 --- a/exporters/prometheus/testdata/counter.txt +++ b/exporters/prometheus/testdata/counter.txt @@ -4,4 +4,4 @@ foo_seconds_total{A="B",C="D",E="true",F="42",otel_scope_fizz="buzz",otel_scope_ foo_seconds_total{A="D",C="B",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 5 # HELP target_info Target metadata # TYPE target_info gauge -target_info{"service.name"="prometheus_test","telemetry.sdk.language"="go","telemetry.sdk.name"="opentelemetry","telemetry.sdk.version"="latest"} 1 +target_info{service_name="prometheus_test",telemetry_sdk_language="go",telemetry_sdk_name="opentelemetry",telemetry_sdk_version="latest"} 1 diff --git a/exporters/prometheus/testdata/counter_no_unit.txt b/exporters/prometheus/testdata/counter_no_unit.txt new file mode 100755 index 000000000..0d5bc97c0 --- /dev/null +++ b/exporters/prometheus/testdata/counter_no_unit.txt @@ -0,0 +1,7 @@ +# HELP foo_total a simple counter +# TYPE foo_total counter +foo_total{A="B",C="D",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 24.3 +foo_total{A="D",C="B",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 5 +# HELP target_info Target metadata +# TYPE target_info gauge +target_info{"service.name"="prometheus_test","telemetry.sdk.language"="go","telemetry.sdk.name"="opentelemetry","telemetry.sdk.version"="latest"} 1 diff --git a/exporters/prometheus/testdata/counter_noutf8_with_unit_suffix.txt b/exporters/prometheus/testdata/counter_noutf8_with_unit_suffix.txt new file mode 100755 index 000000000..1e1daab96 --- /dev/null +++ b/exporters/prometheus/testdata/counter_noutf8_with_unit_suffix.txt @@ -0,0 +1,7 @@ +# HELP "foo_seconds_total" a simple counter +# TYPE "foo_seconds_total" counter +{"foo_seconds_total",A="B",C="D",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 24.3 +{"foo_seconds_total",A="D",C="B",E="true",F="42",otel_scope_fizz="buzz",otel_scope_name="testmeter",otel_scope_schema_url="",otel_scope_version="v0.1.0"} 5 +# HELP target_info Target metadata +# TYPE target_info gauge +target_info{service_name="prometheus_test",telemetry_sdk_language="go",telemetry_sdk_name="opentelemetry",telemetry_sdk_version="latest"} 1