From 1ab1fc27e46d15f898353caceb4ea279c2b94e11 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 08:14:09 +0000 Subject: [PATCH 01/10] Add Prometheus translation strategy support Signed-off-by: Gregor Zeitlinger --- dependencyManagement/build.gradle.kts | 2 +- .../prometheus/Otel2PrometheusConverter.java | 113 ++++++++++++++++-- .../prometheus/PrometheusHttpServer.java | 12 +- .../PrometheusHttpServerBuilder.java | 15 +++ .../prometheus/PrometheusMetricReader.java | 15 ++- .../PrometheusMetricReaderBuilder.java | 28 ++++- .../prometheus/PrometheusUnitsHelper.java | 33 ++++- .../prometheus/TranslationStrategy.java | 38 ++++++ .../internal/PrometheusComponentProvider.java | 20 ++++ .../Otel2PrometheusConverterTest.java | 50 ++++++++ .../prometheus/PrometheusHttpServerTest.java | 46 ++++++- .../PrometheusMetricReaderTest.java | 7 +- .../MetricReaderFactoryTest.java | 2 + 13 files changed, 355 insertions(+), 26 deletions(-) create mode 100644 exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 92ea1be8023..2a24b3e149f 100644 --- a/dependencyManagement/build.gradle.kts +++ b/dependencyManagement/build.gradle.kts @@ -15,7 +15,7 @@ val jmhVersion = "1.37" val mockitoVersion = "4.11.0" val slf4jVersion = "2.0.17" val opencensusVersion = "0.31.1" -val prometheusServerVersion = "1.5.1" +val prometheusServerVersion = "1.6.1" val armeriaVersion = "1.38.0" val junitVersion = "5.14.4" val okhttpVersion = "5.3.2" diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index 5f443dba0ab..9c022074844 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -7,7 +7,6 @@ import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName; import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeLabelName; -import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeMetricName; import static java.util.Objects.requireNonNull; import io.opentelemetry.api.common.AttributeKey; @@ -84,6 +83,7 @@ final class Otel2PrometheusConverter { private final boolean otelScopeLabelsEnabled; private final boolean targetInfoMetricEnabled; + private final TranslationStrategy translationStrategy; @Nullable private final Predicate allowedResourceAttributesFilter; /** @@ -104,9 +104,11 @@ final class Otel2PrometheusConverter { Otel2PrometheusConverter( boolean otelScopeLabelsEnabled, boolean targetInfoMetricEnabled, + TranslationStrategy translationStrategy, @Nullable Predicate allowedResourceAttributesFilter) { this.otelScopeLabelsEnabled = otelScopeLabelsEnabled; this.targetInfoMetricEnabled = targetInfoMetricEnabled; + this.translationStrategy = translationStrategy; this.allowedResourceAttributesFilter = allowedResourceAttributesFilter; this.resourceAttributesToAllowedKeysCache = allowedResourceAttributesFilter != null @@ -122,6 +124,10 @@ boolean isTargetInfoMetricEnabled() { return targetInfoMetricEnabled; } + TranslationStrategy getTranslationStrategy() { + return translationStrategy; + } + @Nullable Predicate getAllowedResourceAttributesFilter() { return allowedResourceAttributesFilter; @@ -155,7 +161,8 @@ private MetricSnapshot convert(MetricData metricData) { // Note that AggregationTemporality.DELTA should never happen // because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE. - MetricMetadata metadata = convertMetadata(metricData); + boolean isCounter = isMonotonicSum(metricData); + MetricMetadata metadata = convertMetadata(metricData, isCounter); InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo(); switch (metricData.getType()) { case LONG_GAUGE: @@ -210,6 +217,17 @@ private MetricSnapshot convert(MetricData metricData) { return null; } + private static boolean isMonotonicSum(MetricData metricData) { + switch (metricData.getType()) { + case LONG_SUM: + return metricData.getLongSumData().isMonotonic(); + case DOUBLE_SUM: + return metricData.getDoubleSumData().isMonotonic(); + default: + return false; + } + } + private GaugeSnapshot convertLongGauge( MetricMetadata metadata, InstrumentationScopeInfo scope, @@ -550,29 +568,91 @@ private List> filterAllowedResourceAttributeKeys(@Nullable Resou * non-standard characters (dots, dashes, etc.) to underscores, and {@code sanitizeLabelName} * strips invalid leading prefixes. */ - private static String convertLabelName(String key) { - return sanitizeLabelName(prometheusName(key)); + private String convertLabelName(String key) { + if (translationStrategy.shouldEscape()) { + return sanitizeLabelName(prometheusName(key)); + } + return key; + } + + private MetricMetadata convertMetadata(MetricData metricData, boolean isCounter) { + switch (translationStrategy) { + case UNDERSCORE_ESCAPING_WITH_SUFFIXES: + return convertMetadataEscapedWithSuffixes(metricData); + case UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES: + return convertMetadataEscapedWithoutSuffixes(metricData); + case NO_UTF8_ESCAPING_WITH_SUFFIXES: + return convertMetadataUtf8WithSuffixes(metricData, isCounter); + case NO_TRANSLATION: + return convertMetadataNoTranslation(metricData); + } + throw new IllegalStateException("Unknown strategy: " + translationStrategy); + } + + private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metricData) { + String name = prometheusName(metricData.getName()); + String help = metricData.getDescription(); + Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit()); + name = stripRepeatedUnderscores(stripReservedMetricSuffixes(name)); + if (unit != null && !name.endsWith(unit.toString())) { + name = name + "_" + unit; + } + return new MetricMetadata(stripRepeatedUnderscores(name), help, unit); } - private static MetricMetadata convertMetadata(MetricData metricData) { - String name = sanitizeMetricName(prometheusName(metricData.getName())); + private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { + String rawName = stripRepeatedUnderscores(prometheusName(metricData.getName())); + String name = stripReservedMetricSuffixes(rawName); + return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null); + } + + private static MetricMetadata convertMetadataUtf8WithSuffixes( + MetricData metricData, boolean isCounter) { + String name = metricData.getName(); String help = metricData.getDescription(); Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit()); if (unit != null && !name.endsWith(unit.toString())) { name = name + "_" + unit; } - // Repeated __ are discouraged according to spec, although this is allowed in prometheus, see - // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1 + String expositionBaseName = name; + if (isCounter && !expositionBaseName.endsWith("_total")) { + expositionBaseName = expositionBaseName + "_total"; + } + return new MetricMetadata(stripReservedMetricSuffixes(name), expositionBaseName, help, unit); + } + + private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) { + String rawName = metricData.getName(); + String name = stripReservedMetricSuffixes(rawName); + return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null); + } + + private static String stripReservedMetricSuffixes(String name) { + boolean modified = true; + while (modified) { + modified = false; + for (String suffix : PrometheusUnitsHelper.RESERVED_SUFFIXES) { + if (name.equals(suffix)) { + return name.substring(1); + } + if (name.endsWith(suffix)) { + name = name.substring(0, name.length() - suffix.length()); + modified = true; + } + } + } + return name; + } + + private static String stripRepeatedUnderscores(String name) { while (name.contains("__")) { name = name.replace("__", "_"); } - - return new MetricMetadata(name, help, unit); + return name; } - private static void putOrMerge( - Map snapshotsByName, MetricSnapshot snapshot) { - String name = snapshot.getMetadata().getPrometheusName(); + private void putOrMerge(Map snapshotsByName, MetricSnapshot snapshot) { + String name = getMergeKey(snapshot.getMetadata()); if (snapshotsByName.containsKey(name)) { MetricSnapshot merged = merge(snapshotsByName.get(name), snapshot); if (merged != null) { @@ -583,6 +663,13 @@ private static void putOrMerge( } } + private String getMergeKey(MetricMetadata metadata) { + if (translationStrategy.shouldEscape()) { + return metadata.getPrometheusName(); + } + return metadata.getName(); + } + /** * OpenTelemetry may use the same metric name multiple times but in different instrumentation * scopes. In that case, we try to merge the metrics. They will have different {@code diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServer.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServer.java index 2caed4f385c..ee5aca82300 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServer.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServer.java @@ -21,6 +21,7 @@ import io.opentelemetry.sdk.metrics.export.CollectionRegistration; import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector; import io.opentelemetry.sdk.metrics.export.MetricReader; +import io.prometheus.metrics.config.PrometheusProperties; import io.prometheus.metrics.exporter.httpserver.HTTPServer; import io.prometheus.metrics.model.registry.PrometheusRegistry; import java.io.IOException; @@ -73,6 +74,7 @@ public static PrometheusHttpServerBuilder builder() { @Nullable HttpHandler defaultHandler, DefaultAggregationSelector defaultAggregationSelector, @Nullable Authenticator authenticator, + TranslationStrategy translationStrategy, PrometheusMetricReader prometheusMetricReader) { this.host = host; this.port = port; @@ -95,9 +97,17 @@ public static PrometheusHttpServerBuilder builder() { new LinkedBlockingQueue<>(), new DaemonThreadFactory("prometheus-http-server")); } + HTTPServer.Builder httpServerBuilder = HTTPServer.builder(); + if (translationStrategy != TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES) { + // Intentionally enable OM2 without content negotiation so OpenMetrics responses keep the + // legacy OM1 content type while using OM2 name-preservation semantics. + PrometheusProperties prometheusProperties = + PrometheusProperties.builder().enableOpenMetrics2(om2 -> {}).build(); + httpServerBuilder = HTTPServer.builder(prometheusProperties); + } try { this.httpServer = - HTTPServer.builder() + httpServerBuilder .hostname(host) .port(port) .executorService(executor) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerBuilder.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerBuilder.java index dcf7aa0bb43..6466b8d32c4 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerBuilder.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerBuilder.java @@ -102,6 +102,20 @@ public PrometheusHttpServerBuilder setTargetInfoMetricEnabled(boolean targetInfo return this; } + /** + * Sets the translation strategy for metric and label name conversion. + * + * @param translationStrategy the strategy to use + * @return this builder + * @see TranslationStrategy + */ + public PrometheusHttpServerBuilder setTranslationStrategy( + TranslationStrategy translationStrategy) { + requireNonNull(translationStrategy, "translationStrategy"); + metricReaderBuilder.setTranslationStrategy(translationStrategy); + return this; + } + /** * Set if the resource attributes should be added as labels on each exported metric. * @@ -201,6 +215,7 @@ public PrometheusHttpServer build() { defaultHandler, defaultAggregationSelector, authenticator, + metricReaderBuilder.getTranslationStrategy(), metricReaderBuilder.build()); } } diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReader.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReader.java index 821a7d37cb6..ee78fefbc26 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReader.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReader.java @@ -52,7 +52,8 @@ public PrometheusMetricReader( this( allowedResourceAttributesFilter, /* otelScopeLabelsEnabled= */ true, - /* targetInfoMetricEnabled= */ true); + /* targetInfoMetricEnabled= */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES); } /** @@ -65,7 +66,8 @@ public PrometheusMetricReader(@Nullable Predicate allowedResourceAttribu this( allowedResourceAttributesFilter, /* otelScopeLabelsEnabled= */ true, - /* targetInfoMetricEnabled= */ true); + /* targetInfoMetricEnabled= */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES); } // Package-private constructor used by builder @@ -73,10 +75,14 @@ public PrometheusMetricReader(@Nullable Predicate allowedResourceAttribu PrometheusMetricReader( @Nullable Predicate allowedResourceAttributesFilter, boolean otelScopeLabelsEnabled, - boolean targetInfoMetricEnabled) { + boolean targetInfoMetricEnabled, + TranslationStrategy translationStrategy) { this.converter = new Otel2PrometheusConverter( - otelScopeLabelsEnabled, targetInfoMetricEnabled, allowedResourceAttributesFilter); + otelScopeLabelsEnabled, + targetInfoMetricEnabled, + translationStrategy, + allowedResourceAttributesFilter); } @Override @@ -109,6 +115,7 @@ public String toString() { StringJoiner joiner = new StringJoiner(",", "PrometheusMetricReader{", "}"); joiner.add("otelScopeLabelsEnabled=" + converter.isOtelScopeLabelsEnabled()); joiner.add("targetInfoMetricEnabled=" + converter.isTargetInfoMetricEnabled()); + joiner.add("translationStrategy=" + converter.getTranslationStrategy()); joiner.add("allowedResourceAttributesFilter=" + converter.getAllowedResourceAttributesFilter()); return joiner.toString(); } diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderBuilder.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderBuilder.java index 2d6101df416..56ed668b6af 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderBuilder.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderBuilder.java @@ -5,6 +5,8 @@ package io.opentelemetry.exporter.prometheus; +import static java.util.Objects.requireNonNull; + import java.util.function.Predicate; import javax.annotation.Nullable; @@ -13,6 +15,8 @@ public final class PrometheusMetricReaderBuilder { private boolean otelScopeLabelsEnabled = true; private boolean targetInfoMetricEnabled = true; + private TranslationStrategy translationStrategy = + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES; @Nullable private Predicate allowedResourceAttributesFilter; PrometheusMetricReaderBuilder() {} @@ -20,6 +24,7 @@ public final class PrometheusMetricReaderBuilder { PrometheusMetricReaderBuilder(PrometheusMetricReaderBuilder metricReaderBuilder) { this.otelScopeLabelsEnabled = metricReaderBuilder.otelScopeLabelsEnabled; this.targetInfoMetricEnabled = metricReaderBuilder.targetInfoMetricEnabled; + this.translationStrategy = metricReaderBuilder.translationStrategy; this.allowedResourceAttributesFilter = metricReaderBuilder.allowedResourceAttributesFilter; } @@ -47,6 +52,20 @@ public PrometheusMetricReaderBuilder setTargetInfoMetricEnabled(boolean targetIn return this; } + /** + * Sets the translation strategy for metric and label name conversion. + * + * @param translationStrategy the strategy to use + * @return this builder + * @see TranslationStrategy + */ + public PrometheusMetricReaderBuilder setTranslationStrategy( + TranslationStrategy translationStrategy) { + requireNonNull(translationStrategy, "translationStrategy"); + this.translationStrategy = translationStrategy; + return this; + } + /** * Sets a filter to control which resource attributes are added as labels on each exported metric. * If {@code null}, no resource attributes will be added as labels. Default is {@code null}. @@ -60,9 +79,16 @@ public PrometheusMetricReaderBuilder setAllowedResourceAttributesFilter( return this; } + TranslationStrategy getTranslationStrategy() { + return translationStrategy; + } + /** Builds a new {@link PrometheusMetricReader}. */ public PrometheusMetricReader build() { return new PrometheusMetricReader( - allowedResourceAttributesFilter, otelScopeLabelsEnabled, targetInfoMetricEnabled); + allowedResourceAttributesFilter, + otelScopeLabelsEnabled, + targetInfoMetricEnabled, + translationStrategy); } } diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java index 1d4fee1c66f..be5e0146470 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java @@ -14,6 +14,8 @@ /** Convert OpenTelemetry unit names to Prometheus units. */ class PrometheusUnitsHelper { + static final String[] RESERVED_SUFFIXES = {"_total", "_created", "_bucket", "_info"}; + private static final Map pluralNames = new ConcurrentHashMap<>(); private static final Map singularNames = new ConcurrentHashMap<>(); private static final Map predefinedUnits = new ConcurrentHashMap<>(); @@ -97,11 +99,36 @@ static Unit convertUnit(String otelUnit) { @Nullable private static Unit unitOrNull(String name) { try { - return new Unit(PrometheusNaming.sanitizeUnitName(name)); + String sanitized = PrometheusNaming.sanitizeUnitName(name); + sanitized = stripReservedUnitSuffixes(sanitized); + if (sanitized.isEmpty()) { + return null; + } + return new Unit(sanitized); } catch (IllegalArgumentException e) { - // This happens if the name cannot be converted to a valid Prometheus unit name, - // for example if name is "total". return null; } } + + private static String stripReservedUnitSuffixes(String name) { + boolean modified = true; + while (modified) { + modified = false; + for (String suffix : RESERVED_SUFFIXES) { + String suffixWithoutUnderscore = suffix.substring(1); + if (name.equals(suffixWithoutUnderscore)) { + return ""; + } + if (name.endsWith(suffix)) { + name = name.substring(0, name.length() - suffix.length()); + modified = true; + } + } + while (name.endsWith("_") || name.endsWith(".")) { + name = name.substring(0, name.length() - 1); + modified = true; + } + } + return name; + } } diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java new file mode 100644 index 00000000000..6276d9e17d0 --- /dev/null +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java @@ -0,0 +1,38 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.prometheus; + +/** + * Controls how OpenTelemetry metric and label names are translated to Prometheus format. + * + * @see Prometheus + * exporter configuration + */ +public enum TranslationStrategy { + /** + * Default. Non-standard characters are converted to underscores, and type / unit suffixes are + * attached. + */ + UNDERSCORE_ESCAPING_WITH_SUFFIXES, + + /** + * Non-standard characters are converted to underscores, but type / unit suffixes are not attached + * by the exporter. + */ + UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + + /** UTF-8 metric and label names are preserved, while type / unit suffixes are still attached. */ + NO_UTF8_ESCAPING_WITH_SUFFIXES, + + /** Metric and label names are passed through without translation. */ + NO_TRANSLATION; + + boolean shouldEscape() { + return this == UNDERSCORE_ESCAPING_WITH_SUFFIXES + || this == UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES; + } +} diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java index d1989a04039..391f9b49244 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java @@ -9,10 +9,12 @@ import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; import io.opentelemetry.exporter.prometheus.PrometheusHttpServer; import io.opentelemetry.exporter.prometheus.PrometheusHttpServerBuilder; +import io.opentelemetry.exporter.prometheus.TranslationStrategy; import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider; import io.opentelemetry.sdk.common.internal.IncludeExcludePredicate; import io.opentelemetry.sdk.metrics.export.MetricReader; import java.util.List; +import java.util.Locale; /** * Declarative configuration SPI implementation for {@link PrometheusHttpServer}. @@ -54,6 +56,10 @@ public MetricReader create(DeclarativeConfigProperties config) { if (withoutScopeInfo != null) { prometheusBuilder.setOtelScopeLabelsEnabled(!withoutScopeInfo); } + String translationStrategy = config.getString("translation_strategy"); + if (translationStrategy != null) { + prometheusBuilder.setTranslationStrategy(parseTranslationStrategy(translationStrategy)); + } DeclarativeConfigProperties withResourceConstantLabels = config.getStructured("with_resource_constant_labels"); @@ -72,4 +78,18 @@ public MetricReader create(DeclarativeConfigProperties config) { return prometheusBuilder.build(); } + + private static TranslationStrategy parseTranslationStrategy(String value) { + String normalized = + value + .replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .replace('-', '_') + .replace('/', '_') + .replace(' ', '_') + .toUpperCase(Locale.ROOT); + if (normalized.endsWith("_DEVELOPMENT")) { + normalized = normalized.substring(0, normalized.length() - "_DEVELOPMENT".length()); + } + return TranslationStrategy.valueOf(normalized); + } } diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java index c7618769d53..808f3372372 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java @@ -40,6 +40,7 @@ import io.prometheus.metrics.expositionformats.ExpositionFormats; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.MetricMetadata; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.ByteArrayOutputStream; @@ -73,6 +74,7 @@ class Otel2PrometheusConverterTest { new Otel2PrometheusConverter( /* otelScopeLabelsEnabled= */ true, /* targetInfoMetricEnabled= */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, /* allowedResourceAttributesFilter= */ null); @ParameterizedTest @@ -193,6 +195,52 @@ private static Stream metricMetadataArgs() { "_metric_name_bytes_count")); } + @ParameterizedTest + @MethodSource("translationStrategyArgs") + void metricMetadata_translationStrategy( + TranslationStrategy translationStrategy, + String expectedName, + String expectedExpositionBaseName, + String expectedOriginalName) { + Otel2PrometheusConverter converter = + new Otel2PrometheusConverter( + /* otelScopeLabelsEnabled= */ true, + /* targetInfoMetricEnabled= */ true, + translationStrategy, + /* allowedResourceAttributesFilter= */ null); + + MetricSnapshots snapshots = + converter.convert( + Collections.singletonList( + createSampleMetricData("sample.name", "By", MetricDataType.LONG_SUM))); + + MetricMetadata metadata = snapshots.get(0).getMetadata(); + assertThat(metadata.getName()).isEqualTo(expectedName); + assertThat(metadata.getExpositionBaseName()).isEqualTo(expectedExpositionBaseName); + assertThat(metadata.getOriginalName()).isEqualTo(expectedOriginalName); + } + + private static Stream translationStrategyArgs() { + return Stream.of( + Arguments.of( + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + "sample_name_bytes", + "sample_name_bytes", + "sample_name_bytes"), + Arguments.of( + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + "sample_name", + "sample_name", + "sample_name"), + Arguments.of( + TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES, + "sample.name_bytes", + "sample.name_bytes_total", + "sample.name_bytes_total"), + Arguments.of( + TranslationStrategy.NO_TRANSLATION, "sample.name", "sample.name", "sample.name")); + } + @ParameterizedTest @MethodSource("resourceAttributesAdditionArgs") void resourceAttributesAddition( @@ -206,6 +254,7 @@ void resourceAttributesAddition( new Otel2PrometheusConverter( /* otelScopeLabelsEnabled= */ true, /* targetInfoMetricEnabled= */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, allowedResourceAttributesFilter); ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -509,6 +558,7 @@ void validateCacheIsBounded() { new Otel2PrometheusConverter( /* otelScopeLabelsEnabled= */ true, /* targetInfoMetricEnabled= */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, /* allowedResourceAttributesFilter= */ countPredicate); // Create 20 different metric data objects with 2 different resource attributes; diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerTest.java index af31c5e224d..c1e8cd0f4f0 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerTest.java @@ -239,6 +239,50 @@ void fetchOpenMetrics() { + "# EOF\n"); } + @Test + void fetchOpenMetrics_translationStrategyEnablesOm2() { + try (PrometheusHttpServer prometheusServer = + PrometheusHttpServer.builder() + .setHost("localhost") + .setPort(0) + .setTranslationStrategy(TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES) + .build()) { + prometheusServer.register( + new CollectionRegistration() { + @Override + public Collection collectAllMetrics() { + return metricData.get(); + } + }); + WebClient client = + WebClient.builder("http://localhost:" + prometheusServer.getAddress().getPort()) + .decorator(RetryingClient.newDecorator(RetryRule.failsafe())) + .build(); + + AggregatedHttpResponse response = + client + .execute( + RequestHeaders.of( + HttpMethod.GET, + "/metrics", + HttpHeaderNames.ACCEPT, + "application/openmetrics-text")) + .aggregate() + .join(); + + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.headers().get(HttpHeaderNames.CONTENT_TYPE)) + .isEqualTo("application/openmetrics-text; version=1.0.0; charset=utf-8"); + assertThat(response.contentUtf8()) + .contains( + "grpc_name{kp=\"vp\",otel_scope_name=\"grpc\",otel_scope_version=\"version\"} 5.0") + .contains( + "http_name{kp=\"vp\",otel_scope_name=\"http\",otel_scope_version=\"version\"} 3.5") + .doesNotContain("grpc_name_unit_total") + .doesNotContain("http_name_unit_total"); + } + } + @Test void fetchProtobuf() { AggregatedHttpResponse response = @@ -432,7 +476,7 @@ void stringRepresentation() { "PrometheusHttpServer{" + "host=localhost," + "port=0," - + "metricReader=PrometheusMetricReader{otelScopeLabelsEnabled=true,targetInfoMetricEnabled=true,allowedResourceAttributesFilter=null}," + + "metricReader=PrometheusMetricReader{otelScopeLabelsEnabled=true,targetInfoMetricEnabled=true,translationStrategy=UNDERSCORE_ESCAPING_WITH_SUFFIXES,allowedResourceAttributesFilter=null}," + "memoryMode=REUSABLE_DATA," + "defaultAggregationSelector=DefaultAggregationSelector{COUNTER=default, UP_DOWN_COUNTER=default, HISTOGRAM=default, OBSERVABLE_COUNTER=default, OBSERVABLE_UP_DOWN_COUNTER=default, OBSERVABLE_GAUGE=default, GAUGE=default}" + "}"); diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderTest.java index 57bcdaf3b5a..c2474922b7c 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReaderTest.java @@ -1074,10 +1074,13 @@ void deprecatedConstructor() { assertThat(new PrometheusMetricReader(/* otelScopeEnabled= */ false, null)) .usingRecursiveComparison() .isEqualTo(new PrometheusMetricReader(null)); - // The 3-arg constructor should behave the same as the 2-arg deprecated constructor + // The 4-arg constructor should behave the same as the 2-arg deprecated constructor assertThat( new PrometheusMetricReader( - null, /* otelScopeLabelsEnabled= */ true, /* targetInfoMetricEnabled */ true)) + null, + /* otelScopeLabelsEnabled= */ true, + /* targetInfoMetricEnabled */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES)) .usingRecursiveComparison() .isEqualTo(new PrometheusMetricReader(null)); } diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MetricReaderFactoryTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MetricReaderFactoryTest.java index b84115618da..390fc287837 100644 --- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MetricReaderFactoryTest.java +++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MetricReaderFactoryTest.java @@ -18,6 +18,7 @@ import io.opentelemetry.common.ComponentLoader; import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; import io.opentelemetry.exporter.prometheus.PrometheusHttpServer; +import io.opentelemetry.exporter.prometheus.TranslationStrategy; import io.opentelemetry.internal.testing.CleanupExtension; import io.opentelemetry.sdk.common.internal.IncludeExcludePredicate; import io.opentelemetry.sdk.declarativeconfig.internal.model.CardinalityLimitsModel; @@ -172,6 +173,7 @@ void create_PullPrometheusConfigured() throws IOException { PrometheusHttpServer.builder() .setHost("localhost") .setPort(port) + .setTranslationStrategy(TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES) .setAllowedResourceAttributesFilter( IncludeExcludePredicate.createPatternMatching( singletonList("foo"), singletonList("bar"))) From 2cc649c6238d0dfb485be3e32e90397c5fe50494 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 08:39:51 +0000 Subject: [PATCH 02/10] Simplify Prometheus translation strategy mapping Signed-off-by: Gregor Zeitlinger --- .../internal/PrometheusComponentProvider.java | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java index 391f9b49244..4d042fddfe0 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java @@ -14,7 +14,6 @@ import io.opentelemetry.sdk.common.internal.IncludeExcludePredicate; import io.opentelemetry.sdk.metrics.export.MetricReader; import java.util.List; -import java.util.Locale; /** * Declarative configuration SPI implementation for {@link PrometheusHttpServer}. @@ -80,16 +79,27 @@ public MetricReader create(DeclarativeConfigProperties config) { } private static TranslationStrategy parseTranslationStrategy(String value) { - String normalized = - value - .replaceAll("([a-z0-9])([A-Z])", "$1_$2") - .replace('-', '_') - .replace('/', '_') - .replace(' ', '_') - .toUpperCase(Locale.ROOT); - if (normalized.endsWith("_DEVELOPMENT")) { - normalized = normalized.substring(0, normalized.length() - "_DEVELOPMENT".length()); + switch (value) { + case "UnderscoreEscapingWithSuffixes": + case "underscore_escaping_with_suffixes": + return TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES; + case "UnderscoreEscapingWithoutSuffixes": + case "UnderscoreEscapingWithoutSuffixes/Development": + case "underscore_escaping_without_suffixes": + case "underscore_escaping_without_suffixes/development": + return TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES; + case "NoUTF8EscapingWithSuffixes": + case "NoUTF8EscapingWithSuffixes/Development": + case "no_utf8_escaping_with_suffixes": + case "no_utf8_escaping_with_suffixes/development": + return TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES; + case "NoTranslation": + case "NoTranslation/Development": + case "no_translation": + case "no_translation/development": + return TranslationStrategy.NO_TRANSLATION; + default: + throw new IllegalArgumentException("Unsupported translation_strategy: " + value); } - return TranslationStrategy.valueOf(normalized); } } From 6986009e9778165b89433d885ff4f01ea652368d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 09:07:13 +0000 Subject: [PATCH 03/10] Restrict Prometheus strategy parsing to declared values Signed-off-by: Gregor Zeitlinger --- .../internal/PrometheusComponentProvider.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java index 4d042fddfe0..328e5d31c48 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java @@ -80,22 +80,12 @@ public MetricReader create(DeclarativeConfigProperties config) { private static TranslationStrategy parseTranslationStrategy(String value) { switch (value) { - case "UnderscoreEscapingWithSuffixes": case "underscore_escaping_with_suffixes": return TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES; - case "UnderscoreEscapingWithoutSuffixes": - case "UnderscoreEscapingWithoutSuffixes/Development": - case "underscore_escaping_without_suffixes": case "underscore_escaping_without_suffixes/development": return TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES; - case "NoUTF8EscapingWithSuffixes": - case "NoUTF8EscapingWithSuffixes/Development": - case "no_utf8_escaping_with_suffixes": case "no_utf8_escaping_with_suffixes/development": return TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES; - case "NoTranslation": - case "NoTranslation/Development": - case "no_translation": case "no_translation/development": return TranslationStrategy.NO_TRANSLATION; default: From ae83c7ec324ae0d10a5d8de3b484475517d3f6e5 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 09:58:09 +0000 Subject: [PATCH 04/10] Align Prometheus label translation Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverter.java | 57 +++++++++++-- .../Otel2PrometheusConverterTest.java | 83 +++++++++++++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index 9c022074844..e6823b0c3cc 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -6,7 +6,6 @@ package io.opentelemetry.exporter.prometheus; import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName; -import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeLabelName; import static java.util.Objects.requireNonNull; import io.opentelemetry.api.common.AttributeKey; @@ -563,18 +562,62 @@ private List> filterAllowedResourceAttributeKeys(@Nullable Resou return allowedAttributeKeys; } - /** - * Convert an attribute key to a legacy Prometheus label name. {@code prometheusName} converts - * non-standard characters (dots, dashes, etc.) to underscores, and {@code sanitizeLabelName} - * strips invalid leading prefixes. - */ private String convertLabelName(String key) { if (translationStrategy.shouldEscape()) { - return sanitizeLabelName(prometheusName(key)); + return convertLegacyLabelName(key); } return key; } + private static String convertLegacyLabelName(String key) { + if (key.isEmpty()) { + throw new IllegalArgumentException("label name is empty"); + } + + StringBuilder result = new StringBuilder(key.length()); + boolean previousWasUnderscore = false; + for (int i = 0; i < key.length(); ) { + int codePoint = key.codePointAt(i); + if (isValidLegacyLabelChar(codePoint)) { + result.appendCodePoint(codePoint); + previousWasUnderscore = false; + } else if (!previousWasUnderscore) { + result.append('_'); + previousWasUnderscore = true; + } + i += Character.charCount(codePoint); + } + + String normalized = result.toString(); + if (!normalized.isEmpty() && Character.isDigit(normalized.charAt(0))) { + normalized = "key_" + normalized; + } + if (containsOnlyUnderscores(normalized)) { + throw new IllegalArgumentException( + "normalization for label name \"" + + key + + "\" resulted in invalid name \"" + + normalized + + "\""); + } + return normalized; + } + + private static boolean isValidLegacyLabelChar(int codePoint) { + return (codePoint >= 'a' && codePoint <= 'z') + || (codePoint >= 'A' && codePoint <= 'Z') + || (codePoint >= '0' && codePoint <= '9'); + } + + private static boolean containsOnlyUnderscores(String value) { + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) != '_') { + return false; + } + } + return true; + } + private MetricMetadata convertMetadata(MetricData metricData, boolean isCounter) { switch (translationStrategy) { case UNDERSCORE_ESCAPING_WITH_SUFFIXES: diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java index 808f3372372..c9f4957c8c9 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java @@ -16,6 +16,7 @@ import static io.opentelemetry.api.common.AttributeKey.valueKey; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.KeyValue; @@ -241,6 +242,88 @@ private static Stream translationStrategyArgs() { TranslationStrategy.NO_TRANSLATION, "sample.name", "sample.name", "sample.name")); } + @ParameterizedTest + @MethodSource("legacyLabelNameTranslationArgs") + void labelNameTranslation_underscoreEscaping(String labelName, String expectedLabelName) { + Labels labels = + convertAttributeLabels(labelName, TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES); + + assertThat(labels.size()).isEqualTo(1); + assertThat(labels.getName(0)).isEqualTo(expectedLabelName); + assertThat(labels.getValue(0)).isEqualTo("value"); + } + + private static Stream legacyLabelNameTranslationArgs() { + return Stream.of( + Arguments.of("label:with:colons", "label_with_colons"), + Arguments.of("LabelWithCapitalLetters", "LabelWithCapitalLetters"), + Arguments.of("label!with&special$chars)", "label_with_special_chars_"), + Arguments.of( + "label_with_foreign_characters_\u5b57\u7b26", "label_with_foreign_characters_"), + Arguments.of("label.with.dots", "label_with_dots"), + Arguments.of("123label", "key_123label"), + Arguments.of("_label_starting_with_underscore", "_label_starting_with_underscore"), + Arguments.of("__label_starting_with_2underscores", "_label_starting_with_2underscores"), + Arguments.of("label__with__double__underscores", "label_with_double_underscores"), + Arguments.of("label.name__with&&special##chars", "label_name_with_special_chars"), + // Prometheus Java rejects user labels starting with "__". + Arguments.of("__reserved__label__name__", "_reserved_label_name_"), + Arguments.of("trailing_underscores___", "trailing_underscores_")); + } + + @Test + void labelNameTranslation_legacyRejectsInvalidNormalizedName() { + assertThatThrownBy( + () -> + convertAttributeLabels( + "\u3088\u3046\u3053\u305d", + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "normalization for label name \"\u3088\u3046\u3053\u305d\" resulted in invalid name" + + " \"_\""); + } + + @ParameterizedTest + @MethodSource("nonEscapingTranslationStrategyArgs") + void labelNameTranslation_nonEscapingStrategiesPreserveLabels( + TranslationStrategy translationStrategy) { + Labels labels = convertAttributeLabels("label:with:colons", translationStrategy); + + assertThat(labels.size()).isEqualTo(1); + assertThat(labels.getName(0)).isEqualTo("label:with:colons"); + assertThat(labels.getValue(0)).isEqualTo("value"); + } + + private static Stream nonEscapingTranslationStrategyArgs() { + return Stream.of( + Arguments.of(TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES), + Arguments.of(TranslationStrategy.NO_TRANSLATION)); + } + + private static Labels convertAttributeLabels( + String labelName, TranslationStrategy translationStrategy) { + Otel2PrometheusConverter converter = + new Otel2PrometheusConverter( + /* otelScopeLabelsEnabled= */ false, + /* targetInfoMetricEnabled= */ false, + translationStrategy, + /* allowedResourceAttributesFilter= */ null); + + MetricSnapshots snapshots = + converter.convert( + Collections.singletonList( + createSampleMetricData( + "sample", + "1", + MetricDataType.LONG_SUM, + Attributes.of(stringKey(labelName), "value"), + Resource.empty()))); + + assertThat(snapshots).hasSize(1); + return snapshots.get(0).getDataPoints().get(0).getLabels(); + } + @ParameterizedTest @MethodSource("resourceAttributesAdditionArgs") void resourceAttributesAddition( From 6f1f18f1771ad2abc66b6d1c970e47158736e495 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 10:41:25 +0000 Subject: [PATCH 05/10] Document Prometheus label reservation Signed-off-by: Gregor Zeitlinger --- .../exporter/prometheus/Otel2PrometheusConverter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index e6823b0c3cc..5f5835c1bdf 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -574,6 +574,10 @@ private static String convertLegacyLabelName(String key) { throw new IllegalArgumentException("label name is empty"); } + // The OTel compatibility spec requires invalid attribute-name characters and repeated + // underscores to collapse to a single "_". Prometheus label names beginning with "__" are + // reserved for internal labels like "__name__" and scrape/relabel labels, and Prometheus Java + // rejects them as user labels, so do not preserve "__...__" reserved-looking names here. StringBuilder result = new StringBuilder(key.length()); boolean previousWasUnderscore = false; for (int i = 0; i < key.length(); ) { From 7dfbab29ee8de0bbc3f2492f5bb0d471a068e243 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 11:15:10 +0000 Subject: [PATCH 06/10] Clarify Prometheus translation ownership Signed-off-by: Gregor Zeitlinger --- .../exporter/prometheus/Otel2PrometheusConverter.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index 5f5835c1bdf..caa3cd1cb02 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -574,10 +574,12 @@ private static String convertLegacyLabelName(String key) { throw new IllegalArgumentException("label name is empty"); } - // The OTel compatibility spec requires invalid attribute-name characters and repeated - // underscores to collapse to a single "_". Prometheus label names beginning with "__" are - // reserved for internal labels like "__name__" and scrape/relabel labels, and Prometheus Java - // rejects them as user labels, so do not preserve "__...__" reserved-looking names here. + // OTel owns OTLP-to-Prometheus translation. Prometheus Java validates and serializes names, + // but no longer owns this naming mangling. The OTel compatibility spec requires invalid + // attribute-name characters and repeated underscores to collapse to a single "_". Prometheus + // label names beginning with "__" are reserved for internal labels like "__name__" and + // scrape/relabel labels, and Prometheus Java rejects them as user labels, so do not preserve + // "__...__" reserved-looking names here. StringBuilder result = new StringBuilder(key.length()); boolean previousWasUnderscore = false; for (int i = 0; i < key.length(); ) { From 1ad36002a149196ac70bd72cf1f6b72a9e9359f6 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 11:19:33 +0000 Subject: [PATCH 07/10] Own Prometheus name translation Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverter.java | 31 +++++++++++++-- .../prometheus/PrometheusUnitsHelper.java | 39 ++++++++++++++++++- .../Otel2PrometheusConverterTest.java | 2 +- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index caa3cd1cb02..0a62aee1276 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -5,7 +5,6 @@ package io.opentelemetry.exporter.prometheus; -import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName; import static java.util.Objects.requireNonNull; import io.opentelemetry.api.common.AttributeKey; @@ -624,6 +623,32 @@ private static boolean containsOnlyUnderscores(String value) { return true; } + private static String convertLegacyMetricName(String name) { + if (name.isEmpty()) { + return name; + } + + StringBuilder result = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); ) { + int codePoint = name.codePointAt(i); + if (isValidLegacyMetricChar(codePoint, i)) { + result.appendCodePoint(codePoint); + } else { + result.append('_'); + } + i += Character.charCount(codePoint); + } + return result.toString(); + } + + private static boolean isValidLegacyMetricChar(int codePoint, int index) { + return (codePoint >= 'a' && codePoint <= 'z') + || (codePoint >= 'A' && codePoint <= 'Z') + || codePoint == '_' + || codePoint == ':' + || (codePoint >= '0' && codePoint <= '9' && index > 0); + } + private MetricMetadata convertMetadata(MetricData metricData, boolean isCounter) { switch (translationStrategy) { case UNDERSCORE_ESCAPING_WITH_SUFFIXES: @@ -639,7 +664,7 @@ private MetricMetadata convertMetadata(MetricData metricData, boolean isCounter) } private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metricData) { - String name = prometheusName(metricData.getName()); + String name = convertLegacyMetricName(metricData.getName()); String help = metricData.getDescription(); Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit()); name = stripRepeatedUnderscores(stripReservedMetricSuffixes(name)); @@ -650,7 +675,7 @@ private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metr } private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { - String rawName = stripRepeatedUnderscores(prometheusName(metricData.getName())); + String rawName = stripRepeatedUnderscores(convertLegacyMetricName(metricData.getName())); String name = stripReservedMetricSuffixes(rawName); return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null); } diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java index be5e0146470..8d63a7a48c8 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java @@ -5,7 +5,6 @@ package io.opentelemetry.exporter.prometheus; -import io.prometheus.metrics.model.snapshots.PrometheusNaming; import io.prometheus.metrics.model.snapshots.Unit; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -99,7 +98,7 @@ static Unit convertUnit(String otelUnit) { @Nullable private static Unit unitOrNull(String name) { try { - String sanitized = PrometheusNaming.sanitizeUnitName(name); + String sanitized = sanitizeUnitName(name); sanitized = stripReservedUnitSuffixes(sanitized); if (sanitized.isEmpty()) { return null; @@ -110,6 +109,42 @@ private static Unit unitOrNull(String name) { } } + private static String sanitizeUnitName(String unitName) { + if (unitName.isEmpty()) { + throw new IllegalArgumentException("Cannot convert an empty string to a valid unit name."); + } + String sanitizedName = replaceIllegalCharsInUnitName(unitName); + while (sanitizedName.startsWith("_") || sanitizedName.startsWith(".")) { + sanitizedName = sanitizedName.substring(1); + } + while (sanitizedName.endsWith(".") || sanitizedName.endsWith("_")) { + sanitizedName = sanitizedName.substring(0, sanitizedName.length() - 1); + } + if (sanitizedName.isEmpty()) { + throw new IllegalArgumentException( + "Cannot convert '" + unitName + "' into a valid unit name."); + } + return sanitizedName; + } + + private static String replaceIllegalCharsInUnitName(String name) { + int length = name.length(); + char[] sanitized = new char[length]; + for (int i = 0; i < length; i++) { + char ch = name.charAt(i); + if (ch == ':' + || ch == '.' + || (ch >= 'a' && ch <= 'z') + || (ch >= 'A' && ch <= 'Z') + || (ch >= '0' && ch <= '9')) { + sanitized[i] = ch; + } else { + sanitized[i] = '_'; + } + } + return new String(sanitized); + } + private static String stripReservedUnitSuffixes(String name) { boolean modified = true; while (modified) { diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java index c9f4957c8c9..f58fc0fec6b 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java @@ -421,7 +421,7 @@ private static Stream resourceAttributesAdditionArgs() { } @Test - void prometheusNameCollisionTest_Issue6277() { + void metricNameCollisionTest_Issue6277() { // NOTE: Metrics with the same resolved prometheus name should merge. However, // Otel2PrometheusConverter is not responsible for merging individual series, so the merge will // fail if the two different metrics contain overlapping series. Users should deal with this by From c59b24d526b7fb70744ef1a70334b98b963505fe Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 11:26:57 +0000 Subject: [PATCH 08/10] Fix Prometheus test checkstyle Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverterTest.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java index f58fc0fec6b..1e3ceea7755 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java @@ -258,8 +258,7 @@ private static Stream legacyLabelNameTranslationArgs() { Arguments.of("label:with:colons", "label_with_colons"), Arguments.of("LabelWithCapitalLetters", "LabelWithCapitalLetters"), Arguments.of("label!with&special$chars)", "label_with_special_chars_"), - Arguments.of( - "label_with_foreign_characters_\u5b57\u7b26", "label_with_foreign_characters_"), + Arguments.of("label_with_foreign_characters_字符", "label_with_foreign_characters_"), Arguments.of("label.with.dots", "label_with_dots"), Arguments.of("123label", "key_123label"), Arguments.of("_label_starting_with_underscore", "_label_starting_with_underscore"), @@ -276,12 +275,9 @@ void labelNameTranslation_legacyRejectsInvalidNormalizedName() { assertThatThrownBy( () -> convertAttributeLabels( - "\u3088\u3046\u3053\u305d", - TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES)) + "ようこそ", TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage( - "normalization for label name \"\u3088\u3046\u3053\u305d\" resulted in invalid name" - + " \"_\""); + .hasMessage("normalization for label name \"ようこそ\" resulted in invalid name" + " \"_\""); } @ParameterizedTest From 671e43793a2c651f5ba63f8cce2d3361613c6b11 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 5 May 2026 10:19:17 +0000 Subject: [PATCH 09/10] Address Prometheus translation review comments Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverter.java | 181 +++++++++++------- .../prometheus/PrometheusUnitsHelper.java | 2 + .../prometheus/TranslationStrategy.java | 5 - .../internal/PrometheusComponentProvider.java | 2 +- .../Otel2PrometheusConverterTest.java | 62 +++++- 5 files changed, 169 insertions(+), 83 deletions(-) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index 0a62aee1276..ec05a9e572a 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -155,62 +155,72 @@ MetricSnapshots convert(@Nullable Collection metricDataCollection) { @Nullable private MetricSnapshot convert(MetricData metricData) { - - // Note that AggregationTemporality.DELTA should never happen - // because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE. - - boolean isCounter = isMonotonicSum(metricData); - MetricMetadata metadata = convertMetadata(metricData, isCounter); - InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo(); - switch (metricData.getType()) { - case LONG_GAUGE: - return convertLongGauge( - metadata, scope, metricData.getLongGaugeData().getPoints(), metricData.getResource()); - case DOUBLE_GAUGE: - return convertDoubleGauge( - metadata, scope, metricData.getDoubleGaugeData().getPoints(), metricData.getResource()); - case LONG_SUM: - SumData longSumData = metricData.getLongSumData(); - if (longSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { - return null; - } else if (longSumData.isMonotonic()) { - return convertLongCounter( - metadata, scope, longSumData.getPoints(), metricData.getResource()); - } else { + try { + // Note that AggregationTemporality.DELTA should never happen + // because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE. + + boolean isCounter = isMonotonicSum(metricData); + MetricMetadata metadata = convertMetadata(metricData, isCounter); + InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo(); + switch (metricData.getType()) { + case LONG_GAUGE: return convertLongGauge( - metadata, scope, longSumData.getPoints(), metricData.getResource()); - } - case DOUBLE_SUM: - SumData doubleSumData = metricData.getDoubleSumData(); - if (doubleSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { - return null; - } else if (doubleSumData.isMonotonic()) { - return convertDoubleCounter( - metadata, scope, doubleSumData.getPoints(), metricData.getResource()); - } else { + metadata, scope, metricData.getLongGaugeData().getPoints(), metricData.getResource()); + case DOUBLE_GAUGE: return convertDoubleGauge( - metadata, scope, doubleSumData.getPoints(), metricData.getResource()); - } - case HISTOGRAM: - HistogramData histogramData = metricData.getHistogramData(); - if (histogramData.getAggregationTemporality() == AggregationTemporality.DELTA) { - return null; - } else { - return convertHistogram( - metadata, scope, histogramData.getPoints(), metricData.getResource()); - } - case EXPONENTIAL_HISTOGRAM: - ExponentialHistogramData exponentialHistogramData = - metricData.getExponentialHistogramData(); - if (exponentialHistogramData.getAggregationTemporality() == AggregationTemporality.DELTA) { - return null; - } else { - return convertExponentialHistogram( - metadata, scope, exponentialHistogramData.getPoints(), metricData.getResource()); - } - case SUMMARY: - return convertSummary( - metadata, scope, metricData.getSummaryData().getPoints(), metricData.getResource()); + metadata, + scope, + metricData.getDoubleGaugeData().getPoints(), + metricData.getResource()); + case LONG_SUM: + SumData longSumData = metricData.getLongSumData(); + if (longSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { + return null; + } else if (longSumData.isMonotonic()) { + return convertLongCounter( + metadata, scope, longSumData.getPoints(), metricData.getResource()); + } else { + return convertLongGauge( + metadata, scope, longSumData.getPoints(), metricData.getResource()); + } + case DOUBLE_SUM: + SumData doubleSumData = metricData.getDoubleSumData(); + if (doubleSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { + return null; + } else if (doubleSumData.isMonotonic()) { + return convertDoubleCounter( + metadata, scope, doubleSumData.getPoints(), metricData.getResource()); + } else { + return convertDoubleGauge( + metadata, scope, doubleSumData.getPoints(), metricData.getResource()); + } + case HISTOGRAM: + HistogramData histogramData = metricData.getHistogramData(); + if (histogramData.getAggregationTemporality() == AggregationTemporality.DELTA) { + return null; + } else { + return convertHistogram( + metadata, scope, histogramData.getPoints(), metricData.getResource()); + } + case EXPONENTIAL_HISTOGRAM: + ExponentialHistogramData exponentialHistogramData = + metricData.getExponentialHistogramData(); + if (exponentialHistogramData.getAggregationTemporality() + == AggregationTemporality.DELTA) { + return null; + } else { + return convertExponentialHistogram( + metadata, scope, exponentialHistogramData.getPoints(), metricData.getResource()); + } + case SUMMARY: + return convertSummary( + metadata, scope, metricData.getSummaryData().getPoints(), metricData.getResource()); + } + } catch (IllegalArgumentException e) { + THROTTLING_LOGGER.log( + Level.WARNING, + "Failed to convert metric " + metricData.getName() + ". Dropping metric.", + e); } return null; } @@ -562,12 +572,19 @@ private List> filterAllowedResourceAttributeKeys(@Nullable Resou } private String convertLabelName(String key) { - if (translationStrategy.shouldEscape()) { + if (shouldEscape(translationStrategy)) { return convertLegacyLabelName(key); } return key; } + /** + * Normalize an attribute name to the legacy Prometheus label scheme. + * + *

