diff --git a/docs/content/config/config.md b/docs/content/config/config.md index deceae000..7cd2a2f39 100644 --- a/docs/content/config/config.md +++ b/docs/content/config/config.md @@ -38,21 +38,23 @@ The properties file is searched in the following locations: -| Name | Javadoc | Note | -| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Name | Javadoc | Note | +|-----------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------| | io.prometheus.metrics.exemplarsEnabled | [Counter.Builder.withExemplars()]() | (1) (2) | -| io.prometheus.metrics.histogramNativeOnly | [Histogram.Builder.nativeOnly()]() | (2) | -| io.prometheus.metrics.histogramClassicOnly | [Histogram.Builder.classicOnly()]() | (2) | -| io.prometheus.metrics.histogramClassicUpperBounds | [Histogram.Builder.classicUpperBounds()]() | (3) | -| io.prometheus.metrics.histogramNativeInitialSchema | [Histogram.Builder.nativeInitialSchema()]() | | -| io.prometheus.metrics.histogramNativeMinZeroThreshold | [Histogram.Builder.nativeMinZeroThreshold()]() | | -| io.prometheus.metrics.histogramNativeMaxZeroThreshold | [Histogram.Builder.nativeMaxZeroThreshold()]() | | -| io.prometheus.metrics.histogramNativeMaxNumberOfBuckets | [Histogram.Builder.nativeMaxNumberOfBuckets()]() | | -| io.prometheus.metrics.histogramNativeResetDurationSeconds | [Histogram.Builder.nativeResetDuration()]() | | -| io.prometheus.metrics.summaryQuantiles | [Summary.Builder.quantile(double)]() | (4) | -| io.prometheus.metrics.summaryQuantileErrors | [Summary.Builder.quantile(double, double)]() | (5) | -| io.prometheus.metrics.summaryMaxAgeSeconds | [Summary.Builder.maxAgeSeconds()]() | | -| io.prometheus.metrics.summaryNumberOfAgeBuckets | [Summary.Builder.numberOfAgeBuckets()]() | | +| io.prometheus.metrics.histogramNativeOnly | [Histogram.Builder.nativeOnly()]() | (2) | +| io.prometheus.metrics.histogramClassicOnly | [Histogram.Builder.classicOnly()]() | (2) | +| io.prometheus.metrics.histogramClassicUpperBounds | [Histogram.Builder.classicUpperBounds()]() | (3) | +| io.prometheus.metrics.histogramNativeInitialSchema | [Histogram.Builder.nativeInitialSchema()]() | | +| io.prometheus.metrics.histogramNativeMinZeroThreshold | [Histogram.Builder.nativeMinZeroThreshold()]() | | +| io.prometheus.metrics.histogramNativeMaxZeroThreshold | [Histogram.Builder.nativeMaxZeroThreshold()]() | | +| io.prometheus.metrics.histogramNativeMaxNumberOfBuckets | [Histogram.Builder.nativeMaxNumberOfBuckets()]() | | +| io.prometheus.metrics.histogramNativeResetDurationSeconds | [Histogram.Builder.nativeResetDuration()]() | | +| io.prometheus.metrics.summaryQuantiles | [Summary.Builder.quantile(double)]() | (4) | +| io.prometheus.metrics.summaryQuantileErrors | [Summary.Builder.quantile(double, double)]() | (5) | +| io.prometheus.metrics.summaryMaxAgeSeconds | [Summary.Builder.maxAgeSeconds()]() | | +| io.prometheus.metrics.summaryNumberOfAgeBuckets | [Summary.Builder.numberOfAgeBuckets()]() | | +| io.prometheus.metrics.useOtelMetrics | [MetricsProperties.useOtelMetrics()]() | (2) | +| io.prometheus.metrics.otelOptIn | [MetricsProperties.isOtelOptIn()]() | (2) | diff --git a/docs/content/instrumentation/jvm.md b/docs/content/instrumentation/jvm.md index 804c1b09b..110df42f0 100644 --- a/docs/content/instrumentation/jvm.md +++ b/docs/content/instrumentation/jvm.md @@ -123,6 +123,21 @@ jvm_gc_collection_seconds_count{gc="PS Scavenge"} 0 jvm_gc_collection_seconds_sum{gc="PS Scavenge"} 0.0 ``` +For more detailed GC metrics, enable the [useOtelMetrics](https://prometheus.github.io/client_java/config/config/#metrics-properties) configuration option. This replaces the standard metric with a +histogram implemented according to the [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#metric-jvmgcduration). + +```text +# HELP jvm_gc_duration_seconds Duration of JVM garbage collection actions. +# TYPE jvm_gc_duration_seconds histogram +jvm_gc_duration_seconds_bucket{jvm_gc_action="end of minor GC",jvm_gc_name="G1 Young Generation",le="0.01"} 4 +jvm_gc_duration_seconds_bucket{jvm_gc_action="end of minor GC",jvm_gc_name="G1 Young Generation",le="0.1"} 4 +jvm_gc_duration_seconds_bucket{jvm_gc_action="end of minor GC",jvm_gc_name="G1 Young Generation",le="1.0"} 4 +jvm_gc_duration_seconds_bucket{jvm_gc_action="end of minor GC",jvm_gc_name="G1 Young Generation",le="10.0"} 4 +jvm_gc_duration_seconds_bucket{jvm_gc_action="end of minor GC",jvm_gc_name="G1 Young Generation",le="+Inf"} 4 +jvm_gc_duration_seconds_count{jvm_gc_action="end of minor GC",jvm_gc_name="G1 Young Generation"} 4 +jvm_gc_duration_seconds_sum{jvm_gc_action="end of minor GC",jvm_gc_name="G1 Young Generation"} 0.029 +``` + ## JVM Memory Metrics JVM memory metrics are provided by diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/MetricsProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/MetricsProperties.java index 6c8942713..837a26e2c 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/MetricsProperties.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/MetricsProperties.java @@ -28,6 +28,8 @@ public class MetricsProperties { private static final String SUMMARY_QUANTILE_ERRORS = "summaryQuantileErrors"; private static final String SUMMARY_MAX_AGE_SECONDS = "summaryMaxAgeSeconds"; private static final String SUMMARY_NUMBER_OF_AGE_BUCKETS = "summaryNumberOfAgeBuckets"; + private static final String USE_OTEL_METRICS = "useOtelMetrics"; + private static final String OTEL_OPT_IN = "otelOptIn"; @Nullable private final Boolean exemplarsEnabled; @Nullable private final Boolean histogramNativeOnly; @@ -42,6 +44,8 @@ public class MetricsProperties { @Nullable private final List summaryQuantileErrors; @Nullable private final Long summaryMaxAgeSeconds; @Nullable private final Integer summaryNumberOfAgeBuckets; + @Nullable private final Boolean useOtelMetrics; + @Nullable private final Boolean otelOptIn; public MetricsProperties( @Nullable Boolean exemplarsEnabled, @@ -56,7 +60,9 @@ public MetricsProperties( @Nullable List summaryQuantiles, @Nullable List summaryQuantileErrors, @Nullable Long summaryMaxAgeSeconds, - @Nullable Integer summaryNumberOfAgeBuckets) { + @Nullable Integer summaryNumberOfAgeBuckets, + @Nullable Boolean useOtelMetrics, + Boolean otelOptIn) { this( exemplarsEnabled, histogramNativeOnly, @@ -71,6 +77,8 @@ public MetricsProperties( summaryQuantileErrors, summaryMaxAgeSeconds, summaryNumberOfAgeBuckets, + useOtelMetrics, + otelOptIn, ""); } @@ -88,8 +96,11 @@ private MetricsProperties( @Nullable List summaryQuantileErrors, @Nullable Long summaryMaxAgeSeconds, @Nullable Integer summaryNumberOfAgeBuckets, + @Nullable Boolean useOtelMetrics, + @Nullable Boolean otelOptIn, String configPropertyPrefix) { this.exemplarsEnabled = exemplarsEnabled; + this.otelOptIn = otelOptIn; this.histogramNativeOnly = isHistogramNativeOnly(histogramClassicOnly, histogramNativeOnly); this.histogramClassicOnly = isHistogramClassicOnly(histogramClassicOnly, histogramNativeOnly); this.histogramClassicUpperBounds = @@ -109,6 +120,7 @@ private MetricsProperties( : unmodifiableList(new ArrayList<>(summaryQuantileErrors)); this.summaryMaxAgeSeconds = summaryMaxAgeSeconds; this.summaryNumberOfAgeBuckets = summaryNumberOfAgeBuckets; + this.useOtelMetrics = useOtelMetrics; validate(configPropertyPrefix); } @@ -334,6 +346,22 @@ public Integer getSummaryNumberOfAgeBuckets() { return summaryNumberOfAgeBuckets; } + /** + * Where applicable, metrics are registered in accordance with OpenTelemetry Semantic Conventions. + * Implementation should respect opt-in requirements and ensure no data duplication occurs with + * existing Prometheus metrics. + */ + @Nullable + public Boolean useOtelMetrics() { + return useOtelMetrics; + } + + /** Where applicable, if using otel metrics, allow usage of opt-in labels */ + @Nullable + public Boolean isOtelOptIn() { + return otelOptIn; + } + /** * Note that this will remove entries from {@code properties}. This is because we want to know if * there are unused properties remaining after all properties have been loaded. @@ -354,6 +382,8 @@ static MetricsProperties load(String prefix, Map properties) Util.loadDoubleList(prefix + "." + SUMMARY_QUANTILE_ERRORS, properties), Util.loadLong(prefix + "." + SUMMARY_MAX_AGE_SECONDS, properties), Util.loadInteger(prefix + "." + SUMMARY_NUMBER_OF_AGE_BUCKETS, properties), + Util.loadBoolean(prefix + "." + USE_OTEL_METRICS, properties), + Util.loadBoolean(prefix + "." + OTEL_OPT_IN, properties), prefix); } @@ -375,6 +405,8 @@ public static class Builder { @Nullable private List summaryQuantileErrors; @Nullable private Long summaryMaxAgeSeconds; @Nullable private Integer summaryNumberOfAgeBuckets; + @Nullable private Boolean useOtelMetrics; + @Nullable private Boolean otelOptIn; private Builder() {} @@ -392,7 +424,9 @@ public MetricsProperties build() { summaryQuantiles, summaryQuantileErrors, summaryMaxAgeSeconds, - summaryNumberOfAgeBuckets); + summaryNumberOfAgeBuckets, + useOtelMetrics, + otelOptIn); } /** See {@link MetricsProperties#getExemplarsEnabled()} */ @@ -476,5 +510,17 @@ public Builder summaryNumberOfAgeBuckets(@Nullable Integer summaryNumberOfAgeBuc this.summaryNumberOfAgeBuckets = summaryNumberOfAgeBuckets; return this; } + + /** See {@link MetricsProperties#useOtelMetrics()} */ + public Builder useOtelMetrics(@Nullable Boolean useOtelMetrics) { + this.useOtelMetrics = useOtelMetrics; + return this; + } + + /** See {@link MetricsProperties#isOtelOptIn()} */ + public Builder otelOptIn(@Nullable Boolean otelOptIn) { + this.otelOptIn = otelOptIn; + return this; + } } } diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java index ab78b48e3..4cf68ee4e 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; import javax.annotation.Nullable; /** @@ -102,6 +103,20 @@ public ExporterOpenTelemetryProperties getExporterOpenTelemetryProperties() { return exporterOpenTelemetryProperties; } + public boolean useOtelMetrics(String prometheusMetric, String otelMetric) { + Boolean useByPrometheusMetric = usesOtelMetric(prometheusMetric); + if (Boolean.FALSE.equals(useByPrometheusMetric)) return false; + Boolean useByOtelMetric = usesOtelMetric(otelMetric); + if (Boolean.FALSE.equals(useByOtelMetric)) return false; + return Boolean.TRUE.equals(getDefaultMetricProperties().useOtelMetrics()); + } + + private Boolean usesOtelMetric(String metric) { + return Optional.ofNullable(getMetricProperties(metric)) + .map(MetricsProperties::useOtelMetrics) + .orElse(null); + } + public static class Builder { private MetricsProperties defaultMetricsProperties = MetricsProperties.builder().build(); private Map metricProperties = new HashMap<>(); diff --git a/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java index d0205b28e..1dd5d4bd1 100644 --- a/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java +++ b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java @@ -6,6 +6,7 @@ import java.io.InputStream; import java.util.Collections; import java.util.HashMap; +import java.util.Map; import java.util.Properties; import org.junit.jupiter.api.Test; @@ -67,4 +68,44 @@ public void testBuilder() { assertThat(result.getMetricProperties("unknown_metric")).isNull(); assertThat(result.getExporterProperties()).isSameAs(defaults.getExporterProperties()); } + + @Test + void useOtelMetricsSupportsNegativeOverride() { + Map metricMap = new HashMap<>(); + metricMap.put("prom_metric", otelProperties(false)); + PrometheusProperties props = buildProperties(true, metricMap); + assertThat(props.useOtelMetrics("prom_metric", "any_otel_metric")).isFalse(); + } + + @Test + void useOtelMetricsDisablesByMetricName() { + Map metricMap = new HashMap<>(); + metricMap.put("otel_metric", otelProperties(false)); + PrometheusProperties props = buildProperties(true, metricMap); + assertThat(props.useOtelMetrics("some_prom_metric", "otel_metric")).isFalse(); + } + + @Test + void useOtelMetricsRespectsDefaultIfNoOverride() { + PrometheusProperties props = buildProperties(true, Collections.emptyMap()); + assertThat(props.useOtelMetrics("prom_x", "otel_y")).isTrue(); + } + + @Test + void noOverridesReturnsFalse() { + PrometheusProperties props = PrometheusProperties.get(); + assertThat(props.useOtelMetrics("prom_x", "otel_y")).isFalse(); + } + + private static PrometheusProperties buildProperties( + Boolean defaultUse, Map metricProps) { + return PrometheusProperties.builder() + .defaultMetricsProperties(otelProperties(defaultUse)) + .metricProperties(new HashMap<>(metricProps)) + .build(); + } + + private static MetricsProperties otelProperties(Boolean useOtel) { + return MetricsProperties.builder().useOtelMetrics(useOtel).build(); + } } diff --git a/prometheus-metrics-instrumentation-jvm/src/main/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetrics.java b/prometheus-metrics-instrumentation-jvm/src/main/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetrics.java index 262e2df5f..77b251246 100644 --- a/prometheus-metrics-instrumentation-jvm/src/main/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetrics.java +++ b/prometheus-metrics-instrumentation-jvm/src/main/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetrics.java @@ -1,14 +1,21 @@ package io.prometheus.metrics.instrumentation.jvm; +import com.sun.management.GarbageCollectionNotificationInfo; +import io.prometheus.metrics.config.MetricsProperties; import io.prometheus.metrics.config.PrometheusProperties; +import io.prometheus.metrics.core.metrics.Histogram; import io.prometheus.metrics.core.metrics.SummaryWithCallback; import io.prometheus.metrics.model.registry.PrometheusRegistry; import io.prometheus.metrics.model.snapshots.Quantiles; import io.prometheus.metrics.model.snapshots.Unit; import java.lang.management.GarbageCollectorMXBean; import java.lang.management.ManagementFactory; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; import javax.annotation.Nullable; +import javax.management.NotificationEmitter; +import javax.management.openmbean.CompositeData; /** * JVM Garbage Collector metrics. The {@link JvmGarbageCollectorMetrics} are registered as part of @@ -39,6 +46,7 @@ public class JvmGarbageCollectorMetrics { private static final String JVM_GC_COLLECTION_SECONDS = "jvm_gc_collection_seconds"; + private static final String JVM_GC_DURATION = "jvm.gc.duration"; private final PrometheusProperties config; private final List garbageCollectorBeans; @@ -50,7 +58,14 @@ private JvmGarbageCollectorMetrics( } private void register(PrometheusRegistry registry) { + if (config.useOtelMetrics(JVM_GC_COLLECTION_SECONDS, JVM_GC_DURATION)) { + registerGCDurationHistogram(registry); + } else { + registerGCDurationSummary(registry); + } + } + private void registerGCDurationSummary(PrometheusRegistry registry) { SummaryWithCallback.builder(config) .name(JVM_GC_COLLECTION_SECONDS) .help("Time spent in a given JVM garbage collector in seconds.") @@ -69,6 +84,70 @@ private void register(PrometheusRegistry registry) { .register(registry); } + private void registerGCDurationHistogram(PrometheusRegistry registry) { + double[] buckets = {0.01, 0.1, 1, 10}; + + List labels = new ArrayList<>(List.of("jvm.gc.action", "jvm.gc.name")); + boolean otelOptIn = + Optional.ofNullable(config.getMetricProperties(JVM_GC_DURATION)) + .map(MetricsProperties::isOtelOptIn) + .orElse(false); + if (otelOptIn) { + labels.add("jvm.gc.cause"); + } + + Histogram gcDurationHistogram = + Histogram.builder(config) + .name(JVM_GC_DURATION) + .unit(Unit.SECONDS) + .help("Duration of JVM garbage collection actions.") + .labelNames(labels.toArray(String[]::new)) + .classicUpperBounds(buckets) + .register(registry); + + registerNotificationListener(gcDurationHistogram, otelOptIn); + } + + private void registerNotificationListener(Histogram gcDurationHistogram, boolean otelOptIn) { + for (GarbageCollectorMXBean gcBean : garbageCollectorBeans) { + + if (!(gcBean instanceof NotificationEmitter)) { + continue; + } + + ((NotificationEmitter) gcBean) + .addNotificationListener( + (notification, handback) -> { + if (!GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION.equals( + notification.getType())) { + return; + } + + GarbageCollectionNotificationInfo info = + GarbageCollectionNotificationInfo.from( + (CompositeData) notification.getUserData()); + + observe(gcDurationHistogram, otelOptIn, info); + }, + null, + null); + } + } + + private void observe( + Histogram gcDurationHistogram, boolean otelOptIn, GarbageCollectionNotificationInfo info) { + double observedDuration = Unit.millisToSeconds(info.getGcInfo().getDuration()); + if (otelOptIn) { + gcDurationHistogram + .labelValues(info.getGcAction(), info.getGcName(), info.getGcCause()) + .observe(observedDuration); + } else { + gcDurationHistogram + .labelValues(info.getGcAction(), info.getGcName()) + .observe(observedDuration); + } + } + public static Builder builder() { return new Builder(PrometheusProperties.get()); } diff --git a/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetricsTest.java b/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetricsTest.java index 177f29d2e..85931aed3 100644 --- a/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetricsTest.java +++ b/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/JvmGarbageCollectorMetricsTest.java @@ -1,26 +1,40 @@ package io.prometheus.metrics.instrumentation.jvm; +import static com.sun.management.GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION; import static io.prometheus.metrics.instrumentation.jvm.TestUtil.convertToOpenMetricsFormat; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.Mockito.*; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.prometheus.metrics.config.MetricsProperties; +import io.prometheus.metrics.config.PrometheusProperties; import io.prometheus.metrics.model.registry.MetricNameFilter; import io.prometheus.metrics.model.registry.PrometheusRegistry; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.IOException; import java.lang.management.GarbageCollectorMXBean; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; +import javax.management.Notification; +import javax.management.NotificationEmitter; +import javax.management.NotificationListener; +import javax.management.openmbean.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; class JvmGarbageCollectorMetricsTest { - private final GarbageCollectorMXBean mockGcBean1 = Mockito.mock(GarbageCollectorMXBean.class); - private final GarbageCollectorMXBean mockGcBean2 = Mockito.mock(GarbageCollectorMXBean.class); + private final GarbageCollectorMXBean mockGcBean1 = mock(GarbageCollectorMXBean.class); + private final GarbageCollectorMXBean mockGcBean2 = mock(GarbageCollectorMXBean.class); @BeforeEach public void setUp() { @@ -58,7 +72,9 @@ public void testGoodCase() throws IOException { @Test public void testIgnoredMetricNotScraped() { MetricNameFilter filter = - MetricNameFilter.builder().nameMustNotBeEqualTo("jvm_gc_collection_seconds").build(); + MetricNameFilter.builder() + .nameMustNotBeEqualTo("jvm_gc_collection_seconds", "jvm_gc_duration") + .build(); PrometheusRegistry registry = new PrometheusRegistry(); JvmGarbageCollectorMetrics.builder() @@ -70,4 +86,230 @@ public void testIgnoredMetricNotScraped() { verify(mockGcBean1, times(0)).getCollectionCount(); assertThat(snapshots.size()).isZero(); } + + @Test + public void testNonOtelMetricsAbsentWhenUseOtelEnabled() { + + PrometheusRegistry registry = new PrometheusRegistry(); + PrometheusProperties properties = + PrometheusProperties.builder() + .defaultMetricsProperties(MetricsProperties.builder().useOtelMetrics(true).build()) + .build(); + JvmGarbageCollectorMetrics.builder(properties) + .garbageCollectorBeans(Arrays.asList(mockGcBean1, mockGcBean2)) + .register(registry); + registry.scrape(); + + verify(mockGcBean1, times(0)).getCollectionTime(); + verify(mockGcBean1, times(0)).getCollectionCount(); + } + + @Test + @SuppressWarnings("rawtypes") + public void testGCDurationHistogramLabels() throws Exception { + GarbageCollectorMXBean mockGcBean = + mock( + GarbageCollectorMXBean.class, + withSettings().extraInterfaces(NotificationEmitter.class)); + when(mockGcBean.getName()).thenReturn("MyGC"); + + PrometheusProperties properties = + PrometheusProperties.builder() + .defaultMetricsProperties(MetricsProperties.builder().useOtelMetrics(true).build()) + .metricProperties( + Map.of("jvm_gc_duration", MetricsProperties.builder().otelOptIn(true).build())) + .build(); + + PrometheusRegistry registry = new PrometheusRegistry(); + JvmGarbageCollectorMetrics.builder(properties) + .garbageCollectorBeans(Collections.singletonList(mockGcBean)) + .register(registry); + + NotificationListener listener; + ArgumentCaptor captor = forClass(NotificationListener.class); + verify((NotificationEmitter) mockGcBean) + .addNotificationListener(captor.capture(), isNull(), isNull()); + listener = captor.getValue(); + + CompositeData notificationData = getNotificationData(); + + Notification notification = + new Notification( + GARBAGE_COLLECTION_NOTIFICATION, mockGcBean, 1, System.currentTimeMillis(), "gc"); + notification.setUserData(notificationData); + + listener.handleNotification(notification, null); + + MetricSnapshots snapshots = registry.scrape(); + + String expected = + """ + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.cause"="testCause","jvm.gc.name"="MyGC",le="0.01"} 0 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.cause"="testCause","jvm.gc.name"="MyGC",le="0.1"} 1 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.cause"="testCause","jvm.gc.name"="MyGC",le="1.0"} 1 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.cause"="testCause","jvm.gc.name"="MyGC",le="10.0"} 1 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.cause"="testCause","jvm.gc.name"="MyGC",le="+Inf"} 1 + {"jvm.gc.duration_seconds_count","jvm.gc.action"="end of minor GC","jvm.gc.cause"="testCause","jvm.gc.name"="MyGC"} 1 + {"jvm.gc.duration_seconds_sum","jvm.gc.action"="end of minor GC","jvm.gc.cause"="testCause","jvm.gc.name"="MyGC"} 0.1 + """; + + String metrics = convertToOpenMetricsFormat(snapshots); + + assertThat(metrics).contains(expected); + } + + @SuppressWarnings("rawtypes") + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testGCDurationHistogramLabelsWithNoOptIn(boolean nullOptIn) throws Exception { + GarbageCollectorMXBean mockGcBean = + mock( + GarbageCollectorMXBean.class, + withSettings().extraInterfaces(NotificationEmitter.class)); + when(mockGcBean.getName()).thenReturn("MyGC"); + + PrometheusProperties.Builder builder = + PrometheusProperties.builder() + .defaultMetricsProperties(MetricsProperties.builder().useOtelMetrics(true).build()); + if (!nullOptIn) { + builder = + builder.metricProperties( + Map.of("jvm.gc.duration", MetricsProperties.builder().otelOptIn(true).build())); + } + PrometheusProperties properties = builder.build(); + + PrometheusRegistry registry = new PrometheusRegistry(); + JvmGarbageCollectorMetrics.builder(properties) + .garbageCollectorBeans(Collections.singletonList(mockGcBean)) + .register(registry); + + NotificationListener listener; + ArgumentCaptor captor = forClass(NotificationListener.class); + verify((NotificationEmitter) mockGcBean) + .addNotificationListener(captor.capture(), isNull(), isNull()); + listener = captor.getValue(); + + CompositeData notificationData = getNotificationData(); + + Notification notification = + new Notification( + GARBAGE_COLLECTION_NOTIFICATION, mockGcBean, 1, System.currentTimeMillis(), "gc"); + notification.setUserData(notificationData); + + listener.handleNotification(notification, null); + + MetricSnapshots snapshots = registry.scrape(); + + String expected = + """ + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.name"="MyGC",le="0.01"} 0 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.name"="MyGC",le="0.1"} 1 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.name"="MyGC",le="1.0"} 1 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.name"="MyGC",le="10.0"} 1 + {"jvm.gc.duration_seconds_bucket","jvm.gc.action"="end of minor GC","jvm.gc.name"="MyGC",le="+Inf"} 1 + {"jvm.gc.duration_seconds_count","jvm.gc.action"="end of minor GC","jvm.gc.name"="MyGC"} 1 + {"jvm.gc.duration_seconds_sum","jvm.gc.action"="end of minor GC","jvm.gc.name"="MyGC"} 0.1 + """; + + String metrics = convertToOpenMetricsFormat(snapshots); + + assertThat(metrics).contains(expected); + } + + private CompositeData getNotificationData() throws OpenDataException { + TabularType memoryTabularType = getMemoryTabularType(); + TabularData memoryBefore = new TabularDataSupport(memoryTabularType); + TabularData memoryAfter = new TabularDataSupport(memoryTabularType); + + CompositeData notificationData = + getGCNotificationData(memoryTabularType, memoryBefore, memoryAfter); + return notificationData; + } + + private CompositeData getGCNotificationData( + TabularType memoryTabularType, TabularData memoryBefore, TabularData memoryAfter) + throws OpenDataException { + CompositeType gcInfoType = getGCInfoCompositeType(memoryTabularType); + + Map gcInfoMap = getGcInfoMap(memoryBefore, memoryAfter); + + CompositeData notificationData = getGcNotificationData(gcInfoType, gcInfoMap); + return notificationData; + } + + private Map getGcInfoMap(TabularData memoryBefore, TabularData memoryAfter) { + Map gcInfoMap = new HashMap<>(); + gcInfoMap.put("id", 0L); + gcInfoMap.put("startTime", 100L); + gcInfoMap.put("endTime", 200L); + gcInfoMap.put("duration", 100L); + gcInfoMap.put("memoryUsageBeforeGc", memoryBefore); + gcInfoMap.put("memoryUsageAfterGc", memoryAfter); + return gcInfoMap; + } + + private CompositeType getGCInfoCompositeType(TabularType memoryTabularType) + throws OpenDataException { + return new CompositeType( + "sun.management.BaseGcInfoCompositeType", + "gcInfo", + new String[] { + "id", "startTime", "endTime", "duration", "memoryUsageBeforeGc", "memoryUsageAfterGc" + }, + new String[] { + "id", "startTime", "endTime", "duration", "memoryUsageBeforeGc", "memoryUsageAfterGc" + }, + new OpenType[] { + SimpleType.LONG, + SimpleType.LONG, + SimpleType.LONG, + SimpleType.LONG, + memoryTabularType, + memoryTabularType + }); + } + + private TabularType getMemoryTabularType() throws OpenDataException { + CompositeType memoryUsageType = + new CompositeType( + "java.lang.management.MemoryUsage", + "MemoryUsage", + new String[] {"init", "used", "committed", "max"}, + new String[] {"init", "used", "committed", "max"}, + new OpenType[] {SimpleType.LONG, SimpleType.LONG, SimpleType.LONG, SimpleType.LONG}); + + CompositeType memoryUsageEntryType = + new CompositeType( + "memoryUsageEntry", + "memoryUsageEntry", + new String[] {"key", "value"}, + new String[] {"key", "value"}, + new OpenType[] {SimpleType.STRING, memoryUsageType}); + + return new TabularType( + "memoryUsageTabular", "memoryUsageTabular", memoryUsageEntryType, new String[] {"key"}); + } + + private static CompositeData getGcNotificationData( + CompositeType gcInfoType, Map gcInfoMap) throws OpenDataException { + CompositeData gcInfoData = new CompositeDataSupport(gcInfoType, gcInfoMap); + + CompositeType notificationType = + new CompositeType( + "sun.management.BaseGarbageCollectionNotifInfoCompositeType", + "GarbageCollectionNotificationInfo", + new String[] {"gcAction", "gcName", "gcCause", "gcInfo"}, + new String[] {"gcAction", "gcName", "gcCause", "gcInfo"}, + new OpenType[] { + SimpleType.STRING, SimpleType.STRING, SimpleType.STRING, gcInfoType + }); + + Map notifMap = new HashMap<>(); + notifMap.put("gcAction", "end of minor GC"); + notifMap.put("gcName", "MyGC"); + notifMap.put("gcCause", "testCause"); + notifMap.put("gcInfo", gcInfoData); + + return new CompositeDataSupport(notificationType, notifMap); + } }