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 @@ -155,7 +159,8 @@ private MetricSnapshot convert(MetricData metricData) {
// Note that AggregationTemporality.DELTA should never happen
// because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE.

MetricMetadata metadata = convertMetadata(metricData);
boolean isCounter = isMonotonicSum(metricData);
MetricMetadata metadata = convertMetadata(metricData, isCounter);
InstrumentationScopeInfo scope = metricData.getInstrumentationScopeInfo();
switch (metricData.getType()) {
case LONG_GAUGE:
Expand Down Expand Up @@ -210,6 +215,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 +561,172 @@ private List<AttributeKey<?>> filterAllowedResourceAttributeKeys(@Nullable Resou
return allowedAttributeKeys;
}

/**
* Convert an attribute key to a legacy Prometheus label name. {@code prometheusName} converts
* non-standard characters (dots, dashes, etc.) to underscores, and {@code sanitizeLabelName}
* strips invalid leading prefixes.
*/
private static String convertLabelName(String key) {
return sanitizeLabelName(prometheusName(key));
private String convertLabelName(String key) {
if (translationStrategy.shouldEscape()) {
return convertLegacyLabelName(key);
}
return 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 (!normalized.isEmpty() && Character.isDigit(normalized.charAt(0))) {
Comment thread
zeitlinger marked this conversation as resolved.
Outdated
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());
for (int i = 0; i < name.length(); ) {
int codePoint = name.codePointAt(i);
if (isValidLegacyMetricChar(codePoint, i)) {
result.appendCodePoint(codePoint);
Comment thread
zeitlinger marked this conversation as resolved.
Outdated
} else {
result.append('_');
}
i += Character.charCount(codePoint);
}
return result.toString();
}

private static MetricMetadata convertMetadata(MetricData metricData) {
String name = sanitizeMetricName(prometheusName(metricData.getName()));
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 name = convertLegacyMetricName(metricData.getName());
String help = metricData.getDescription();
Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit());
name = stripRepeatedUnderscores(stripReservedMetricSuffixes(name));
if (unit != null && !name.endsWith(unit.toString())) {
name = name + "_" + unit;
}
// 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
return new MetricMetadata(stripRepeatedUnderscores(name), help, unit);
}

private static MetricMetadata convertMetadataEscapedWithoutSuffixes(MetricData metricData) {
String rawName = stripRepeatedUnderscores(convertLegacyMetricName(metricData.getName()));
Comment thread
zeitlinger marked this conversation as resolved.
Outdated
String name = stripReservedMetricSuffixes(rawName);
return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null);
}

private static MetricMetadata convertMetadataUtf8WithSuffixes(
MetricData metricData, boolean isCounter) {
String name = metricData.getName();
String help = metricData.getDescription();
Unit unit = PrometheusUnitsHelper.convertUnit(metricData.getUnit());
if (unit != null && !name.endsWith(unit.toString())) {
name = name + "_" + unit;
}
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);
}

private static MetricMetadata convertMetadataNoTranslation(MetricData metricData) {
String rawName = metricData.getName();
String name = stripReservedMetricSuffixes(rawName);
return new MetricMetadata(name, rawName, rawName, metricData.getDescription(), null);
}

private static String stripReservedMetricSuffixes(String name) {
boolean modified = true;
while (modified) {
modified = false;
for (String suffix : PrometheusUnitsHelper.RESERVED_SUFFIXES) {
if (name.equals(suffix)) {
return name.substring(1);
}
if (name.endsWith(suffix)) {
name = name.substring(0, name.length() - suffix.length());
modified = true;
}
}
}
return name;
}

private static String stripRepeatedUnderscores(String name) {
while (name.contains("__")) {
name = name.replace("__", "_");
}

return new MetricMetadata(name, help, unit);
return name;
}

private static void putOrMerge(
Map<String, MetricSnapshot> snapshotsByName, MetricSnapshot snapshot) {
String name = snapshot.getMetadata().getPrometheusName();
private void putOrMerge(Map<String, MetricSnapshot> snapshotsByName, MetricSnapshot snapshot) {
String name = getMergeKey(snapshot.getMetadata());
Comment thread
zeitlinger marked this conversation as resolved.
Outdated
if (snapshotsByName.containsKey(name)) {
MetricSnapshot merged = merge(snapshotsByName.get(name), snapshot);
if (merged != null) {
Expand All @@ -583,6 +737,13 @@ private static void putOrMerge(
}
}

private String getMergeKey(MetricMetadata metadata) {
if (translationStrategy.shouldEscape()) {
return metadata.getPrometheusName();
}
return metadata.getName();
}

/**
* OpenTelemetry may use the same metric name multiple times but in different instrumentation
* scopes. In that case, we try to merge the metrics. They will have different {@code
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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ public PrometheusMetricReader(
this(
allowedResourceAttributesFilter,
/* otelScopeLabelsEnabled= */ true,
/* targetInfoMetricEnabled= */ true);
/* targetInfoMetricEnabled= */ true,
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES);
}

/**
Expand All @@ -65,18 +66,23 @@ public PrometheusMetricReader(@Nullable Predicate<String> allowedResourceAttribu
this(
allowedResourceAttributesFilter,
/* otelScopeLabelsEnabled= */ true,
/* targetInfoMetricEnabled= */ true);
/* targetInfoMetricEnabled= */ true,
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES);
}

// Package-private constructor used by builder
@SuppressWarnings("InconsistentOverloads")
PrometheusMetricReader(
@Nullable Predicate<String> allowedResourceAttributesFilter,
boolean otelScopeLabelsEnabled,
boolean targetInfoMetricEnabled) {
boolean targetInfoMetricEnabled,
TranslationStrategy translationStrategy) {
this.converter =
new Otel2PrometheusConverter(
otelScopeLabelsEnabled, targetInfoMetricEnabled, allowedResourceAttributesFilter);
otelScopeLabelsEnabled,
targetInfoMetricEnabled,
translationStrategy,
allowedResourceAttributesFilter);
}

@Override
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading