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 e1cb84d9766..664ad1bd4c2 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 @@ -10,6 +10,7 @@ import static java.util.Objects.requireNonNull; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributeType; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; @@ -60,6 +61,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.StringJoiner; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; @@ -472,7 +474,9 @@ private Labels convertAttributes( Map labelNameToValue = new HashMap<>(); attributes.forEach( - (key, value) -> labelNameToValue.put(sanitizeLabelName(key.getKey()), value.toString())); + (key, value) -> + labelNameToValue.put( + sanitizeLabelName(key.getKey()), toLabelValue(key.getType(), value))); for (int i = 0; i < additionalAttributes.length; i += 2) { labelNameToValue.putIfAbsent( @@ -642,4 +646,76 @@ private static String typeString(MetricSnapshot snapshot) { // Simple helper for a log message. return snapshot.getClass().getSimpleName().replace("Snapshot", "").toLowerCase(Locale.ENGLISH); } + + private static String toLabelValue(AttributeType type, Object attributeValue) { + switch (type) { + case STRING: + case BOOLEAN: + case LONG: + case DOUBLE: + return attributeValue.toString(); + case BOOLEAN_ARRAY: + case LONG_ARRAY: + case DOUBLE_ARRAY: + case STRING_ARRAY: + if (attributeValue instanceof List) { + return toJsonStr((List) attributeValue); + } else { + throw new IllegalStateException( + String.format( + "Unexpected label value of %s for %s", + attributeValue.getClass().getName(), type.name())); + } + } + throw new IllegalStateException("Unrecognized AttributeType: " + type); + } + + public static String toJsonStr(List attributeValue) { + StringJoiner joiner = new StringJoiner(",", "[", "]"); + for (int i = 0; i < attributeValue.size(); i++) { + Object value = attributeValue.get(i); + joiner.add(value instanceof String ? toJsonValidStr((String) value) : String.valueOf(value)); + } + return joiner.toString(); + } + + public static String toJsonValidStr(String str) { + StringBuilder sb = new StringBuilder(); + sb.append('"'); + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c <= 0x1F) { + sb.append(String.format(Locale.ROOT, "\\u%04X", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + return sb.toString(); + } } 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 5b8dd270548..6395d60c75e 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 @@ -5,10 +5,20 @@ package io.opentelemetry.exporter.prometheus; +import static io.opentelemetry.api.common.AttributeKey.booleanArrayKey; +import static io.opentelemetry.api.common.AttributeKey.booleanKey; +import static io.opentelemetry.api.common.AttributeKey.doubleArrayKey; +import static io.opentelemetry.api.common.AttributeKey.doubleKey; +import static io.opentelemetry.api.common.AttributeKey.longArrayKey; +import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringArrayKey; import static io.opentelemetry.api.common.AttributeKey.stringKey; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.common.AttributeType; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; import io.opentelemetry.sdk.metrics.data.AggregationTemporality; @@ -28,6 +38,7 @@ import io.opentelemetry.sdk.metrics.internal.data.ImmutableSummaryPointData; import io.opentelemetry.sdk.resources.Resource; import io.prometheus.metrics.expositionformats.ExpositionFormats; +import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -53,6 +64,7 @@ class Otel2PrometheusConverterTest { private static final Pattern PATTERN = Pattern.compile( "# HELP (?.*)\n# TYPE (?.*)\n(?.*)\\{otel_scope_name=\"scope\"}(.|\\n)*"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final Otel2PrometheusConverter converter = new Otel2PrometheusConverter(true, /* allowedResourceAttributesFilter= */ null); @@ -79,6 +91,101 @@ void metricMetadata( assertThat(matcher.group("metricName")).isEqualTo(expectedMetricName); } + private static Stream metricMetadataArgs() { + return Stream.of( + // the unity unit "1" is translated to "ratio" + Arguments.of( + createSampleMetricData("sample", "1", MetricDataType.LONG_GAUGE), + "sample_ratio gauge", + "sample_ratio description", + "sample_ratio"), + // unit is appended to metric name + Arguments.of( + createSampleMetricData("sample", "unit", MetricDataType.LONG_GAUGE), + "sample_unit gauge", + "sample_unit description", + "sample_unit"), + // units in curly braces are dropped + Arguments.of( + createSampleMetricData("sample", "1{dropped}", MetricDataType.LONG_GAUGE), + "sample_ratio gauge", + "sample_ratio description", + "sample_ratio"), + // monotonic sums always include _total suffix + Arguments.of( + createSampleMetricData("sample", "unit", MetricDataType.LONG_SUM), + "sample_unit_total counter", + "sample_unit_total description", + "sample_unit_total"), + Arguments.of( + createSampleMetricData("sample", "1", MetricDataType.LONG_SUM), + "sample_ratio_total counter", + "sample_ratio_total description", + "sample_ratio_total"), + // units expressed as numbers other than 1 are retained + Arguments.of( + createSampleMetricData("sample", "2", MetricDataType.LONG_SUM), + "sample_2_total counter", + "sample_2_total description", + "sample_2_total"), + Arguments.of( + createSampleMetricData("metric_name", "2", MetricDataType.SUMMARY), + "metric_name_2 summary", + "metric_name_2 description", + "metric_name_2_count"), + // unsupported characters are translated to "_", repeated "_" are dropped + Arguments.of( + createSampleMetricData("s%%ple", "%/min", MetricDataType.SUMMARY), + "s_ple_percent_per_minute summary", + "s_ple_percent_per_minute description", + "s_ple_percent_per_minute_count"), + // metric unit is not appended if the name already contains the unit + Arguments.of( + createSampleMetricData("metric_name_total", "total", MetricDataType.LONG_SUM), + "metric_name_total counter", + "metric_name_total description", + "metric_name_total"), + // total suffix is stripped because total is a reserved suffixed for monotonic sums + Arguments.of( + createSampleMetricData("metric_name_total", "total", MetricDataType.SUMMARY), + "metric_name summary", + "metric_name description", + "metric_name_count"), + // if metric name ends with unit the unit is omitted + Arguments.of( + createSampleMetricData("metric_name_ratio", "1", MetricDataType.LONG_GAUGE), + "metric_name_ratio gauge", + "metric_name_ratio description", + "metric_name_ratio"), + Arguments.of( + createSampleMetricData("metric_name_ratio", "1", MetricDataType.SUMMARY), + "metric_name_ratio summary", + "metric_name_ratio description", + "metric_name_ratio_count"), + Arguments.of( + createSampleMetricData("metric_hertz", "hertz", MetricDataType.LONG_GAUGE), + "metric_hertz gauge", + "metric_hertz description", + "metric_hertz"), + Arguments.of( + createSampleMetricData("metric_hertz", "hertz", MetricDataType.LONG_SUM), + "metric_hertz_total counter", + "metric_hertz_total description", + "metric_hertz_total"), + // if metric name ends with unit the unit is omitted - order matters + Arguments.of( + createSampleMetricData("metric_total_hertz", "hertz_total", MetricDataType.LONG_SUM), + "metric_total_hertz_total counter", + "metric_total_hertz_total description", + "metric_total_hertz_total"), + // metric name cannot start with a number + Arguments.of( + createSampleMetricData("2_metric_name", "By", MetricDataType.SUMMARY), + "_metric_name_bytes summary", + "_metric_name_bytes description", + "_metric_name_bytes_count")); + } + @ParameterizedTest @MethodSource("resourceAttributesAdditionArgs") void resourceAttributesAddition( @@ -109,34 +216,6 @@ void resourceAttributesAddition( assertThat(metricLabels).isEqualTo(expectedMetricLabels); } - @Test - void prometheusNameCollisionTest_Issue6277() { - // NOTE: Metrics with the same resolved prometheus name should merge. However, - // Otel2PrometheusConverter is not responsible for merging individual series, so the merge will - // fail if the two different metrics contain overlapping series. Users should deal with this by - // adding a view that renames one of the two metrics such that the conflict does not occur. - MetricData dotName = - createSampleMetricData( - "my.metric", - "units", - MetricDataType.LONG_SUM, - Attributes.builder().put("key", "a").build(), - Resource.create(Attributes.empty())); - MetricData underscoreName = - createSampleMetricData( - "my_metric", - "units", - MetricDataType.LONG_SUM, - Attributes.builder().put("key", "b").build(), - Resource.create(Attributes.empty())); - - List metricData = new ArrayList<>(); - metricData.add(dotName); - metricData.add(underscoreName); - - assertThatCode(() -> converter.convert(metricData)).doesNotThrowAnyException(); - } - private static Stream resourceAttributesAdditionArgs() { List arguments = new ArrayList<>(); @@ -199,99 +278,74 @@ private static Stream resourceAttributesAdditionArgs() { return arguments.stream(); } - private static Stream metricMetadataArgs() { + @Test + void prometheusNameCollisionTest_Issue6277() { + // NOTE: Metrics with the same resolved prometheus name should merge. However, + // Otel2PrometheusConverter is not responsible for merging individual series, so the merge will + // fail if the two different metrics contain overlapping series. Users should deal with this by + // adding a view that renames one of the two metrics such that the conflict does not occur. + MetricData dotName = + createSampleMetricData( + "my.metric", + "units", + MetricDataType.LONG_SUM, + Attributes.builder().put("key", "a").build(), + Resource.create(Attributes.empty())); + MetricData underscoreName = + createSampleMetricData( + "my_metric", + "units", + MetricDataType.LONG_SUM, + Attributes.builder().put("key", "b").build(), + Resource.create(Attributes.empty())); + + List metricData = new ArrayList<>(); + metricData.add(dotName); + metricData.add(underscoreName); + + assertThatCode(() -> converter.convert(metricData)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @MethodSource("labelValueSerializationArgs") + void labelValueSerialization(Attributes attributes) { + MetricData metricData = + createSampleMetricData("sample", "1", MetricDataType.LONG_SUM, attributes, null); + + MetricSnapshots snapshots = converter.convert(Collections.singletonList(metricData)); + + Labels labels = snapshots.get(0).getDataPoints().get(0).getLabels(); + attributes.forEach( + (key, value) -> { + String labelValue = labels.get(key.getKey()); + try { + String expectedValue = + key.getType() == AttributeType.STRING + ? (String) value + : OBJECT_MAPPER.writeValueAsString(value); + assertThat(labelValue).isEqualTo(expectedValue); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + } + + private static Stream labelValueSerializationArgs() { return Stream.of( - // the unity unit "1" is translated to "ratio" + Arguments.of(Attributes.of(stringKey("key"), "stringValue")), + Arguments.of(Attributes.of(booleanKey("key"), true)), + Arguments.of(Attributes.of(longKey("key"), Long.MAX_VALUE)), + Arguments.of(Attributes.of(doubleKey("key"), 0.12345)), Arguments.of( - createSampleMetricData("sample", "1", MetricDataType.LONG_GAUGE), - "sample_ratio gauge", - "sample_ratio description", - "sample_ratio"), - // unit is appended to metric name + Attributes.of( + stringArrayKey("key"), + Arrays.asList("stringValue1", "\"+\\\\\\+\b+\f+\n+\r+\t+" + (char) 0))), + Arguments.of(Attributes.of(booleanArrayKey("key"), Arrays.asList(true, false))), Arguments.of( - createSampleMetricData("sample", "unit", MetricDataType.LONG_GAUGE), - "sample_unit gauge", - "sample_unit description", - "sample_unit"), - // units in curly braces are dropped + Attributes.of(longArrayKey("key"), Arrays.asList(Long.MIN_VALUE, Long.MAX_VALUE))), Arguments.of( - createSampleMetricData("sample", "1{dropped}", MetricDataType.LONG_GAUGE), - "sample_ratio gauge", - "sample_ratio description", - "sample_ratio"), - // monotonic sums always include _total suffix - Arguments.of( - createSampleMetricData("sample", "unit", MetricDataType.LONG_SUM), - "sample_unit_total counter", - "sample_unit_total description", - "sample_unit_total"), - Arguments.of( - createSampleMetricData("sample", "1", MetricDataType.LONG_SUM), - "sample_ratio_total counter", - "sample_ratio_total description", - "sample_ratio_total"), - // units expressed as numbers other than 1 are retained - Arguments.of( - createSampleMetricData("sample", "2", MetricDataType.LONG_SUM), - "sample_2_total counter", - "sample_2_total description", - "sample_2_total"), - Arguments.of( - createSampleMetricData("metric_name", "2", MetricDataType.SUMMARY), - "metric_name_2 summary", - "metric_name_2 description", - "metric_name_2_count"), - // unsupported characters are translated to "_", repeated "_" are dropped - Arguments.of( - createSampleMetricData("s%%ple", "%/min", MetricDataType.SUMMARY), - "s_ple_percent_per_minute summary", - "s_ple_percent_per_minute description", - "s_ple_percent_per_minute_count"), - // metric unit is not appended if the name already contains the unit - Arguments.of( - createSampleMetricData("metric_name_total", "total", MetricDataType.LONG_SUM), - "metric_name_total counter", - "metric_name_total description", - "metric_name_total"), - // total suffix is stripped because total is a reserved suffixed for monotonic sums - Arguments.of( - createSampleMetricData("metric_name_total", "total", MetricDataType.SUMMARY), - "metric_name summary", - "metric_name description", - "metric_name_count"), - // if metric name ends with unit the unit is omitted - Arguments.of( - createSampleMetricData("metric_name_ratio", "1", MetricDataType.LONG_GAUGE), - "metric_name_ratio gauge", - "metric_name_ratio description", - "metric_name_ratio"), - Arguments.of( - createSampleMetricData("metric_name_ratio", "1", MetricDataType.SUMMARY), - "metric_name_ratio summary", - "metric_name_ratio description", - "metric_name_ratio_count"), - Arguments.of( - createSampleMetricData("metric_hertz", "hertz", MetricDataType.LONG_GAUGE), - "metric_hertz gauge", - "metric_hertz description", - "metric_hertz"), - Arguments.of( - createSampleMetricData("metric_hertz", "hertz", MetricDataType.LONG_SUM), - "metric_hertz_total counter", - "metric_hertz_total description", - "metric_hertz_total"), - // if metric name ends with unit the unit is omitted - order matters - Arguments.of( - createSampleMetricData("metric_total_hertz", "hertz_total", MetricDataType.LONG_SUM), - "metric_total_hertz_total counter", - "metric_total_hertz_total description", - "metric_total_hertz_total"), - // metric name cannot start with a number - Arguments.of( - createSampleMetricData("2_metric_name", "By", MetricDataType.SUMMARY), - "_metric_name_bytes summary", - "_metric_name_bytes description", - "_metric_name_bytes_count")); + Attributes.of( + doubleArrayKey("key"), Arrays.asList(Double.MIN_VALUE, Double.MAX_VALUE)))); } static MetricData createSampleMetricData(