Skip to content

Commit 1428ef9

Browse files
committed
Add translation_strategy configuration for Prometheus exporter
Implements the translation_strategy option from the OpenTelemetry Prometheus exporter specification, supporting 4 strategies: UnderscoreEscapingWithSuffixes (default), UnderscoreEscapingWithoutSuffixes, NoUTF8EscapingWithSuffixes, and NoTranslation. Depends on prometheus client_java 1.6.0 (not yet released). Resolves #8195 Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent 2acd434 commit 1428ef9

13 files changed

Lines changed: 297 additions & 38 deletions

dependencyManagement/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ val jmhVersion = "1.37"
1515
val mockitoVersion = "4.11.0"
1616
val slf4jVersion = "2.0.17"
1717
val opencensusVersion = "0.31.1"
18-
val prometheusServerVersion = "1.5.0"
18+
val prometheusServerVersion = "1.6.0-SNAPSHOT"
1919
val armeriaVersion = "1.37.0"
2020
val junitVersion = "5.14.3"
2121
val okhttpVersion = "5.3.2"

exporters/prometheus/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies {
1616
exclude(group = "io.prometheus", module = "prometheus-metrics-exposition-formats")
1717
}
1818
implementation("io.prometheus:prometheus-metrics-exposition-formats-no-protobuf")
19+
implementation("io.prometheus:prometheus-metrics-config")
1920

2021
compileOnly("com.google.auto.value:auto-value-annotations")
2122
compileOnly("com.google.errorprone:error_prone_annotations")

exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java

Lines changed: 127 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName;
99
import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeLabelName;
10-
import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeMetricName;
1110
import static java.util.Objects.requireNonNull;
1211

1312
import io.opentelemetry.api.common.AttributeKey;
@@ -82,8 +81,13 @@ final class Otel2PrometheusConverter {
8281
private static final long NANOS_PER_MILLISECOND = TimeUnit.MILLISECONDS.toNanos(1);
8382
static final int MAX_CACHE_SIZE = 10;
8483

84+
private static final String[] RESERVED_METRIC_SUFFIXES = {
85+
"_total", "_created", "_bucket", "_info"
86+
};
87+
8588
private final boolean otelScopeLabelsEnabled;
8689
private final boolean targetInfoMetricEnabled;
90+
private final TranslationStrategy translationStrategy;
8791
@Nullable private final Predicate<String> allowedResourceAttributesFilter;
8892

8993
/**
@@ -92,21 +96,14 @@ final class Otel2PrometheusConverter {
9296
*/
9397
private final Map<Attributes, List<AttributeKey<?>>> resourceAttributesToAllowedKeysCache;
9498

95-
/**
96-
* Constructor with feature flag parameters.
97-
*
98-
* @param otelScopeLabelsEnabled whether to add OpenTelemetry scope labels to exported metrics
99-
* @param targetInfoMetricEnabled whether to export the target_info metric with resource
100-
* attributes
101-
* @param allowedResourceAttributesFilter if not {@code null}, resource attributes with keys
102-
* matching this predicate will be added as labels on each exported metric
103-
*/
10499
Otel2PrometheusConverter(
105100
boolean otelScopeLabelsEnabled,
106101
boolean targetInfoMetricEnabled,
102+
TranslationStrategy translationStrategy,
107103
@Nullable Predicate<String> allowedResourceAttributesFilter) {
108104
this.otelScopeLabelsEnabled = otelScopeLabelsEnabled;
109105
this.targetInfoMetricEnabled = targetInfoMetricEnabled;
106+
this.translationStrategy = translationStrategy;
110107
this.allowedResourceAttributesFilter = allowedResourceAttributesFilter;
111108
this.resourceAttributesToAllowedKeysCache =
112109
allowedResourceAttributesFilter != null
@@ -122,6 +119,10 @@ boolean isTargetInfoMetricEnabled() {
122119
return targetInfoMetricEnabled;
123120
}
124121

122+
TranslationStrategy getTranslationStrategy() {
123+
return translationStrategy;
124+
}
125+
125126
@Nullable
126127
Predicate<String> getAllowedResourceAttributesFilter() {
127128
return allowedResourceAttributesFilter;
@@ -155,7 +156,8 @@ private MetricSnapshot convert(MetricData metricData) {
155156
// Note that AggregationTemporality.DELTA should never happen
156157
// because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE.
157158

158-
MetricMetadata metadata = convertMetadata(metricData);
159+
boolean isCounter = isMonotonicSum(metricData);
160+
MetricMetadata metadata = convertMetadata(metricData, isCounter);
159161
InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo();
160162
switch (metricData.getType()) {
161163
case LONG_GAUGE:
@@ -210,6 +212,17 @@ private MetricSnapshot convert(MetricData metricData) {
210212
return null;
211213
}
212214

215+
private static boolean isMonotonicSum(MetricData metricData) {
216+
switch (metricData.getType()) {
217+
case LONG_SUM:
218+
return metricData.getLongSumData().isMonotonic();
219+
case DOUBLE_SUM:
220+
return metricData.getDoubleSumData().isMonotonic();
221+
default:
222+
return false;
223+
}
224+
}
225+
213226
private GaugeSnapshot convertLongGauge(
214227
MetricMetadata metadata,
215228
InstrumentationScopeInfo scope,
@@ -545,34 +558,116 @@ private List<AttributeKey<?>> filterAllowedResourceAttributeKeys(@Nullable Resou
545558
return allowedAttributeKeys;
546559
}
547560

548-
/**
549-
* Convert an attribute key to a legacy Prometheus label name. {@code prometheusName} converts
550-
* non-standard characters (dots, dashes, etc.) to underscores, and {@code sanitizeLabelName}
551-
* strips invalid leading prefixes.
552-
*/
553-
private static String convertLabelName(String key) {
554-
return sanitizeLabelName(prometheusName(key));
561+
private String convertLabelName(String key) {
562+
if (translationStrategy.shouldEscape()) {
563+
return sanitizeLabelName(prometheusName(key));
564+
}
565+
return key;
566+
}
567+
568+
private MetricMetadata convertMetadata(MetricData metricData, boolean isCounter) {
569+
switch (translationStrategy) {
570+
case UNDERSCORE_ESCAPING_WITH_SUFFIXES:
571+
return convertMetadataEscapedWithSuffixes(metricData);
572+
case UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES:
573+
return convertMetadataEscapedWithoutSuffixes(metricData);
574+
case NO_UTF8_ESCAPING_WITH_SUFFIXES:
575+
return convertMetadataUtf8WithSuffixes(metricData, isCounter);
576+
case NO_TRANSLATION:
577+
return convertMetadataNoTranslation(metricData);
578+
}
579+
throw new IllegalStateException("Unknown strategy: " + translationStrategy);
555580
}
556581

557-
private static MetricMetadata convertMetadata(MetricData metricData) {
558-
String name = sanitizeMetricName(prometheusName(metricData.getName()));
582+
/**
583+
* Default strategy: escape names, append unit and type suffixes, collapse repeated __. Uses 3-arg
584+
* MetricMetadata constructor so the format writer handles type suffixes (_total for counters)
585+
* automatically — preserving backward-compatible output format.
586+
*/
587+
private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metricData) {
588+
String name = prometheusName(metricData.getName());
559589
String help = metricData.getDescription();
560590
Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit());
591+
592+
// Strip reserved suffixes (_total, _info, etc.) BEFORE appending unit.
593+
// This replicates the old sanitizeMetricName behavior which ran before unit append.
594+
name = stripReservedMetricSuffixes(name);
595+
596+
// Append unit suffix
561597
if (unit != null && !name.endsWith(unit.toString())) {
562598
name = name + "_" + unit;
563599
}
564-
// Repeated __ are discouraged according to spec, although this is allowed in prometheus, see
565-
// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1
600+
601+
// Collapse repeated __ introduced by escaping
566602
while (name.contains("__")) {
567603
name = name.replace("__", "_");
568604
}
569605

570606
return new MetricMetadata(name, help, unit);
571607
}
572608

573-
private static void putOrMerge(
574-
Map<String, MetricSnapshot> snapshotsByName, MetricSnapshot snapshot) {
575-
String name = snapshot.getMetadata().getPrometheusName();
609+
/** Escape names but don't add any suffixes. */
610+
private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) {
611+
String name = prometheusName(metricData.getName());
612+
613+
// Collapse repeated __ introduced by escaping
614+
while (name.contains("__")) {
615+
name = name.replace("__", "_");
616+
}
617+
618+
return new MetricMetadata(name, name, metricData.getDescription(), null);
619+
}
620+
621+
/** Passthrough names (UTF-8 preserved), but add unit and type suffixes. */
622+
private static MetricMetadata convertMetadataUtf8WithSuffixes(
623+
MetricData metricData, boolean isCounter) {
624+
String name = metricData.getName();
625+
String help = metricData.getDescription();
626+
Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit());
627+
628+
if (unit != null && !name.endsWith(unit.toString())) {
629+
name = name + "_" + unit;
630+
}
631+
632+
String expositionBaseName = name;
633+
if (isCounter && !name.endsWith("_total")) {
634+
expositionBaseName = name + "_total";
635+
}
636+
637+
return new MetricMetadata(name, expositionBaseName, help, unit);
638+
}
639+
640+
/** Full passthrough: no escaping, no suffixes, no unit. */
641+
private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) {
642+
String name = metricData.getName();
643+
return new MetricMetadata(name, name, name, metricData.getDescription(), null);
644+
}
645+
646+
/**
647+
* Strip reserved Prometheus metric name suffixes (_total, _info, _created, _bucket). This
648+
* replicates the behavior of the old {@code PrometheusNaming.sanitizeMetricName} which was
649+
* changed to a no-op in prometheus client_java 1.6.0.
650+
*/
651+
private static String stripReservedMetricSuffixes(String name) {
652+
boolean modified = true;
653+
while (modified) {
654+
modified = false;
655+
for (String suffix : RESERVED_METRIC_SUFFIXES) {
656+
if (name.equals(suffix)) {
657+
// Corner case: name is exactly "_total" → return "total"
658+
return name.substring(1);
659+
}
660+
if (name.endsWith(suffix)) {
661+
name = name.substring(0, name.length() - suffix.length());
662+
modified = true;
663+
}
664+
}
665+
}
666+
return name;
667+
}
668+
669+
private void putOrMerge(Map<String, MetricSnapshot> snapshotsByName, MetricSnapshot snapshot) {
670+
String name = getMergeKey(snapshot.getMetadata());
576671
if (snapshotsByName.containsKey(name)) {
577672
MetricSnapshot merged = merge(snapshotsByName.get(name), snapshot);
578673
if (merged != null) {
@@ -583,6 +678,13 @@ private static void putOrMerge(
583678
}
584679
}
585680

681+
private String getMergeKey(MetricMetadata metadata) {
682+
if (translationStrategy.shouldEscape()) {
683+
return metadata.getPrometheusName();
684+
}
685+
return metadata.getName();
686+
}
687+
586688
/**
587689
* OpenTelemetry may use the same metric name multiple times but in different instrumentation
588690
* scopes. In that case, we try to merge the metrics. They will have different {@code

exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServer.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import io.opentelemetry.sdk.metrics.export.CollectionRegistration;
2222
import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector;
2323
import io.opentelemetry.sdk.metrics.export.MetricReader;
24+
import io.prometheus.metrics.config.OpenMetrics2Properties;
25+
import io.prometheus.metrics.config.PrometheusProperties;
2426
import io.prometheus.metrics.exporter.httpserver.HTTPServer;
2527
import io.prometheus.metrics.model.registry.PrometheusRegistry;
2628
import java.io.IOException;
@@ -73,6 +75,7 @@ public static PrometheusHttpServerBuilder builder() {
7375
@Nullable HttpHandler defaultHandler,
7476
DefaultAggregationSelector defaultAggregationSelector,
7577
@Nullable Authenticator authenticator,
78+
TranslationStrategy translationStrategy,
7679
PrometheusMetricReader prometheusMetricReader) {
7780
this.host = host;
7881
this.port = port;
@@ -95,9 +98,21 @@ public static PrometheusHttpServerBuilder builder() {
9598
new LinkedBlockingQueue<>(),
9699
new DaemonThreadFactory("prometheus-http-server"));
97100
}
101+
// Enable OM2 format writer for non-default strategies where the converter controls
102+
// expositionBaseName directly. For the default strategy, OM1 handles suffixes automatically.
103+
HTTPServer.Builder httpServerBuilder;
104+
if (translationStrategy != TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES) {
105+
PrometheusProperties prometheusProperties =
106+
PrometheusProperties.builder()
107+
.openMetrics2Properties(OpenMetrics2Properties.builder().enabled(true).build())
108+
.build();
109+
httpServerBuilder = HTTPServer.builder(prometheusProperties);
110+
} else {
111+
httpServerBuilder = HTTPServer.builder();
112+
}
98113
try {
99114
this.httpServer =
100-
HTTPServer.builder()
115+
httpServerBuilder
101116
.hostname(host)
102117
.port(port)
103118
.executorService(executor)

exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusHttpServerBuilder.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ public PrometheusHttpServerBuilder setTargetInfoMetricEnabled(boolean targetInfo
9292
return this;
9393
}
9494

95+
/**
96+
* Sets the translation strategy for metric and label name conversion.
97+
*
98+
* @param translationStrategy the strategy to use
99+
* @return this builder
100+
* @see TranslationStrategy
101+
*/
102+
public PrometheusHttpServerBuilder setTranslationStrategy(
103+
TranslationStrategy translationStrategy) {
104+
requireNonNull(translationStrategy, "translationStrategy");
105+
metricReaderBuilder.setTranslationStrategy(translationStrategy);
106+
return this;
107+
}
108+
95109
/**
96110
* Set if the resource attributes should be added as labels on each exported metric.
97111
*
@@ -183,6 +197,7 @@ public PrometheusHttpServer build() {
183197
defaultHandler,
184198
defaultAggregationSelector,
185199
authenticator,
200+
metricReaderBuilder.getTranslationStrategy(),
186201
metricReaderBuilder.build());
187202
}
188203
}

exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/PrometheusMetricReader.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ public PrometheusMetricReader(
5252
this(
5353
allowedResourceAttributesFilter,
5454
/* otelScopeLabelsEnabled= */ true,
55-
/* targetInfoMetricEnabled= */ true);
55+
/* targetInfoMetricEnabled= */ true,
56+
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES);
5657
}
5758

5859
/**
@@ -65,18 +66,23 @@ public PrometheusMetricReader(@Nullable Predicate<String> allowedResourceAttribu
6566
this(
6667
allowedResourceAttributesFilter,
6768
/* otelScopeLabelsEnabled= */ true,
68-
/* targetInfoMetricEnabled= */ true);
69+
/* targetInfoMetricEnabled= */ true,
70+
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES);
6971
}
7072

