Skip to content
2 changes: 1 addition & 1 deletion dependencyManagement/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -84,6 +81,7 @@ final class Otel2PrometheusConverter {

private final boolean otelScopeLabelsEnabled;
private final boolean targetInfoMetricEnabled;
private final TranslationStrategy translationStrategy;
@Nullable private final Predicate<String> allowedResourceAttributesFilter;

/**
Expand All @@ -104,9 +102,11 @@ final class Otel2PrometheusConverter {
Otel2PrometheusConverter(
boolean otelScopeLabelsEnabled,
boolean targetInfoMetricEnabled,
TranslationStrategy translationStrategy,
@Nullable Predicate<String> allowedResourceAttributesFilter) {
this.otelScopeLabelsEnabled = otelScopeLabelsEnabled;
this.targetInfoMetricEnabled = targetInfoMetricEnabled;
this.translationStrategy = translationStrategy;
this.allowedResourceAttributesFilter = allowedResourceAttributesFilter;
this.resourceAttributesToAllowedKeysCache =
allowedResourceAttributesFilter != null
Expand All @@ -122,6 +122,10 @@ boolean isTargetInfoMetricEnabled() {
return targetInfoMetricEnabled;
}

TranslationStrategy getTranslationStrategy() {
return translationStrategy;
}

@Nullable
Predicate<String> getAllowedResourceAttributesFilter() {
return allowedResourceAttributesFilter;
Expand Down Expand Up @@ -151,11 +155,24 @@ MetricSnapshots convert(@Nullable Collection<MetricData> metricDataCollection) {

@Nullable
private MetricSnapshot convert(MetricData metricData) {
Comment thread
zeitlinger marked this conversation as resolved.
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:
Expand Down Expand Up @@ -210,6 +227,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,
Expand Down Expand Up @@ -545,34 +573,187 @@ private List<AttributeKey<?>> 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.
*
* <p>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");
}
Comment thread
zeitlinger marked this conversation as resolved.

// 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());
Comment thread
zeitlinger marked this conversation as resolved.
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;
Comment thread
zeitlinger marked this conversation as resolved.
}
if (containsOnlyUnderscores(normalized)) {
throw new IllegalArgumentException(
Comment thread
zeitlinger marked this conversation as resolved.
"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 String convertLegacyMetricName(String name) {
if (name.isEmpty()) {
Comment thread
zeitlinger marked this conversation as resolved.
return 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)) {
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);
}
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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find these convert methods in the weeds and hard to follow / verify. Going to have to trust that you've done the research.

Could help improve the readability by:

  • Computing the MetricMetadata constructor params in a consistent order the same as MetricMetadata accepts (i.e. name, expositionName, originalName, help, unit)
  • Using variable names which are the same as the param names of MetricMetadata
  • Always using the same constructor overload so its explicit which things we're setting to null

For example, applied to this method it might look like:

  private static MetricMetadata convertMetadataEscapedWithSuffixes(MetricData metricData) {
    String name = convertLegacyMetricName(metricData.getName());
    name = stripReservedMetricSuffixes(name);
    String expositionBaseName = name;
    String originalName = name;
    String help = metricData.getDescription();
    Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit());
    
    if (unit != null && !name.endsWith(unit.toString())) {
      name = name + "_" + unit;
    }
    validateNormalizedMetricName(metricData.getName(), name);
    
    return new MetricMetadata(name, expositionBaseName, originalName, help, unit);
  }

Something to think about to make it easier for humans to reason about / maintain.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied to convertMetadataEscapedWithSuffixes (consistent ordering, single 5-arg MetricMetadata ctor) per your example. Left the other three strategy methods as-is for now since they read OK to me; happy to apply the same shape across all four if you'd prefer consistency. Commit 957e2e7.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... let me think about this more.

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;
}
validateNormalizedMetricName(originalName, name);
return new MetricMetadata(name, name, name, help, unit);
}

private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) {
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);
}

private static MetricMetadata convertMetadata(MetricData metricData) {
String name = sanitizeMetricName(prometheusName(metricData.getName()));
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
while (name.contains("__")) {
name = name.replace("__", "_");
String expositionBaseName = name;
if (isCounter && !expositionBaseName.endsWith("_total")) {
expositionBaseName = expositionBaseName + "_total";
}
Comment thread
zeitlinger marked this conversation as resolved.
return new MetricMetadata(stripReservedMetricSuffixes(name), expositionBaseName, help, unit);
}

return new MetricMetadata(name, 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 void putOrMerge(
Map<String, MetricSnapshot> snapshotsByName, MetricSnapshot snapshot) {
String name = snapshot.getMetadata().getPrometheusName();
private void putOrMerge(Map<String, MetricSnapshot> 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) {
Expand All @@ -583,6 +764,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -95,9 +97,17 @@ public static PrometheusHttpServerBuilder builder() {
new LinkedBlockingQueue<>(),
new DaemonThreadFactory("prometheus-http-server"));
}
HTTPServer.Builder httpServerBuilder = HTTPServer.builder();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Based on the "Interaction with Translation Strategy" spec, I was expecting to see some content negotiation, basically reading accept headers somewhere.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's park this until open-telemetry/opentelemetry-specification#5062 is resolved.

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -201,6 +215,7 @@ public PrometheusHttpServer build() {
defaultHandler,
defaultAggregationSelector,
authenticator,
metricReaderBuilder.getTranslationStrategy(),
metricReaderBuilder.build());
}
}
Loading
Loading