From 1ab1fc27e46d15f898353caceb4ea279c2b94e11 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 29 Apr 2026 08:14:09 +0000 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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; } From 9c20343d792a25f0f4029421991243234caeb75a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 4 Jun 2026 17:51:25 +0000 Subject: [PATCH 11/19] Bump prometheus client_java 1.6.1 -> 1.7.0 Also bumps protobuf-bom 4.34.1 -> 4.35.0 to match the gencode shipped inside prometheus-metrics-exposition-formats 1.7.0 (runtime must be >= gencode version). Signed-off-by: Gregor Zeitlinger --- dependencyManagement/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 2a24b3e149f..901f77b7cac 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.6.1" +val prometheusServerVersion = "1.7.0" val armeriaVersion = "1.38.0" val junitVersion = "5.14.4" val okhttpVersion = "5.3.2" @@ -28,7 +28,7 @@ val DEPENDENCY_BOMS = listOf( "com.fasterxml.jackson:jackson-bom:2.21.2", "com.google.guava:guava-bom:33.6.0-jre", - "com.google.protobuf:protobuf-bom:4.34.1", + "com.google.protobuf:protobuf-bom:4.35.0", "com.squareup.okhttp3:okhttp-bom:$okhttpVersion", "com.squareup.okio:okio-bom:3.17.0", // applies to transitive dependencies of okhttp "io.grpc:grpc-bom:1.80.0", From 747e64b30f6bd17840e44ee1617d707498752c89 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 10 Jun 2026 11:14:15 +0000 Subject: [PATCH 12/19] Use MetricMetadata builder with local Prometheus snapshot Signed-off-by: Gregor Zeitlinger --- dependencyManagement/build.gradle.kts | 2 +- .../prometheus/Otel2PrometheusConverter.java | 35 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 901f77b7cac..1ea7a01599d 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.7.0" +val prometheusServerVersion = "1.7.1-SNAPSHOT" 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 1ba2322a8b4..2f8172127ad 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 @@ -470,7 +470,7 @@ private Exemplar convertExemplar(double value, ExemplarData exemplar) { private InfoSnapshot makeTargetInfo(Resource resource) { return new InfoSnapshot( - new MetricMetadata("target"), + MetricMetadata.builder().name("target").build(), Collections.singletonList( new InfoDataPointSnapshot( convertAttributes( @@ -701,7 +701,13 @@ private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metr name = name + "_" + unit; } validateNormalizedMetricName(originalName, name); - return new MetricMetadata(name, name, name, help, unit); + return MetricMetadata.builder() + .name(name) + .expositionBaseName(name) + .originalName(name) + .help(help) + .unit(unit) + .build(); } private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { @@ -709,13 +715,17 @@ private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData m String name = stripReservedMetricSuffixes(rawName); validateNormalizedMetricName(metricData.getName(), rawName); validateNormalizedMetricName(metricData.getName(), name); - return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null); + return MetricMetadata.builder() + .name(name) + .expositionBaseName(rawName) + .originalName(rawName) + .help(metricData.getDescription()) + .build(); } 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; @@ -724,13 +734,24 @@ private static MetricMetadata convertMetadataUtf8WithSuffixes( if (isCounter && !expositionBaseName.endsWith("_total")) { expositionBaseName = expositionBaseName + "_total"; } - return new MetricMetadata(stripReservedMetricSuffixes(name), expositionBaseName, help, unit); + return MetricMetadata.builder() + .name(stripReservedMetricSuffixes(name)) + .originalName(expositionBaseName) + .help(metricData.getDescription()) + .unit(unit) + .counterSuffix(isCounter) + .build(); } private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) { String rawName = metricData.getName(); String name = stripReservedMetricSuffixes(rawName); - return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null); + return MetricMetadata.builder() + .name(name) + .expositionBaseName(rawName) + .originalName(rawName) + .help(metricData.getDescription()) + .build(); } private static String stripReservedMetricSuffixes(String name) { @@ -859,7 +880,7 @@ private static MetricMetadata mergeMetadata(MetricMetadata a, MetricMetadata b) + "."); return null; } - return new MetricMetadata(name, help, unit); + return MetricMetadata.builder().name(name).help(help).unit(unit).build(); } private static String typeString(MetricSnapshot snapshot) { From eef409b889a77873dd4c67b6907886ec01fa5c56 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 11 Jun 2026 06:26:41 +0000 Subject: [PATCH 13/19] Add Prometheus negotiation coverage and simplify metadata conversion Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverter.java | 47 +++++++---- .../prometheus/PrometheusHttpServerTest.java | 83 ++++++++++++++++++- 2 files changed, 113 insertions(+), 17 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 2f8172127ad..1c295d2123c 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 @@ -700,32 +700,41 @@ private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metr if (unit != null && !name.endsWith(unit.toString())) { name = name + "_" + unit; } + String expositionBaseName = name; + String originalPrometheusName = name; validateNormalizedMetricName(originalName, name); return MetricMetadata.builder() .name(name) - .expositionBaseName(name) - .originalName(name) + .expositionBaseName(expositionBaseName) + .originalName(originalPrometheusName) .help(help) .unit(unit) .build(); } private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { - String rawName = convertLegacyMetricName(metricData.getName()); - String name = stripReservedMetricSuffixes(rawName); - validateNormalizedMetricName(metricData.getName(), rawName); + String originalName = metricData.getName(); + String expositionBaseName = convertLegacyMetricName(originalName); + String name = stripReservedMetricSuffixes(expositionBaseName); + String originalPrometheusName = expositionBaseName; + String help = metricData.getDescription(); + Unit unit = null; + validateNormalizedMetricName(originalName, expositionBaseName); validateNormalizedMetricName(metricData.getName(), name); return MetricMetadata.builder() .name(name) - .expositionBaseName(rawName) - .originalName(rawName) - .help(metricData.getDescription()) + .expositionBaseName(expositionBaseName) + .originalName(originalPrometheusName) + .help(help) + .unit(unit) .build(); } private static MetricMetadata convertMetadataUtf8WithSuffixes( MetricData metricData, boolean isCounter) { - String name = metricData.getName(); + String originalName = metricData.getName(); + String name = originalName; + String help = metricData.getDescription(); Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit()); if (unit != null && !name.endsWith(unit.toString())) { name = name + "_" + unit; @@ -734,23 +743,29 @@ private static MetricMetadata convertMetadataUtf8WithSuffixes( if (isCounter && !expositionBaseName.endsWith("_total")) { expositionBaseName = expositionBaseName + "_total"; } + String originalPrometheusName = expositionBaseName; return MetricMetadata.builder() .name(stripReservedMetricSuffixes(name)) - .originalName(expositionBaseName) - .help(metricData.getDescription()) + .originalName(originalPrometheusName) + .help(help) .unit(unit) .counterSuffix(isCounter) .build(); } private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) { - String rawName = metricData.getName(); - String name = stripReservedMetricSuffixes(rawName); + String originalName = metricData.getName(); + String expositionBaseName = originalName; + String name = stripReservedMetricSuffixes(expositionBaseName); + String originalPrometheusName = originalName; + String help = metricData.getDescription(); + Unit unit = null; return MetricMetadata.builder() .name(name) - .expositionBaseName(rawName) - .originalName(rawName) - .help(metricData.getDescription()) + .expositionBaseName(expositionBaseName) + .originalName(originalPrometheusName) + .help(help) + .unit(unit) .build(); } 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 c1e8cd0f4f0..eb95afbf63b 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 @@ -64,6 +64,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; +import java.util.stream.Stream; import java.util.zip.GZIPInputStream; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.AfterAll; @@ -71,6 +72,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class PrometheusHttpServerTest { private static final AtomicReference> metricData = new AtomicReference<>(); @@ -283,6 +287,68 @@ public Collection collectAllMetrics() { } } + @ParameterizedTest + @MethodSource("translationStrategyContentNegotiationArgs") + void fetchOpenMetrics_translationStrategyRespectsAcceptEscaping( + TranslationStrategy translationStrategy, + String acceptHeader, + String expectedLineFragment, + String unexpectedLineFragment) { + metricData.set(generateContentNegotiationTestData()); + try (PrometheusHttpServer prometheusServer = + PrometheusHttpServer.builder() + .setHost("localhost") + .setPort(0) + .setTranslationStrategy(translationStrategy) + .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, acceptHeader)) + .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(expectedLineFragment) + .doesNotContain(unexpectedLineFragment); + } + } + + private static Stream translationStrategyContentNegotiationArgs() { + return Stream.of( + Arguments.of( + TranslationStrategy.NO_TRANSLATION, + "application/openmetrics-text; version=1.0.0; escaping=underscores", + "metric_name{otel_scope_name=\"scope\"} 1.0", + "{\"metric.name\",otel_scope_name=\"scope\"} 1.0"), + Arguments.of( + TranslationStrategy.NO_TRANSLATION, + "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", + "{\"metric.name\",otel_scope_name=\"scope\"} 1.0", + "metric_name{otel_scope_name=\"scope\"} 1.0"), + Arguments.of( + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", + "metric_name_total{otel_scope_name=\"scope\"} 1.0", + "{\"metric.name\",otel_scope_name=\"scope\"} 1.0")); + } + @Test void fetchProtobuf() { AggregatedHttpResponse response = @@ -581,10 +647,25 @@ private static List generateTestData() { /* isMonotonic= */ true, AggregationTemporality.CUMULATIVE, Collections.singletonList( - ImmutableDoublePointData.create( + ImmutableDoublePointData.create( 123, 456, Attributes.of(stringKey("kp"), "vp"), 3.5))))); } + private static List generateContentNegotiationTestData() { + return Collections.singletonList( + ImmutableMetricData.createLongSum( + Resource.empty(), + InstrumentationScopeInfo.create("scope"), + "metric.name", + "description", + "", + ImmutableSumData.create( + /* isMonotonic= */ true, + AggregationTemporality.CUMULATIVE, + Collections.singletonList( + ImmutableLongPointData.create(123, 456, Attributes.empty(), 1))))); + } + @Test void toBuilder() { PrometheusHttpServerBuilder builder = PrometheusHttpServer.builder(); From 9afaa37ab305a4e043c5a4733495d5f0c5f5baa9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 11 Jun 2026 12:12:19 +0000 Subject: [PATCH 14/19] Use Prometheus client 1.8.0 Signed-off-by: Gregor Zeitlinger --- dependencyManagement/build.gradle.kts | 2 +- .../prometheus/Otel2PrometheusConverter.java | 49 +++------------- .../Otel2PrometheusConverterTest.java | 2 +- .../prometheus/PrometheusHttpServerTest.java | 56 +++++++++++++++++++ 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 1ea7a01599d..99e3044977f 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.7.1-SNAPSHOT" +val prometheusServerVersion = "1.8.0" 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 1c295d2123c..a331510ec22 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 @@ -700,53 +700,28 @@ private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metr if (unit != null && !name.endsWith(unit.toString())) { name = name + "_" + unit; } - String expositionBaseName = name; - String originalPrometheusName = name; validateNormalizedMetricName(originalName, name); - return MetricMetadata.builder() - .name(name) - .expositionBaseName(expositionBaseName) - .originalName(originalPrometheusName) - .help(help) - .unit(unit) - .build(); + return MetricMetadata.builder().name(name).help(help).unit(unit).build(); } private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { String originalName = metricData.getName(); - String expositionBaseName = convertLegacyMetricName(originalName); - String name = stripReservedMetricSuffixes(expositionBaseName); - String originalPrometheusName = expositionBaseName; + String name = stripReservedMetricSuffixes(convertLegacyMetricName(originalName)); String help = metricData.getDescription(); - Unit unit = null; - validateNormalizedMetricName(originalName, expositionBaseName); - validateNormalizedMetricName(metricData.getName(), name); - return MetricMetadata.builder() - .name(name) - .expositionBaseName(expositionBaseName) - .originalName(originalPrometheusName) - .help(help) - .unit(unit) - .build(); + validateNormalizedMetricName(originalName, name); + return MetricMetadata.builder().name(name).help(help).build(); } private static MetricMetadata convertMetadataUtf8WithSuffixes( MetricData metricData, boolean isCounter) { - String originalName = metricData.getName(); - String name = originalName; + String name = metricData.getName(); String help = metricData.getDescription(); Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit()); if (unit != null && !name.endsWith(unit.toString())) { name = name + "_" + unit; } - String expositionBaseName = name; - if (isCounter && !expositionBaseName.endsWith("_total")) { - expositionBaseName = expositionBaseName + "_total"; - } - String originalPrometheusName = expositionBaseName; return MetricMetadata.builder() .name(stripReservedMetricSuffixes(name)) - .originalName(originalPrometheusName) .help(help) .unit(unit) .counterSuffix(isCounter) @@ -754,19 +729,9 @@ private static MetricMetadata convertMetadataUtf8WithSuffixes( } private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) { - String originalName = metricData.getName(); - String expositionBaseName = originalName; - String name = stripReservedMetricSuffixes(expositionBaseName); - String originalPrometheusName = originalName; + String name = stripReservedMetricSuffixes(metricData.getName()); String help = metricData.getDescription(); - Unit unit = null; - return MetricMetadata.builder() - .name(name) - .expositionBaseName(expositionBaseName) - .originalName(originalPrometheusName) - .help(help) - .unit(unit) - .build(); + return MetricMetadata.builder().name(name).help(help).build(); } private static String stripReservedMetricSuffixes(String name) { 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 1cfab0aafce..2e646817092 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 @@ -236,7 +236,7 @@ private static Stream translationStrategyArgs() { TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES, "sample.name_bytes", "sample.name_bytes_total", - "sample.name_bytes_total"), + "sample.name_bytes"), Arguments.of( TranslationStrategy.NO_TRANSLATION, "sample.name", "sample.name", "sample.name")); } 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 eb95afbf63b..27d4854c03f 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 @@ -330,6 +330,47 @@ public Collection collectAllMetrics() { } } + @Test + void fetchOpenMetrics_noTranslationPreservesRawInputNameWithUnit() { + metricData.set(generateContentNegotiationTestDataWithUnit()); + try (PrometheusHttpServer prometheusServer = + PrometheusHttpServer.builder() + .setHost("localhost") + .setPort(0) + .setTranslationStrategy(TranslationStrategy.NO_TRANSLATION) + .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; version=1.0.0; escaping=allow-utf-8")) + .aggregate() + .join(); + + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()) + .contains("{\"metric.name\",otel_scope_name=\"scope\"} 1.0") + .doesNotContain("metric.name_bytes") + .doesNotContain("metric.name_total") + .doesNotContain("metric_name"); + } + } + private static Stream translationStrategyContentNegotiationArgs() { return Stream.of( Arguments.of( @@ -666,6 +707,21 @@ private static List generateContentNegotiationTestData() { ImmutableLongPointData.create(123, 456, Attributes.empty(), 1))))); } + private static List generateContentNegotiationTestDataWithUnit() { + return Collections.singletonList( + ImmutableMetricData.createLongSum( + Resource.empty(), + InstrumentationScopeInfo.create("scope"), + "metric.name", + "description", + "By", + ImmutableSumData.create( + /* isMonotonic= */ true, + AggregationTemporality.CUMULATIVE, + Collections.singletonList( + ImmutableLongPointData.create(123, 456, Attributes.empty(), 1))))); + } + @Test void toBuilder() { PrometheusHttpServerBuilder builder = PrometheusHttpServer.builder(); From 561208a17d3c7b01146c26fcecc10384d46f07e7 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 11 Jun 2026 12:35:23 +0000 Subject: [PATCH 15/19] Apply Spotless formatting Signed-off-by: Gregor Zeitlinger --- .../exporter/prometheus/PrometheusHttpServerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 27d4854c03f..6fff928f5ec 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 @@ -688,7 +688,7 @@ private static List generateTestData() { /* isMonotonic= */ true, AggregationTemporality.CUMULATIVE, Collections.singletonList( - ImmutableDoublePointData.create( + ImmutableDoublePointData.create( 123, 456, Attributes.of(stringKey("kp"), "vp"), 3.5))))); } From 03ec22e1c5a621d38d0238665d576c9da77b1f8e Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 12 Jun 2026 09:44:09 +0000 Subject: [PATCH 16/19] Address psx95 review: simplify metric name loop and use argumentSet in tests Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverter.java | 13 +--- .../Otel2PrometheusConverterTest.java | 63 +++++++++++++------ .../prometheus/PrometheusHttpServerTest.java | 9 ++- 3 files changed, 53 insertions(+), 32 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 3cd671b6ad0..0dfd970d09d 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 @@ -688,16 +688,9 @@ private static String convertLegacyMetricName(String name) { boolean previousWasUnderscore = false; for (int i = 0; i < name.length(); ) { int codePoint = name.codePointAt(i); - if (isValidLegacyMetricChar(codePoint, i)) { - if (codePoint == '_') { - if (!previousWasUnderscore) { - result.append('_'); - previousWasUnderscore = true; - } - } else { - result.appendCodePoint(codePoint); - previousWasUnderscore = false; - } + if (isValidLegacyMetricChar(codePoint, i) && codePoint != '_') { + result.appendCodePoint(codePoint); + previousWasUnderscore = false; } else if (!previousWasUnderscore) { result.append('_'); previousWasUnderscore = true; 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 4f042d5f197..576f7ee9230 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 @@ -234,23 +234,30 @@ void metricMetadata_translationStrategy( private static Stream translationStrategyArgs() { return Stream.of( - Arguments.of( + Arguments.argumentSet( + "underscore escaping with suffixes", TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, "sample_name_bytes", "sample_name_bytes", "sample_name_bytes"), - Arguments.of( + Arguments.argumentSet( + "underscore escaping without suffixes", TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, "sample_name", "sample_name", "sample_name"), - Arguments.of( + Arguments.argumentSet( + "no utf8 escaping with suffixes", TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES, "sample.name_bytes", "sample.name_bytes_total", "sample.name_bytes"), - Arguments.of( - TranslationStrategy.NO_TRANSLATION, "sample.name", "sample.name", "sample.name")); + Arguments.argumentSet( + "no translation", + TranslationStrategy.NO_TRANSLATION, + "sample.name", + "sample.name", + "sample.name")); } @Test @@ -284,19 +291,36 @@ void labelNameTranslation_underscoreEscaping(String labelName, String expectedLa 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_字符", "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"), + Arguments.argumentSet("colons", "label:with:colons", "label_with_colons"), + Arguments.argumentSet( + "capital letters", "LabelWithCapitalLetters", "LabelWithCapitalLetters"), + Arguments.argumentSet( + "special chars", "label!with&special$chars)", "label_with_special_chars_"), + Arguments.argumentSet( + "foreign characters", + "label_with_foreign_characters_字符", + "label_with_foreign_characters_"), + Arguments.argumentSet("dots", "label.with.dots", "label_with_dots"), + Arguments.argumentSet("leading digit", "123label", "key_123label"), + Arguments.argumentSet( + "leading underscore", + "_label_starting_with_underscore", + "_label_starting_with_underscore"), + Arguments.argumentSet( + "leading double underscore", + "__label_starting_with_2underscores", + "_label_starting_with_2underscores"), + Arguments.argumentSet( + "double underscores", + "label__with__double__underscores", + "label_with_double_underscores"), + Arguments.argumentSet( + "mixed special", "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_")); + Arguments.argumentSet( + "reserved name", "__reserved__label__name__", "_reserved_label_name_"), + Arguments.argumentSet( + "trailing underscores", "trailing_underscores___", "trailing_underscores_")); } @Test @@ -351,8 +375,9 @@ void labelNameTranslation_nonEscapingStrategiesPreserveLabels( private static Stream nonEscapingTranslationStrategyArgs() { return Stream.of( - Arguments.of(TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES), - Arguments.of(TranslationStrategy.NO_TRANSLATION)); + Arguments.argumentSet( + "no utf8 escaping with suffixes", TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES), + Arguments.argumentSet("no translation", TranslationStrategy.NO_TRANSLATION)); } private static Labels convertAttributeLabels( 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 6fff928f5ec..92e644173ca 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 @@ -373,17 +373,20 @@ public Collection collectAllMetrics() { private static Stream translationStrategyContentNegotiationArgs() { return Stream.of( - Arguments.of( + Arguments.argumentSet( + "no translation + underscores escaping", TranslationStrategy.NO_TRANSLATION, "application/openmetrics-text; version=1.0.0; escaping=underscores", "metric_name{otel_scope_name=\"scope\"} 1.0", "{\"metric.name\",otel_scope_name=\"scope\"} 1.0"), - Arguments.of( + Arguments.argumentSet( + "no translation + allow-utf-8 escaping", TranslationStrategy.NO_TRANSLATION, "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", "{\"metric.name\",otel_scope_name=\"scope\"} 1.0", "metric_name{otel_scope_name=\"scope\"} 1.0"), - Arguments.of( + Arguments.argumentSet( + "underscore escaping + allow-utf-8 accept", TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", "metric_name_total{otel_scope_name=\"scope\"} 1.0", From bdf86f78e6f1de867da285b0faac2de6aba05518 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 17 Jun 2026 05:39:20 +0000 Subject: [PATCH 17/19] Address Prometheus PR review nits Signed-off-by: Gregor Zeitlinger --- .../prometheus/Otel2PrometheusConverter.java | 2 +- .../prometheus/Otel2PrometheusConverterTest.java | 16 ++++++++++++++++ .../prometheus/PrometheusHttpServerTest.java | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) 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 0dfd970d09d..b7e1957a356 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 @@ -681,7 +681,7 @@ private static boolean containsOnlyUnderscores(String value) { private static String convertLegacyMetricName(String name) { if (name.isEmpty()) { - return name; + throw new IllegalArgumentException("metric name is empty"); } StringBuilder result = new StringBuilder(name.length()); 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 576f7ee9230..7fde231d204 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 @@ -362,6 +362,22 @@ void metricNameTranslation_legacyDropsMetricWithInvalidNormalizedName() { assertThat(snapshots).isEmpty(); } + @Test + void metricNameTranslation_legacyDropsMetricWithEmptyName() { + 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 @MethodSource("nonEscapingTranslationStrategyArgs") void labelNameTranslation_nonEscapingStrategiesPreserveLabels( 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 92e644173ca..97a58b7c202 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 @@ -390,6 +390,12 @@ private static Stream translationStrategyContentNegotiationArgs() { TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", "metric_name_total{otel_scope_name=\"scope\"} 1.0", + "{\"metric.name\",otel_scope_name=\"scope\"} 1.0"), + Arguments.argumentSet( + "underscore escaping without suffixes + allow-utf-8 accept", + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", + "metric_name{otel_scope_name=\"scope\"} 1.0", "{\"metric.name\",otel_scope_name=\"scope\"} 1.0")); } From d9c634f1004cdaf1098322a64cddecc12c15c437 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 17 Jun 2026 10:34:37 +0000 Subject: [PATCH 18/19] test: cover Prometheus translation edge cases Signed-off-by: Gregor Zeitlinger --- .../prometheus/PrometheusUnitsHelperTest.java | 21 ++++++++ .../MetricReaderFactoryTest.java | 51 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java index e6ffa0fb760..5aa4cbe5ca3 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java @@ -27,6 +27,27 @@ public void testPrometheusUnitEquivalency(String otlpUnit, String expectedPromet } } + @ParameterizedTest + @MethodSource("reservedSuffixUnitArgs") + void convertUnit_reservedSuffixHandling(String otlpUnit, String expectedPrometheusUnit) { + Unit actualPrometheusUnit = PrometheusUnitsHelper.convertUnit(otlpUnit); + if (expectedPrometheusUnit == null) { + assertNull(actualPrometheusUnit); + } else { + assertEquals(expectedPrometheusUnit, actualPrometheusUnit.toString()); + } + } + + private static Stream reservedSuffixUnitArgs() { + return Stream.of( + Arguments.argumentSet("reserved suffix only", "total", null), + Arguments.argumentSet("reserved suffix stripped", "widgets_total", "widgets"), + Arguments.argumentSet( + "repeated reserved suffixes stripped", "widgets_total_info", "widgets"), + Arguments.argumentSet( + "leading and trailing punctuation trimmed", "._widgets_.", "widgets")); + } + private static Stream providePrometheusOTelUnitEquivalentPairs() { return Stream.of( Arguments.argumentSet("bytes", "By", "bytes"), 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 d4585b4ba49..7f43057a139 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 @@ -19,6 +19,7 @@ import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; import io.opentelemetry.exporter.prometheus.PrometheusHttpServer; import io.opentelemetry.exporter.prometheus.TranslationStrategy; +import io.opentelemetry.exporter.prometheus.internal.PrometheusComponentProvider; import io.opentelemetry.internal.testing.CleanupExtension; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.CardinalityLimitsModel; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.ExperimentalPrometheusMetricExporterModel; @@ -38,7 +39,9 @@ import java.net.ServerSocket; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -219,6 +222,29 @@ void create_PullPrometheusConfigured() throws IOException { verify(context).loadComponent(eq(MetricReader.class), any(ConfigKeyValue.class)); } + @Test + void create_PullPrometheusProviderSupportsAdditionalTranslationStrategies() throws IOException { + assertPrometheusComponentProviderTranslationStrategy( + "no_utf8_escaping_with_suffixes/development", + TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES); + assertPrometheusComponentProviderTranslationStrategy( + "no_translation/development", TranslationStrategy.NO_TRANSLATION); + } + + @Test + void create_PullPrometheusProviderRejectsInvalidTranslationStrategy() { + Map config = new LinkedHashMap<>(); + config.put("host", "localhost"); + config.put("translation_strategy", "not-a-strategy"); + + assertThatThrownBy( + () -> + new PrometheusComponentProvider() + .create(DeclarativeConfiguration.toConfigProperties(config))) + .isInstanceOf(DeclarativeConfigException.class) + .hasMessage("Unsupported translation_strategy: not-a-strategy"); + } + @Test void create_InvalidPullReader() { assertThatThrownBy( @@ -241,6 +267,31 @@ void create_InvalidPullReader() { .hasMessage("metric reader must have exactly one entry but has 0"); } + private void assertPrometheusComponentProviderTranslationStrategy( + String configValue, TranslationStrategy expectedStrategy) throws IOException { + int port = randomAvailablePort(); + + PrometheusHttpServer expectedReader = + PrometheusHttpServer.builder() + .setHost("localhost") + .setPort(port) + .setTranslationStrategy(expectedStrategy) + .build(); + expectedReader.close(); + + Map config = new LinkedHashMap<>(); + config.put("host", "localhost"); + config.put("port", port); + config.put("translation_strategy", configValue); + + MetricReader reader = + new PrometheusComponentProvider() + .create(DeclarativeConfiguration.toConfigProperties(config)); + cleanup.addCloseable(reader); + + assertThat(reader.toString()).isEqualTo(expectedReader.toString()); + } + /** * Find a random unused port. There's a small race if another process takes it before we * initialize. Consider adding retries to this test if it flakes, presumably it never will on CI From 9e612193ecafb729456c5138028c38359f4ab1d0 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 17 Jun 2026 11:08:23 +0000 Subject: [PATCH 19/19] test: cover remaining Prometheus conversion branches Signed-off-by: Gregor Zeitlinger --- .../Otel2PrometheusConverterTest.java | 104 +++++++++++++++++- .../prometheus/PrometheusUnitsHelperTest.java | 7 +- 2 files changed, 104 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 7fde231d204..1d3b8fb5254 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 @@ -396,6 +396,66 @@ private static Stream nonEscapingTranslationStrategyArgs() { Arguments.argumentSet("no translation", TranslationStrategy.NO_TRANSLATION)); } + @Test + void convertReturnsEmptySnapshotsForNullOrEmptyInput() { + assertThat(converter.convert(null)).isEmpty(); + assertThat(converter.convert(Collections.emptyList())).isEmpty(); + } + + @Test + void convertDoesNotAddTargetInfoWhenAllMetricsAreDropped() { + MetricSnapshots snapshots = + converter.convert( + Collections.singletonList( + createSampleMetricData( + "sample", + "1", + MetricDataType.LONG_SUM, + Attributes.of(stringKey("ようこそ"), "value"), + Resource.empty()))); + + assertThat(snapshots).isEmpty(); + } + + @ParameterizedTest + @MethodSource("deltaMetricDataArgs") + void convertDropsDeltaMetrics(MetricData metricData) { + assertThat(converter.convert(Collections.singletonList(metricData))).isEmpty(); + } + + private static Stream deltaMetricDataArgs() { + return Stream.of( + Arguments.argumentSet( + "delta long sum", createDeltaMetricData("sample", "1", MetricDataType.LONG_SUM)), + Arguments.argumentSet( + "delta double sum", createDeltaMetricData("sample", "1", MetricDataType.DOUBLE_SUM)), + Arguments.argumentSet( + "delta histogram", createDeltaMetricData("sample", "1", MetricDataType.HISTOGRAM)), + Arguments.argumentSet( + "delta exponential histogram", + createDeltaMetricData("sample", "1", MetricDataType.EXPONENTIAL_HISTOGRAM))); + } + + @Test + void nonMonotonicDoubleSumConvertsToGauge() { + MetricSnapshots snapshots = + converter.convert( + Collections.singletonList( + createMetricDataWithTemporality( + "sample", + "1", + MetricDataType.DOUBLE_SUM, + null, + null, + /* cumulative= */ true, + /* monotonic= */ false))); + + assertThat(snapshots).hasSize(2); + assertThat(snapshots.stream().map(snapshot -> snapshot.getMetadata().getName())) + .contains("sample", "target") + .doesNotContain("sample_total"); + } + private static Labels convertAttributeLabels( String labelName, TranslationStrategy translationStrategy) { Otel2PrometheusConverter converter = @@ -619,8 +679,40 @@ static MetricData createSampleMetricData( MetricDataType metricDataType, @Nullable Attributes attributes, @Nullable Resource resource) { + return createMetricDataWithTemporality( + metricName, + metricUnit, + metricDataType, + attributes, + resource, + /* cumulative= */ true, + /* monotonic= */ true); + } + + private static MetricData createDeltaMetricData( + String metricName, String metricUnit, MetricDataType metricDataType) { + return createMetricDataWithTemporality( + metricName, + metricUnit, + metricDataType, + null, + null, + /* cumulative= */ false, + /* monotonic= */ true); + } + + private static MetricData createMetricDataWithTemporality( + String metricName, + String metricUnit, + MetricDataType metricDataType, + @Nullable Attributes attributes, + @Nullable Resource resource, + boolean cumulative, + boolean monotonic) { Attributes attributesToUse = attributes == null ? Attributes.empty() : attributes; Resource resourceToUse = resource == null ? Resource.getDefault() : resource; + AggregationTemporality aggregationTemporality = + cumulative ? AggregationTemporality.CUMULATIVE : AggregationTemporality.DELTA; InstrumentationScopeInfo scope = InstrumentationScopeInfo.builder("scope") @@ -648,8 +740,8 @@ static MetricData createSampleMetricData( "description", metricUnit, ImmutableSumData.create( - true, - AggregationTemporality.CUMULATIVE, + monotonic, + aggregationTemporality, Collections.singletonList( ImmutableLongPointData.create(0, 1, attributesToUse, 1L)))); case DOUBLE_SUM: @@ -660,8 +752,8 @@ static MetricData createSampleMetricData( "description", metricUnit, ImmutableSumData.create( - true, - AggregationTemporality.CUMULATIVE, + monotonic, + aggregationTemporality, Collections.singletonList( ImmutableDoublePointData.create(0, 1, attributesToUse, 1.0)))); case LONG_GAUGE: @@ -692,7 +784,7 @@ static MetricData createSampleMetricData( "description", metricUnit, ImmutableHistogramData.create( - AggregationTemporality.CUMULATIVE, + aggregationTemporality, Collections.singletonList( ImmutableHistogramPointData.create( 0, @@ -713,7 +805,7 @@ static MetricData createSampleMetricData( "description", metricUnit, ImmutableExponentialHistogramData.create( - AggregationTemporality.CUMULATIVE, + aggregationTemporality, Collections.singletonList( ImmutableExponentialHistogramPointData.create( 0, diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java index 5aa4cbe5ca3..9d30c6fd35f 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/PrometheusUnitsHelperTest.java @@ -41,11 +41,16 @@ void convertUnit_reservedSuffixHandling(String otlpUnit, String expectedPromethe private static Stream reservedSuffixUnitArgs() { return Stream.of( Arguments.argumentSet("reserved suffix only", "total", null), + Arguments.argumentSet("reserved created suffix only", "created", null), + Arguments.argumentSet("reserved bucket suffix only", "bucket", null), + Arguments.argumentSet("reserved info suffix only", "info", null), Arguments.argumentSet("reserved suffix stripped", "widgets_total", "widgets"), Arguments.argumentSet( "repeated reserved suffixes stripped", "widgets_total_info", "widgets"), Arguments.argumentSet( - "leading and trailing punctuation trimmed", "._widgets_.", "widgets")); + "trailing punctuation removed after suffix stripping", "widgets_total_", "widgets"), + Arguments.argumentSet("leading and trailing punctuation trimmed", "._widgets_.", "widgets"), + Arguments.argumentSet("only punctuation becomes null", "._", null)); } private static Stream providePrometheusOTelUnitEquivalentPairs() {