Skip to content

Commit 824334c

Browse files
authored
Add Prometheus translation strategy support (#8346)
Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent a00536a commit 824334c

14 files changed

Lines changed: 1270 additions & 44 deletions

File tree

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.18"
1717
val opencensusVersion = "0.31.1"
18-
val prometheusServerVersion = "1.5.1"
18+
val prometheusServerVersion = "1.8.0"
1919
val armeriaVersion = "1.39.1"
2020
val junitVersion = "5.14.4"
2121
val junitPlatformVersion = "1.14.4"

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

Lines changed: 216 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55

66
package io.opentelemetry.exporter.prometheus;
77

8-
import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName;
9-
import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeLabelName;
10-
import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeMetricName;
118
import static java.util.Objects.requireNonNull;
129

1310
import io.opentelemetry.api.common.AttributeKey;
@@ -87,6 +84,7 @@ final class Otel2PrometheusConverter {
8784

8885
private final boolean otelScopeLabelsEnabled;
8986
private final boolean targetInfoMetricEnabled;
87+
private final TranslationStrategy translationStrategy;
9088
@Nullable private final Predicate<String> allowedResourceAttributesFilter;
9189

9290
/**
@@ -107,9 +105,11 @@ final class Otel2PrometheusConverter {
107105
Otel2PrometheusConverter(
108106
boolean otelScopeLabelsEnabled,
109107
boolean targetInfoMetricEnabled,
108+
TranslationStrategy translationStrategy,
110109
@Nullable Predicate<String> allowedResourceAttributesFilter) {
111110
this.otelScopeLabelsEnabled = otelScopeLabelsEnabled;
112111
this.targetInfoMetricEnabled = targetInfoMetricEnabled;
112+
this.translationStrategy = translationStrategy;
113113
this.allowedResourceAttributesFilter = allowedResourceAttributesFilter;
114114
this.resourceAttributesToAllowedKeysCache =
115115
allowedResourceAttributesFilter != null
@@ -125,6 +125,10 @@ boolean isTargetInfoMetricEnabled() {
125125
return targetInfoMetricEnabled;
126126
}
127127

128+
TranslationStrategy getTranslationStrategy() {
129+
return translationStrategy;
130+
}
131+
128132
@Nullable
129133
Predicate<String> getAllowedResourceAttributesFilter() {
130134
return allowedResourceAttributesFilter;
@@ -154,11 +158,24 @@ MetricSnapshots convert(@Nullable Collection<MetricData> metricDataCollection) {
154158

155159
@Nullable
156160
private MetricSnapshot convert(MetricData metricData) {
161+
try {
162+
return doConvert(metricData);
163+
} catch (IllegalArgumentException e) {
164+
THROTTLING_LOGGER.log(
165+
Level.WARNING,
166+
"Failed to convert metric " + metricData.getName() + ". Dropping metric.",
167+
e);
168+
return null;
169+
}
170+
}
157171

172+
@Nullable
173+
private MetricSnapshot doConvert(MetricData metricData) {
158174
// Note that AggregationTemporality.DELTA should never happen
159175
// because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE.
160176

161-
MetricMetadata metadata = convertMetadata(metricData);
177+
boolean isCounter = isMonotonicSum(metricData);
178+
MetricMetadata metadata = convertMetadata(metricData, isCounter);
162179
InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo();
163180
switch (metricData.getType()) {
164181
case LONG_GAUGE:
@@ -213,6 +230,17 @@ private MetricSnapshot convert(MetricData metricData) {
213230
return null;
214231
}
215232

233+
private static boolean isMonotonicSum(MetricData metricData) {
234+
switch (metricData.getType()) {
235+
case LONG_SUM:
236+
return metricData.getLongSumData().isMonotonic();
237+
case DOUBLE_SUM:
238+
return metricData.getDoubleSumData().isMonotonic();
239+
default:
240+
return false;
241+
}
242+
}
243+
216244
private GaugeSnapshot convertLongGauge(
217245
MetricMetadata metadata,
218246
InstrumentationScopeInfo scope,
@@ -479,7 +507,7 @@ private static int labelSetLength(Labels labels) {
479507

480508
private InfoSnapshot makeTargetInfo(Resource resource) {
481509
return new InfoSnapshot(
482-
new MetricMetadata("target"),
510+
MetricMetadata.builder().name("target").build(),
483511
Collections.singletonList(
484512
new InfoDataPointSnapshot(
485513
convertAttributes(
@@ -582,34 +610,181 @@ private List<AttributeKey<?>> filterAllowedResourceAttributeKeys(@Nullable Resou
582610
return allowedAttributeKeys;
583611
}
584612

613+
private String convertLabelName(String key) {
614+
if (shouldEscape(translationStrategy)) {
615+
return convertLegacyLabelName(key);
616+
}
617+
return key;
618+
}
619+
585620
/**
586-
* Convert an attribute key to a legacy Prometheus label name. {@code prometheusName} converts
587-
* non-standard characters (dots, dashes, etc.) to underscores, and {@code sanitizeLabelName}
588-
* strips invalid leading prefixes.
621+
* Normalize an attribute name to the legacy Prometheus label scheme.
622+
*
623+
* <p>This mirrors {@code prometheus/otlptranslator}'s invalid-character collapsing rules, but we
624+
* intentionally do not preserve {@code __...__} names because Prometheus Java rejects user label
625+
* names that begin with {@code __}.
589626
*/
590-
private static String convertLabelName(String key) {
591-
return sanitizeLabelName(prometheusName(key));
627+
private static String convertLegacyLabelName(String key) {
628+
if (key.isEmpty()) {
629+
throw new IllegalArgumentException("label name is empty");
630+
}
631+
632+
// OTel owns OTLP-to-Prometheus translation. Prometheus Java validates and serializes names,
633+
// but no longer owns this naming mangling. The OTel compatibility spec requires invalid
634+
// attribute-name characters and repeated underscores to collapse to a single "_". Prometheus
635+
// label names beginning with "__" are reserved for internal labels like "__name__" and
636+
// scrape/relabel labels, and Prometheus Java rejects them as user labels, so do not preserve
637+
// "__...__" reserved-looking names here.
638+
StringBuilder result = new StringBuilder(key.length());
639+
boolean previousWasUnderscore = false;
640+
for (int i = 0; i < key.length(); ) {
641+
int codePoint = key.codePointAt(i);
642+
if (isValidLegacyLabelChar(codePoint)) {
643+
result.appendCodePoint(codePoint);
644+
previousWasUnderscore = false;
645+
} else if (!previousWasUnderscore) {
646+
result.append('_');
647+
previousWasUnderscore = true;
648+
}
649+
i += Character.charCount(codePoint);
650+
}
651+
652+
String normalized = result.toString();
653+
if (Character.isDigit(normalized.charAt(0))) {
654+
normalized = "key_" + normalized;
655+
}
656+
if (containsOnlyUnderscores(normalized)) {
657+
throw new IllegalArgumentException(
658+
"normalization for label name \""
659+
+ key
660+
+ "\" resulted in invalid name \""
661+
+ normalized
662+
+ "\"");
663+
}
664+
return normalized;
665+
}
666+
667+
private static boolean isValidLegacyLabelChar(int codePoint) {
668+
return (codePoint >= 'a' && codePoint <= 'z')
669+
|| (codePoint >= 'A' && codePoint <= 'Z')
670+
|| (codePoint >= '0' && codePoint <= '9');
671+
}
672+
673+
private static boolean containsOnlyUnderscores(String value) {
674+
for (int i = 0; i < value.length(); i++) {
675+
if (value.charAt(i) != '_') {
676+
return false;
677+
}
678+
}
679+
return true;
592680
}
593681

594-
private static MetricMetadata convertMetadata(MetricData metricData) {
595-
String name = sanitizeMetricName(prometheusName(metricData.getName()));
682+
private static String convertLegacyMetricName(String name) {
683+
if (name.isEmpty()) {
684+
throw new IllegalArgumentException("metric name is empty");
685+
}
686+
687+
StringBuilder result = new StringBuilder(name.length());
688+
boolean previousWasUnderscore = false;
689+
for (int i = 0; i < name.length(); ) {
690+
int codePoint = name.codePointAt(i);
691+
if (isValidLegacyMetricChar(codePoint, i) && codePoint != '_') {
692+
result.appendCodePoint(codePoint);
693+
previousWasUnderscore = false;
694+
} else if (!previousWasUnderscore) {
695+
result.append('_');
696+
previousWasUnderscore = true;
697+
}
698+
i += Character.charCount(codePoint);
699+
}
700+
return result.toString();
701+
}
702+
703+
private static boolean isValidLegacyMetricChar(int codePoint, int index) {
704+
return (codePoint >= 'a' && codePoint <= 'z')
705+
|| (codePoint >= 'A' && codePoint <= 'Z')
706+
|| codePoint == '_'
707+
|| codePoint == ':'
708+
|| (codePoint >= '0' && codePoint <= '9' && index > 0);
709+
}
710+
711+
private MetricMetadata convertMetadata(MetricData metricData, boolean isCounter) {
712+
switch (translationStrategy) {
713+
case UNDERSCORE_ESCAPING_WITH_SUFFIXES:
714+
return convertMetadataEscapedWithSuffixes(metricData);
715+
case UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES:
716+
return convertMetadataEscapedWithoutSuffixes(metricData);
717+
case NO_UTF8_ESCAPING_WITH_SUFFIXES:
718+
return convertMetadataUtf8WithSuffixes(metricData, isCounter);
719+
case NO_TRANSLATION:
720+
return convertMetadataNoTranslation(metricData);
721+
}
722+
throw new IllegalStateException("Unknown strategy: " + translationStrategy);
723+
}
724+
725+
private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metricData) {
726+
String originalName = metricData.getName();
727+
String name = stripReservedMetricSuffixes(convertLegacyMetricName(originalName));
596728
String help = metricData.getDescription();
597729
Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit());
598730
if (unit != null && !name.endsWith(unit.toString())) {
599731
name = name + "_" + unit;
600732
}
601-
// Repeated __ are discouraged according to spec, although this is allowed in prometheus, see
602-
// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1
603-
while (name.contains("__")) {
604-
name = name.replace("__", "_");
733+
validateNormalizedMetricName(originalName, name);
734+
return MetricMetadata.builder().name(name).help(help).unit(unit).build();
735+
}
736+
737+
private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) {
738+
String originalName = metricData.getName();
739+
String name = stripReservedMetricSuffixes(convertLegacyMetricName(originalName));
740+
String help = metricData.getDescription();
741+
validateNormalizedMetricName(originalName, name);
742+
return MetricMetadata.builder().name(name).help(help).build();
743+
}
744+
745+
private static MetricMetadata convertMetadataUtf8WithSuffixes(
746+
MetricData metricData, boolean isCounter) {
747+
String name = metricData.getName();
748+
String help = metricData.getDescription();
749+
Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit());
750+
if (unit != null && !name.endsWith(unit.toString())) {
751+
name = name + "_" + unit;
605752
}
753+
return MetricMetadata.builder()
754+
.name(stripReservedMetricSuffixes(name))
755+
.help(help)
756+
.unit(unit)
757+
.counterSuffix(isCounter)
758+
.build();
759+
}
606760

607-
return new MetricMetadata(name, help, unit);
761+
private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) {
762+
String name = stripReservedMetricSuffixes(metricData.getName());
763+
String help = metricData.getDescription();
764+
return MetricMetadata.builder().name(name).help(help).build();
608765
}
609766

