diff --git a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java index 371af012726..d80abc69209 100644 --- a/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java +++ b/exporters/prometheus/src/main/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverter.java @@ -54,7 +54,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -536,11 +538,7 @@ private Labels convertAttributes( ? filterAllowedResourceAttributeKeys(resource) : Collections.emptyList(); - Map labelNameToValue = new HashMap<>(); - attributes.forEach( - (key, value) -> - labelNameToValue.put( - convertLabelName(key.getKey()), toLabelValue(key.getType(), value))); + Map labelNameToValue = buildAttributeLabels(attributes); for (int i = 0; i < additionalAttributes.length; i += 2) { labelNameToValue.putIfAbsent( @@ -566,15 +564,7 @@ private Labels convertAttributes( } if (resource != null) { - Attributes resourceAttributes = resource.getAttributes(); - for (AttributeKey attributeKey : allowedAttributeKeys) { - Object attributeValue = resourceAttributes.get(attributeKey); - if (attributeValue != null) { - labelNameToValue.putIfAbsent( - convertLabelName(attributeKey.getKey()), - toLabelValue(attributeKey.getType(), attributeValue)); - } - } + addResourceAttributeLabels(labelNameToValue, resource.getAttributes(), allowedAttributeKeys); } String[] names = new String[labelNameToValue.size()]; @@ -590,6 +580,121 @@ private Labels convertAttributes( return Labels.of(names, values); } + /** + * Builds a label map from metric attributes. + * + *

In the common (no-collision) case, inserts directly with no extra allocation. When + * normalization maps two attribute keys to the same label name, their values are joined with ";" + * in lexicographic order of the original key names. + */ + private Map buildAttributeLabels(Attributes attributes) { + Map labels = new LinkedHashMap<>(); + if (!shouldEscape(translationStrategy)) { + attributes.forEach( + (key, value) -> + labels.put(convertLabelName(key.getKey()), toLabelValue(key.getType(), value))); + return labels; + } + + // Fast path: insert directly. Stop inserting on first detected collision but finish + // iterating so forEach can complete. + boolean[] hasCollision = {false}; + attributes.forEach( + (key, value) -> { + if (hasCollision[0]) { + return; + } + String labelName = convertLabelName(key.getKey()); + if (labels.containsKey(labelName)) { + hasCollision[0] = true; + } else { + labels.put(labelName, toLabelValue(key.getType(), value)); + } + }); + + if (!hasCollision[0]) { + return labels; // common case: done, no extra allocation + } + + // Collision path: discard partial state and rebuild with merged values. + labels.clear(); + Map> groups = new LinkedHashMap<>(); + attributes.forEach( + (key, value) -> + groups + .computeIfAbsent(convertLabelName(key.getKey()), ignored -> new ArrayList<>()) + .add(new OriginalLabelKeyValue(key.getKey(), toLabelValue(key.getType(), value)))); + groups.forEach((labelName, entries) -> labels.put(labelName, mergeValues(entries))); + return labels; + } + + /** + * Adds resource attribute labels into dest using putIfAbsent, so metric attributes take + * precedence. When normalization maps two resource attribute keys to the same label name, their + * values are joined with ";" in lexicographic order of the original key names. + */ + private void addResourceAttributeLabels( + Map dest, + Attributes resourceAttributes, + List> allowedAttributeKeys) { + if (allowedAttributeKeys.isEmpty()) { + return; + } + + if (!shouldEscape(translationStrategy)) { + for (AttributeKey attributeKey : allowedAttributeKeys) { + Object attributeValue = resourceAttributes.get(attributeKey); + if (attributeValue != null) { + dest.putIfAbsent( + convertLabelName(attributeKey.getKey()), + toLabelValue(attributeKey.getType(), attributeValue)); + } + } + return; + } + + // Fast path: build resource labels into a temp map to detect within-source collisions + // before applying them to dest with putIfAbsent. + Map resourceLabels = new LinkedHashMap<>(); + boolean hasCollision = false; + for (AttributeKey attributeKey : allowedAttributeKeys) { + Object attributeValue = resourceAttributes.get(attributeKey); + if (attributeValue != null) { + String labelName = convertLabelName(attributeKey.getKey()); + if (resourceLabels.containsKey(labelName)) { + hasCollision = true; + break; + } + resourceLabels.put(labelName, toLabelValue(attributeKey.getType(), attributeValue)); + } + } + + if (!hasCollision) { + resourceLabels.forEach(dest::putIfAbsent); + return; + } + + // Collision path: rebuild with merged values. + Map> groups = new LinkedHashMap<>(); + for (AttributeKey attributeKey : allowedAttributeKeys) { + Object attributeValue = resourceAttributes.get(attributeKey); + if (attributeValue != null) { + groups + .computeIfAbsent(convertLabelName(attributeKey.getKey()), ignored -> new ArrayList<>()) + .add( + new OriginalLabelKeyValue( + attributeKey.getKey(), toLabelValue(attributeKey.getType(), attributeValue))); + } + } + groups.forEach((labelName, entries) -> dest.putIfAbsent(labelName, mergeValues(entries))); + } + + /** Sorts entries by original key name and joins their values with ";". */ + private static String mergeValues(List entries) { + entries.sort(Comparator.comparing(OriginalLabelKeyValue::originalKey)); + return entries.stream().map(OriginalLabelKeyValue::value).collect(Collectors.joining(";")); + } + private List> filterAllowedResourceAttributeKeys(@Nullable Resource resource) { requireNonNull( allowedResourceAttributesFilter, @@ -973,4 +1078,23 @@ public static String toJsonValidStr(String str) { sb.append('"'); return sb.toString(); } + + /** Stores the original attribute key and rendered value for one normalized label collision. */ + private static final class OriginalLabelKeyValue { + private final String originalKey; + private final String value; + + private OriginalLabelKeyValue(String originalKey, String value) { + this.originalKey = originalKey; + this.value = value; + } + + private String originalKey() { + return originalKey; + } + + private String value() { + return value; + } + } } diff --git a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java index 90a9555fe10..63971865ff4 100644 --- a/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java +++ b/exporters/prometheus/src/test/java/io/opentelemetry/exporter/prometheus/Otel2PrometheusConverterTest.java @@ -18,7 +18,9 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.common.KeyValue; import io.opentelemetry.api.common.Value; import io.opentelemetry.api.trace.SpanContext; @@ -67,9 +69,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -685,7 +690,7 @@ private static Stream resourceAttributesAdditionArgs() { "cluster=\"mycluster\",otel_scope_foo=\"bar\",otel_scope_name=\"scope\",otel_scope_schema_url=\"schemaUrl\",otel_scope_version=\"version\"")); // Array-valued resource attribute is serialized as a JSON string, matching the point attribute - // path + // path. arguments.add( Arguments.argumentSet( "array-valued resource attribute serialized as json", @@ -700,6 +705,50 @@ private static Stream resourceAttributesAdditionArgs() { "my_metric_units", "clusters=\"[\\\"a\\\",\\\"b\\\"]\",otel_scope_foo=\"bar\",otel_scope_name=\"scope\",otel_scope_schema_url=\"schemaUrl\",otel_scope_version=\"version\"")); + arguments.add( + Arguments.argumentSet( + "resource attribute collisions are merged", + createSampleMetricData( + "my.metric", + "units", + MetricDataType.LONG_SUM, + Attributes.empty(), + Resource.create( + createMapAttributes("foo_bar", "b", "foo-bar", "c", "foo.bar", "a"))), + /* allowedResourceAttributesFilter= */ Predicates.startsWith("foo"), + "my_metric_units", + "foo_bar=\"c;a;b\",otel_scope_foo=\"bar\",otel_scope_name=\"scope\",otel_scope_schema_url=\"schemaUrl\",otel_scope_version=\"version\"")); + + arguments.add( + Arguments.argumentSet( + "metric resource collisions preserve metric precedence", + createSampleMetricData( + "my.metric", + "units", + MetricDataType.LONG_SUM, + createMapAttributes("foo-bar", "metric"), + Resource.create(createMapAttributes("foo.bar", "resource"))), + /* allowedResourceAttributesFilter= */ Predicates.startsWith("foo"), + "my_metric_units", + "foo_bar=\"metric\",otel_scope_foo=\"bar\",otel_scope_name=\"scope\",otel_scope_schema_url=\"schemaUrl\",otel_scope_version=\"version\"")); + + arguments.add( + Arguments.argumentSet( + "array-valued resource collisions use label rendering", + createSampleMetricData( + "my.metric", + "units", + MetricDataType.LONG_SUM, + Attributes.empty(), + Resource.create( + Attributes.builder() + .put(stringArrayKey("foo.bar"), Arrays.asList("a", "b")) + .put("foo-bar", "c") + .build())), + /* allowedResourceAttributesFilter= */ Predicates.startsWith("foo"), + "my_metric_units", + "foo_bar=\"c;[\\\"a\\\",\\\"b\\\"]\",otel_scope_foo=\"bar\",otel_scope_name=\"scope\",otel_scope_schema_url=\"schemaUrl\",otel_scope_version=\"version\"")); + return arguments.stream(); } @@ -734,6 +783,91 @@ void arrayValuedScopeAttributeSerializedAsJson() { assertThat(labels.get("otel_scope_foo")).isEqualTo("[\"a\",\"b\"]"); } + @ParameterizedTest + @MethodSource("metricAttributeCollisionArgs") + void metricAttributeCollisionsAreMergedAndSorted( + TranslationStrategy translationStrategy, + Attributes attributes, + String expectedLabelName, + String expectedLabelValue) { + MetricData metricData = + createSampleMetricData("sample", "1", MetricDataType.LONG_SUM, attributes, null); + + Labels labels = extractMetricLabels(metricData, translationStrategy, null); + assertThat(extractLabelNames(labels)) + .containsExactly( + expectedLabelName, + "otel_scope_foo", + "otel_scope_name", + "otel_scope_schema_url", + "otel_scope_version"); + assertThat(labels.get(expectedLabelName)).isEqualTo(expectedLabelValue); + } + + private static Stream metricAttributeCollisionArgs() { + List arguments = new ArrayList<>(); + List escapingStrategies = + Arrays.asList( + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES); + for (TranslationStrategy translationStrategy : escapingStrategies) { + arguments.add( + Arguments.argumentSet( + translationStrategy + " basic collision", + translationStrategy, + createMapAttributes("foo_bar", "b", "foo-bar", "c", "foo.bar", "a"), + "foo_bar", + "c;a;b")); + arguments.add( + Arguments.argumentSet( + translationStrategy + " semicolon value", + translationStrategy, + createMapAttributes("foo.bar", "a;b", "foo-bar", "c"), + "foo_bar", + "c;a;b")); + arguments.add( + Arguments.argumentSet( + translationStrategy + " many symbols", + translationStrategy, + createMapAttributes("a.b", "1", "a-b", "2", "a_b", "3", "a/b", "4", "a@b", "5"), + "a_b", + "2;1;4;5;3")); + } + return arguments.stream(); + } + + @ParameterizedTest + @MethodSource("metricAttributeCollisionNonEscapingArgs") + void metricAttributeCollisionsRemainDistinctWhenEscapingDisabled( + TranslationStrategy translationStrategy) { + MetricData metricData = + createSampleMetricData( + "sample", + "1", + MetricDataType.LONG_SUM, + createMapAttributes("foo:bar", "a", "foo_bar", "c"), + null); + + Labels labels = extractMetricLabels(metricData, translationStrategy, null); + assertThat(extractLabelNames(labels)) + .containsExactly( + "foo:bar", + "foo_bar", + "otel_scope_foo", + "otel_scope_name", + "otel_scope_schema_url", + "otel_scope_version"); + assertThat(labels.get("foo:bar")).isEqualTo("a"); + assertThat(labels.get("foo_bar")).isEqualTo("c"); + } + + private static Stream metricAttributeCollisionNonEscapingArgs() { + return Stream.of( + Arguments.argumentSet( + "no utf8 escaping with suffixes", TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES), + Arguments.argumentSet("no translation", TranslationStrategy.NO_TRANSLATION)); + } + @Test void metricNameCollisionTest_Issue6277() { // NOTE: Metrics with the same resolved prometheus name should merge. However, @@ -1295,4 +1429,81 @@ private Object invokePrivate(String methodName, Class[] parameterTypes, Objec throw e; } } + + private static Labels extractMetricLabels( + MetricData metricData, + TranslationStrategy translationStrategy, + @Nullable Predicate allowedResourceAttributesFilter) { + Otel2PrometheusConverter converter = + new Otel2PrometheusConverter( + /* otelScopeLabelsEnabled= */ true, + /* targetInfoMetricEnabled= */ true, + translationStrategy, + allowedResourceAttributesFilter); + MetricSnapshots snapshots = converter.convert(Collections.singletonList(metricData)); + + Optional metricSnapshot = + snapshots.stream().filter(snapshot -> !(snapshot instanceof InfoSnapshot)).findFirst(); + assertThat(metricSnapshot).isPresent(); + return metricSnapshot.get().getDataPoints().get(0).getLabels(); + } + + private static List extractLabelNames(Labels labels) { + List names = new ArrayList<>(); + for (int i = 0; i < labels.size(); i++) { + names.add(labels.getName(i)); + } + return names; + } + + // Use a non-default Attributes implementation so ordering-sensitive collision tests do not rely + // on the default SDK storage type. + private static Attributes createMapAttributes(String... keyValues) { + Map, Object> map = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put(stringKey(keyValues[i]), keyValues[i + 1]); + } + return new MapAttributes(map); + } + + @SuppressWarnings("unchecked") + private static final class MapAttributes implements Attributes { + + private final LinkedHashMap, Object> map; + + private MapAttributes(Map, Object> map) { + this.map = new LinkedHashMap<>(map); + } + + @Nullable + @Override + public T get(AttributeKey key) { + return (T) map.get(key); + } + + @Override + public void forEach(BiConsumer, ? super Object> consumer) { + map.forEach(consumer); + } + + @Override + public int size() { + return map.size(); + } + + @Override + public boolean isEmpty() { + return map.isEmpty(); + } + + @Override + public Map, Object> asMap() { + return map; + } + + @Override + public AttributesBuilder toBuilder() { + throw new UnsupportedOperationException("not supported"); + } + } }