This mirrors {@code prometheus/otlptranslator}'s invalid-character collapsing rules, but we + * intentionally do not preserve {@code __...__} names because Prometheus Java rejects user label + * names that begin with {@code __}. + */ private static String convertLegacyLabelName(String key) { if (key.isEmpty()) { throw new IllegalArgumentException("label name is empty"); @@ -594,7 +611,7 @@ private static String convertLegacyLabelName(String key) { } String normalized = result.toString(); - if (!normalized.isEmpty() && Character.isDigit(normalized.charAt(0))) { + if (Character.isDigit(normalized.charAt(0))) { normalized = "key_" + normalized; } if (containsOnlyUnderscores(normalized)) { @@ -629,12 +646,22 @@ private static String convertLegacyMetricName(String name) { } StringBuilder result = new StringBuilder(name.length()); + boolean previousWasUnderscore = false; for (int i = 0; i < name.length(); ) { int codePoint = name.codePointAt(i); if (isValidLegacyMetricChar(codePoint, i)) { - result.appendCodePoint(codePoint); - } else { + if (codePoint == '_') { + if (!previousWasUnderscore) { + result.append('_'); + previousWasUnderscore = true; + } + } else { + result.appendCodePoint(codePoint); + previousWasUnderscore = false; + } + } else if (!previousWasUnderscore) { result.append('_'); + previousWasUnderscore = true; } i += Character.charCount(codePoint); } @@ -667,16 +694,19 @@ private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metr String name = convertLegacyMetricName(metricData.getName()); String help = metricData.getDescription(); Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit()); - name = stripRepeatedUnderscores(stripReservedMetricSuffixes(name)); + name = stripReservedMetricSuffixes(name); if (unit != null && !name.endsWith(unit.toString())) { name = name + "_" + unit; } - return new MetricMetadata(stripRepeatedUnderscores(name), help, unit); + validateNormalizedMetricName(metricData.getName(), name); + return new MetricMetadata(name, help, unit); } private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { - String rawName = stripRepeatedUnderscores(convertLegacyMetricName(metricData.getName())); + String rawName = convertLegacyMetricName(metricData.getName()); String name = stripReservedMetricSuffixes(rawName); + validateNormalizedMetricName(metricData.getName(), rawName); + validateNormalizedMetricName(metricData.getName(), name); return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null); } @@ -718,13 +748,6 @@ private static String stripReservedMetricSuffixes(String name) { return name; } - private static String stripRepeatedUnderscores(String name) { - while (name.contains("__")) { - name = name.replace("__", "_"); - } - return name; - } - private void putOrMerge(Map snapshotsByName, MetricSnapshot snapshot) { String name = getMergeKey(snapshot.getMetadata()); if (snapshotsByName.containsKey(name)) { @@ -738,12 +761,32 @@ private void putOrMerge(Map snapshotsByName, MetricSnaps } private String getMergeKey(MetricMetadata metadata) { - if (translationStrategy.shouldEscape()) { + if (shouldEscape(translationStrategy)) { return metadata.getPrometheusName(); } return metadata.getName(); } + private static boolean shouldEscape(TranslationStrategy translationStrategy) { + return translationStrategy == TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES + || translationStrategy == TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES; + } + + private static void validateNormalizedMetricName(String originalName, String normalizedName) { + if (normalizedName.isEmpty()) { + throw new IllegalArgumentException( + "normalization for metric \"" + originalName + "\" resulted in empty name"); + } + if (containsOnlyUnderscores(normalizedName)) { + throw new IllegalArgumentException( + "normalization for metric \"" + + originalName + + "\" resulted in invalid name \"" + + normalizedName + + "\""); + } + } + /** * OpenTelemetry may use the same metric name multiple times but in different instrumentation * scopes. In that case, we try to merge the metrics. They will have different {@code diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java index 8d63a7a48c8..ef2d7cb52c2 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java @@ -109,6 +109,8 @@ private static Unit unitOrNull(String name) { } } + // These helpers are adapted from Prometheus naming sanitization. We keep a local copy because + // the exporter still needs unit normalization behavior that fits the Prometheus Java model API. private static String sanitizeUnitName(String unitName) { if (unitName.isEmpty()) { throw new IllegalArgumentException("Cannot convert an empty string to a valid unit name."); diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java index 6276d9e17d0..d96e9d483e7 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java @@ -30,9 +30,4 @@ public enum TranslationStrategy { /** Metric and label names are passed through without translation. */ NO_TRANSLATION; - - boolean shouldEscape() { - return this == UNDERSCORE_ESCAPING_WITH_SUFFIXES - || this == UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES; - } } diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java index 328e5d31c48..e670d2dd254 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/internal/PrometheusComponentProvider.java @@ -89,7 +89,7 @@ private static TranslationStrategy parseTranslationStrategy(String value) { case "no_translation/development": return TranslationStrategy.NO_TRANSLATION; default: - throw new IllegalArgumentException("Unsupported translation_strategy: " + value); + throw new DeclarativeConfigException("Unsupported translation_strategy: " + value); } } } diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java index 1e3ceea7755..1cfab0aafce 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java @@ -16,7 +16,6 @@ import static io.opentelemetry.api.common.AttributeKey.valueKey; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.KeyValue; @@ -242,6 +241,24 @@ private static Stream translationStrategyArgs() { TranslationStrategy.NO_TRANSLATION, "sample.name", "sample.name", "sample.name")); } + @Test + void metricMetadata_underscoreEscapingCollapsesRepeatedUnderscores() { + MetricSnapshots snapshots = + converter.convert( + Collections.singletonList( + createSampleMetricData("sample__name", "By", MetricDataType.LONG_SUM))); + + MetricMetadata metadata = + snapshots.stream() + .filter(snapshot -> snapshot instanceof CounterSnapshot) + .findFirst() + .orElseThrow(AssertionError::new) + .getMetadata(); + assertThat(metadata.getName()).isEqualTo("sample_name_bytes"); + assertThat(metadata.getExpositionBaseName()).isEqualTo("sample_name_bytes"); + assertThat(metadata.getOriginalName()).isEqualTo("sample_name_bytes"); + } + @ParameterizedTest @MethodSource("legacyLabelNameTranslationArgs") void labelNameTranslation_underscoreEscaping(String labelName, String expectedLabelName) { @@ -271,13 +288,42 @@ private static Stream legacyLabelNameTranslationArgs() { } @Test - void labelNameTranslation_legacyRejectsInvalidNormalizedName() { - assertThatThrownBy( - () -> - convertAttributeLabels( - "ようこそ", TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("normalization for label name \"ようこそ\" resulted in invalid name" + " \"_\""); + void labelNameTranslation_legacyDropsMetricWithInvalidNormalizedName() { + Otel2PrometheusConverter converter = + new Otel2PrometheusConverter( + /* otelScopeLabelsEnabled= */ false, + /* targetInfoMetricEnabled= */ false, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + /* allowedResourceAttributesFilter= */ null); + + MetricSnapshots snapshots = + converter.convert( + Collections.singletonList( + createSampleMetricData( + "sample", + "1", + MetricDataType.LONG_SUM, + Attributes.of(stringKey("ようこそ"), "value"), + Resource.empty()))); + + assertThat(snapshots).isEmpty(); + } + + @Test + void metricNameTranslation_legacyDropsMetricWithInvalidNormalizedName() { + Otel2PrometheusConverter converter = + new Otel2PrometheusConverter( + /* otelScopeLabelsEnabled= */ false, + /* targetInfoMetricEnabled= */ false, + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + /* allowedResourceAttributesFilter= */ null); + + MetricSnapshots snapshots = + converter.convert( + Collections.singletonList( + createSampleMetricData("ようこそ", "1", MetricDataType.LONG_SUM))); + + assertThat(snapshots).isEmpty(); } @ParameterizedTest From 957e2e7994520076ecb281c4e6277c8609d8cc6a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 8 May 2026 07:56:31 +0000 Subject: [PATCH 10/10] Address Jack's Prometheus translation review nits - Replace IllegalArgumentException control flow in PrometheusUnitsHelper.sanitizeUnitName with @Nullable return; drop the try/catch in unitOrNull. - Extract doConvert helper so convert is just the IAE try/catch boundary. - Inline the getMergeKey ternary at the putOrMerge call site. - Reorder convertMetadataEscapedWithSuffixes for readability and use the explicit 5-arg MetricMetadata constructor. Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverter.java | 141 +++++++++--------- .../prometheus/PrometheusUnitsHelper.java | 21 ++- 2 files changed, 79 insertions(+), 83 deletions(-) diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index ec05a9e572a..1ba2322a8b4 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -156,71 +156,73 @@ MetricSnapshots convert(@Nullable Collection metricDataCollection) { @Nullable private MetricSnapshot convert(MetricData metricData) { try { - // Note that AggregationTemporality.DELTA should never happen - // because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE. - - boolean isCounter = isMonotonicSum(metricData); - MetricMetadata metadata = convertMetadata(metricData, isCounter); - InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo(); - switch (metricData.getType()) { - case LONG_GAUGE: - return convertLongGauge( - metadata, scope, metricData.getLongGaugeData().getPoints(), metricData.getResource()); - case DOUBLE_GAUGE: - return convertDoubleGauge( - metadata, - scope, - metricData.getDoubleGaugeData().getPoints(), - metricData.getResource()); - case LONG_SUM: - SumData longSumData = metricData.getLongSumData(); - if (longSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { - return null; - } else if (longSumData.isMonotonic()) { - return convertLongCounter( - metadata, scope, longSumData.getPoints(), metricData.getResource()); - } else { - return convertLongGauge( - metadata, scope, longSumData.getPoints(), metricData.getResource()); - } - case DOUBLE_SUM: - SumData doubleSumData = metricData.getDoubleSumData(); - if (doubleSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { - return null; - } else if (doubleSumData.isMonotonic()) { - return convertDoubleCounter( - metadata, scope, doubleSumData.getPoints(), metricData.getResource()); - } else { - return convertDoubleGauge( - metadata, scope, doubleSumData.getPoints(), metricData.getResource()); - } - case HISTOGRAM: - HistogramData histogramData = metricData.getHistogramData(); - if (histogramData.getAggregationTemporality() == AggregationTemporality.DELTA) { - return null; - } else { - return convertHistogram( - metadata, scope, histogramData.getPoints(), metricData.getResource()); - } - case EXPONENTIAL_HISTOGRAM: - ExponentialHistogramData exponentialHistogramData = - metricData.getExponentialHistogramData(); - if (exponentialHistogramData.getAggregationTemporality() - == AggregationTemporality.DELTA) { - return null; - } else { - return convertExponentialHistogram( - metadata, scope, exponentialHistogramData.getPoints(), metricData.getResource()); - } - case SUMMARY: - return convertSummary( - metadata, scope, metricData.getSummaryData().getPoints(), metricData.getResource()); - } + return doConvert(metricData); } catch (IllegalArgumentException e) { THROTTLING_LOGGER.log( Level.WARNING, "Failed to convert metric " + metricData.getName() + ". Dropping metric.", e); + return null; + } + } + + @Nullable + private MetricSnapshot doConvert(MetricData metricData) { + // Note that AggregationTemporality.DELTA should never happen + // because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE. + + boolean isCounter = isMonotonicSum(metricData); + MetricMetadata metadata = convertMetadata(metricData, isCounter); + InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo(); + switch (metricData.getType()) { + case LONG_GAUGE: + return convertLongGauge( + metadata, scope, metricData.getLongGaugeData().getPoints(), metricData.getResource()); + case DOUBLE_GAUGE: + return convertDoubleGauge( + metadata, scope, metricData.getDoubleGaugeData().getPoints(), metricData.getResource()); + case LONG_SUM: + SumData longSumData = metricData.getLongSumData(); + if (longSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { + return null; + } else if (longSumData.isMonotonic()) { + return convertLongCounter( + metadata, scope, longSumData.getPoints(), metricData.getResource()); + } else { + return convertLongGauge( + metadata, scope, longSumData.getPoints(), metricData.getResource()); + } + case DOUBLE_SUM: + SumData doubleSumData = metricData.getDoubleSumData(); + if (doubleSumData.getAggregationTemporality() == AggregationTemporality.DELTA) { + return null; + } else if (doubleSumData.isMonotonic()) { + return convertDoubleCounter( + metadata, scope, doubleSumData.getPoints(), metricData.getResource()); + } else { + return convertDoubleGauge( + metadata, scope, doubleSumData.getPoints(), metricData.getResource()); + } + case HISTOGRAM: + HistogramData histogramData = metricData.getHistogramData(); + if (histogramData.getAggregationTemporality() == AggregationTemporality.DELTA) { + return null; + } else { + return convertHistogram( + metadata, scope, histogramData.getPoints(), metricData.getResource()); + } + case EXPONENTIAL_HISTOGRAM: + ExponentialHistogramData exponentialHistogramData = + metricData.getExponentialHistogramData(); + if (exponentialHistogramData.getAggregationTemporality() == AggregationTemporality.DELTA) { + return null; + } else { + return convertExponentialHistogram( + metadata, scope, exponentialHistogramData.getPoints(), metricData.getResource()); + } + case SUMMARY: + return convertSummary( + metadata, scope, metricData.getSummaryData().getPoints(), metricData.getResource()); } return null; } @@ -691,15 +693,15 @@ private MetricMetadata convertMetadata(MetricData metricData, boolean isCounter) } private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metricData) { - String name = convertLegacyMetricName(metricData.getName()); + String originalName = metricData.getName(); + String name = stripReservedMetricSuffixes(convertLegacyMetricName(originalName)); String help = metricData.getDescription(); Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit()); - name = stripReservedMetricSuffixes(name); if (unit != null && !name.endsWith(unit.toString())) { name = name + "_" + unit; } - validateNormalizedMetricName(metricData.getName(), name); - return new MetricMetadata(name, help, unit); + validateNormalizedMetricName(originalName, name); + return new MetricMetadata(name, name, name, help, unit); } private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { @@ -749,7 +751,9 @@ private static String stripReservedMetricSuffixes(String name) { } private void putOrMerge(Map snapshotsByName, MetricSnapshot snapshot) { - String name = getMergeKey(snapshot.getMetadata()); + MetricMetadata metadata = snapshot.getMetadata(); + String name = + shouldEscape(translationStrategy) ? metadata.getPrometheusName() : metadata.getName(); if (snapshotsByName.containsKey(name)) { MetricSnapshot merged = merge(snapshotsByName.get(name), snapshot); if (merged != null) { @@ -760,13 +764,6 @@ private void putOrMerge(Map snapshotsByName, MetricSnaps } } - private String getMergeKey(MetricMetadata metadata) { - if (shouldEscape(translationStrategy)) { - return metadata.getPrometheusName(); - } - return metadata.getName(); - } - private static boolean shouldEscape(TranslationStrategy translationStrategy) { return translationStrategy == TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES || translationStrategy == TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES; diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java index ef2d7cb52c2..2ac0ed5665c 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelper.java @@ -97,23 +97,23 @@ static Unit convertUnit(String otelUnit) { @Nullable private static Unit unitOrNull(String name) { - try { - String sanitized = sanitizeUnitName(name); - sanitized = stripReservedUnitSuffixes(sanitized); - if (sanitized.isEmpty()) { - return null; - } - return new Unit(sanitized); - } catch (IllegalArgumentException e) { + String sanitized = sanitizeUnitName(name); + if (sanitized == null) { + return null; + } + sanitized = stripReservedUnitSuffixes(sanitized); + if (sanitized.isEmpty()) { return null; } + return new Unit(sanitized); } // These helpers are adapted from Prometheus naming sanitization. We keep a local copy because // the exporter still needs unit normalization behavior that fits the Prometheus Java model API. + @Nullable private static String sanitizeUnitName(String unitName) { if (unitName.isEmpty()) { - throw new IllegalArgumentException("Cannot convert an empty string to a valid unit name."); + return null; } String sanitizedName = replaceIllegalCharsInUnitName(unitName); while (sanitizedName.startsWith("_") || sanitizedName.startsWith(".")) { @@ -123,8 +123,7 @@ private static String sanitizeUnitName(String unitName) { sanitizedName = sanitizedName.substring(0, sanitizedName.length() - 1); } if (sanitizedName.isEmpty()) { - throw new IllegalArgumentException( - "Cannot convert '" + unitName + "' into a valid unit name."); + return null; } return sanitizedName; }