-
Notifications
You must be signed in to change notification settings - Fork 971
Add Prometheus translation strategy support #8346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1ab1fc2
2cc649c
6986009
ae83c7e
6f1f18f
7dfbab2
1ad3600
c59b24d
671e437
957e2e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
||
| /** | ||
|
|
@@ -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 | ||
|
|
@@ -122,6 +122,10 @@ boolean isTargetInfoMetricEnabled() { | |
| return targetInfoMetricEnabled; | ||
| } | ||
|
|
||
| TranslationStrategy getTranslationStrategy() { | ||
| return translationStrategy; | ||
| } | ||
|
|
||
| @Nullable | ||
| Predicate<String> getAllowedResourceAttributesFilter() { | ||
| return allowedResourceAttributesFilter; | ||
|
|
@@ -151,11 +155,24 @@ MetricSnapshots convert(@Nullable Collection<MetricData> 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: | ||
|
|
@@ -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, | ||
|
|
@@ -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"); | ||
| } | ||
|
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()); | ||
|
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; | ||
|
zeitlinger marked this conversation as resolved.
|
||
| } | ||
| if (containsOnlyUnderscores(normalized)) { | ||
| throw new IllegalArgumentException( | ||
|
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()) { | ||
|
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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
For example, applied to this method it might look like: Something to think about to make it easier for humans to reason about / maintain.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Applied to
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"; | ||
| } | ||
|
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) { | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.