7173
// Package-private constructor used by builder
7274
@SuppressWarnings("InconsistentOverloads")
7375
PrometheusMetricReader(
7476
@Nullable Predicate<String> allowedResourceAttributesFilter,
7577
boolean otelScopeLabelsEnabled,
76-
boolean targetInfoMetricEnabled) {
78+
boolean targetInfoMetricEnabled,
79+
TranslationStrategy translationStrategy) {
7780
this.converter =
7881
new Otel2PrometheusConverter(
79-
otelScopeLabelsEnabled, targetInfoMetricEnabled, allowedResourceAttributesFilter);
82+
otelScopeLabelsEnabled,
83+
targetInfoMetricEnabled,
84+
translationStrategy,
85+
allowedResourceAttributesFilter);
8086
}
8187

8288
@Override
@@ -109,6 +115,7 @@ public String toString() {
109115
StringJoiner joiner = new StringJoiner(",", "PrometheusMetricReader{", "}");
110116
joiner.add("otelScopeLabelsEnabled=" + converter.isOtelScopeLabelsEnabled());
111117
joiner.add("targetInfoMetricEnabled=" + converter.isTargetInfoMetricEnabled());
118+
joiner.add("translationStrategy=" + converter.getTranslationStrategy());
112119
joiner.add("allowedResourceAttributesFilter=" + converter.getAllowedResourceAttributesFilter());
113120
return joiner.toString();
114121
}

0 commit comments

Comments
 (0)