diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index e518dd7355e..9ba2b7e3f10 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.18" val opencensusVersion = "0.31.1" -val prometheusServerVersion = "1.5.1" +val prometheusServerVersion = "1.8.0" val armeriaVersion = "1.39.1" val junitVersion = "5.14.4" val junitPlatformVersion = "1.14.4" 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 cb67cac74d9..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 @@ -5,9 +5,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 io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeMetricName; import static java.util.Objects.requireNonNull; import io.opentelemetry.api.common.AttributeKey; @@ -87,6 +84,7 @@ final class Otel2PrometheusConverter { private final boolean otelScopeLabelsEnabled; private final boolean targetInfoMetricEnabled; + private final TranslationStrategy translationStrategy; @Nullable private final Predicate allowedResourceAttributesFilter; /** @@ -107,9 +105,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 @@ -125,6 +125,10 @@ boolean isTargetInfoMetricEnabled() { return targetInfoMetricEnabled; } + TranslationStrategy getTranslationStrategy() { + return translationStrategy; + } + @Nullable Predicate getAllowedResourceAttributesFilter() { return allowedResourceAttributesFilter; @@ -154,11 +158,24 @@ MetricSnapshots convert(@Nullable Collection metricDataCollection) { @Nullable private MetricSnapshot convert(MetricData metricData) { + try { + 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. - MetricMetadata metadata = convertMetadata(metricData); + boolean isCounter = isMonotonicSum(metricData); + MetricMetadata metadata = convertMetadata(metricData, isCounter); InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo(); switch (metricData.getType()) { case LONG_GAUGE: @@ -213,6 +230,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, @@ -479,7 +507,7 @@ private static int labelSetLength(Labels labels) { private InfoSnapshot makeTargetInfo(Resource resource) { return new InfoSnapshot( - new MetricMetadata("target"), + MetricMetadata.builder().name("target").build(), Collections.singletonList( new InfoDataPointSnapshot( convertAttributes( @@ -582,34 +610,181 @@ private List> filterAllowedResourceAttributeKeys(@Nullable Resou return allowedAttributeKeys; } + private String convertLabelName(String key) { + if (shouldEscape(translationStrategy)) { + return convertLegacyLabelName(key); + } + return key; + } + /** - * 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. + * 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 convertLabelName(String key) { - return sanitizeLabelName(prometheusName(key)); + private static String convertLegacyLabelName(String key) { + if (key.isEmpty()) { + throw new IllegalArgumentException("label name is empty"); + } + + // 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(); ) { + 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 (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 static MetricMetadata convertMetadata(MetricData metricData) { - String name = sanitizeMetricName(prometheusName(metricData.getName())); + private static String convertLegacyMetricName(String name) { + if (name.isEmpty()) { + throw new IllegalArgumentException("metric name is empty"); + } + + 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) && codePoint != '_') { + result.appendCodePoint(codePoint); + previousWasUnderscore = false; + } else if (!previousWasUnderscore) { + result.append('_'); + previousWasUnderscore = true; + } + 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: + 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 originalName = metricData.getName(); + String name = stripReservedMetricSuffixes(convertLegacyMetricName(originalName)); 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 - while (name.contains("__")) { - name = name.replace("__", "_"); + validateNormalizedMetricName(originalName, name); + return MetricMetadata.builder().name(name).help(help).unit(unit).build(); + } + + private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) { + String originalName = metricData.getName(); + String name = stripReservedMetricSuffixes(convertLegacyMetricName(originalName)); + String help = metricData.getDescription(); + validateNormalizedMetricName(originalName, name); + return MetricMetadata.builder().name(name).help(help).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; } + return MetricMetadata.builder() + .name(stripReservedMetricSuffixes(name)) + .help(help) + .unit(unit) + .counterSuffix(isCounter) + .build(); + } - return new MetricMetadata(name, help, unit); + private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) { + String name = stripReservedMetricSuffixes(metricData.getName()); + String help = metricData.getDescription(); + return MetricMetadata.builder().name(name).help(help).build(); } - private static void putOrMerge( - Map snapshotsByName, MetricSnapshot snapshot) { - String name = snapshot.getMetadata().getPrometheusName(); + 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 void putOrMerge(Map snapshotsByName, MetricSnapshot snapshot) { + 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) { @@ -620,6 +795,26 @@ private static void putOrMerge( } } + 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 @@ -695,7 +890,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) { 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..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 @@ -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; @@ -14,6 +13,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<>(); @@ -96,12 +97,74 @@ static Unit convertUnit(String otelUnit) { @Nullable private static Unit unitOrNull(String name) { - try { - return new Unit(PrometheusNaming.sanitizeUnitName(name)); - } catch (IllegalArgumentException e) { - // This happens if the name cannot be converted to a valid Prometheus unit name, - // for example if name is "total". + 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()) { + return null; + } + 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()) { return null; } + 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) { + 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..d96e9d483e7 --- /dev/null +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/TranslationStrategy.java @@ -0,0 +1,33 @@ +/* + * 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; +} 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..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 @@ -9,6 +9,7 @@ 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; @@ -54,6 +55,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 +77,19 @@ public MetricReader create(DeclarativeConfigProperties config) { return prometheusBuilder.build(); } + + private static TranslationStrategy parseTranslationStrategy(String value) { + switch (value) { + case "underscore_escaping_with_suffixes": + return TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES; + case "underscore_escaping_without_suffixes/development": + return TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES; + case "no_utf8_escaping_with_suffixes/development": + return TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES; + case "no_translation/development": + return TranslationStrategy.NO_TRANSLATION; + default: + 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 210b3f54eed..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 @@ -45,6 +45,7 @@ import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.GaugeSnapshot.GaugeDataPointSnapshot; 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; @@ -78,6 +79,7 @@ class Otel2PrometheusConverterTest { new Otel2PrometheusConverter( /* otelScopeLabelsEnabled= */ true, /* targetInfoMetricEnabled= */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, /* allowedResourceAttributesFilter= */ null); @ParameterizedTest @@ -205,6 +207,278 @@ 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.argumentSet( + "underscore escaping with suffixes", + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + "sample_name_bytes", + "sample_name_bytes", + "sample_name_bytes"), + Arguments.argumentSet( + "underscore escaping without suffixes", + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + "sample_name", + "sample_name", + "sample_name"), + Arguments.argumentSet( + "no utf8 escaping with suffixes", + TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES, + "sample.name_bytes", + "sample.name_bytes_total", + "sample.name_bytes"), + Arguments.argumentSet( + "no translation", + 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) { + 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.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.argumentSet( + "reserved name", "__reserved__label__name__", "_reserved_label_name_"), + Arguments.argumentSet( + "trailing underscores", "trailing_underscores___", "trailing_underscores_")); + } + + @Test + 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(); + } + + @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( + 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.argumentSet( + "no utf8 escaping with suffixes", TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES), + 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 = + 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( @@ -218,6 +492,7 @@ void resourceAttributesAddition( new Otel2PrometheusConverter( /* otelScopeLabelsEnabled= */ true, /* targetInfoMetricEnabled= */ true, + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, allowedResourceAttributesFilter); ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -304,7 +579,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 @@ -404,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") @@ -433,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: @@ -445,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: @@ -477,7 +784,7 @@ static MetricData createSampleMetricData( "description", metricUnit, ImmutableHistogramData.create( - AggregationTemporality.CUMULATIVE, + aggregationTemporality, Collections.singletonList( ImmutableHistogramPointData.create( 0, @@ -498,7 +805,7 @@ static MetricData createSampleMetricData( "description", metricUnit, ImmutableExponentialHistogramData.create( - AggregationTemporality.CUMULATIVE, + aggregationTemporality, Collections.singletonList( ImmutableExponentialHistogramPointData.create( 0, @@ -534,6 +841,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..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 @@ -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<>(); @@ -239,6 +243,162 @@ 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"); + } + } + + @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); + } + } + + @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.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.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.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", + "{\"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")); + } + @Test void fetchProtobuf() { AggregatedHttpResponse response = @@ -432,7 +592,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}" + "}"); @@ -541,6 +701,36 @@ private static List generateTestData() { 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))))); + } + + 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(); 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/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..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 @@ -27,6 +27,32 @@ 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 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( + "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() { 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 040f9a1e19c..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 @@ -18,6 +18,8 @@ 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.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; @@ -37,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; @@ -172,6 +176,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"))) @@ -217,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( @@ -239,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