610-
private static void putOrMerge(
611-
Map<String, MetricSnapshot> snapshotsByName, MetricSnapshot snapshot) {
612-
String name = snapshot.getMetadata().getPrometheusName();
767+
private static String stripReservedMetricSuffixes(String name) {
768+
boolean modified = true;
769+
while (modified) {
770+
modified = false;
771+
for (String suffix : PrometheusUnitsHelper.RESERVED_SUFFIXES) {
772+
if (name.equals(suffix)) {
773+
return name.substring(1);
774+
}
775+
if (name.endsWith(suffix)) {
776+
name = name.substring(0, name.length() - suffix.length());
777+
modified = true;
778+
}
779+
}
780+
}
781+
return name;
782+
}
783+
784+
private void putOrMerge(Map<String, MetricSnapshot> snapshotsByName, MetricSnapshot snapshot) {
785+
MetricMetadata metadata = snapshot.getMetadata();
786+
String name =
787+
shouldEscape(translationStrategy) ? metadata.getPrometheusName() : metadata.getName();
613788
if (snapshotsByName.containsKey(name)) {
614789
MetricSnapshot merged = merge(snapshotsByName.get(name), snapshot);
615790
if (merged != null) {
@@ -620,6 +795,26 @@ private static void putOrMerge(
620795
}
621796
}
622797

798+
private static boolean shouldEscape(TranslationStrategy translationStrategy) {
799+
return translationStrategy == TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES
800+
|| translationStrategy == TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES;
801+
}
802+
803+
private static void validateNormalizedMetricName(String originalName, String normalizedName) {
804+
if (normalizedName.isEmpty()) {
805+
throw new IllegalArgumentException(
806+
"normalization for metric \"" + originalName + "\" resulted in empty name");
807+
}
808+
if (containsOnlyUnderscores(normalizedName)) {
809+
throw new IllegalArgumentException(
810+
"normalization for metric \""
811+
+ originalName
812+
+ "\" resulted in invalid name \""
813+
+ normalizedName
814+
+ "\"");
815+
}
816+
}
817+
623818
/**
624819
* OpenTelemetry may use the same metric name multiple times but in different instrumentation
625820
* 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)
695890
+ ".");
696891
return null;
697892
}
698-
return new MetricMetadata(name, help, unit);
893+
return MetricMetadata.builder().name(name).help(help).unit(unit).build();
699894
}
700895

701896
private static String typeString(MetricSnapshot snapshot) {

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
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.PrometheusProperties;
2425
import io.prometheus.metrics.exporter.httpserver.HTTPServer;
2526
import io.prometheus.metrics.model.registry.PrometheusRegistry;
2627
import java.io.IOException;
@@ -73,6 +74,7 @@ public static PrometheusHttpServerBuilder builder() {
7374
@Nullable HttpHandler defaultHandler,
7475
DefaultAggregationSelector defaultAggregationSelector,
7576
@Nullable Authenticator authenticator,
77+
TranslationStrategy translationStrategy,
7678
PrometheusMetricReader prometheusMetricReader) {
7779
this.host = host;
7880
this.port = port;
@@ -95,9 +97,17 @@ public static PrometheusHttpServerBuilder builder() {
9597
new LinkedBlockingQueue<>(),
9698
new DaemonThreadFactory("prometheus-http-server"));
9799
}
100+
HTTPServer.Builder httpServerBuilder = HTTPServer.builder();
101+
if (translationStrategy != TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES) {
102+
// Intentionally enable OM2 without content negotiation so OpenMetrics responses keep the
103+
// legacy OM1 content type while using OM2 name-preservation semantics.
104+
PrometheusProperties prometheusProperties =
105+
PrometheusProperties.builder().enableOpenMetrics2(om2 -> {}).build();
106+
httpServerBuilder = HTTPServer.builder(prometheusProperties);
107+
}
98108
try {
99109
this.httpServer =
100-
HTTPServer.builder()
110+
httpServerBuilder
101111
.hostname(host)
102112
.port(port)
103113
.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
@@ -102,6 +102,20 @@ public PrometheusHttpServerBuilder setTargetInfoMetricEnabled(boolean targetInfo
102102
return this;
103103
}
104104

105+
/**
106+
* Sets the translation strategy for metric and label name conversion.
107+
*
108+
* @param translationStrategy the strategy to use
109+
* @return this builder
110+
* @see TranslationStrategy
111+
*/
112+
public PrometheusHttpServerBuilder setTranslationStrategy(
113+
TranslationStrategy translationStrategy) {
114+
requireNonNull(translationStrategy, "translationStrategy");
115+
metricReaderBuilder.setTranslationStrategy(translationStrategy);
116+
return this;
117+
}
118+
105119
/**
106120
* Set if the resource attributes should be added as labels on each exported metric.
107121
*
@@ -201,6 +215,7 @@ public PrometheusHttpServer build() {
201215
defaultHandler,
202216
defaultAggregationSelector,
203217
authenticator,
218+
metricReaderBuilder.getTranslationStrategy(),
204219
metricReaderBuilder.build());
205220
}
206221
}

0 commit comments

Comments
 (0)