diff --git a/core/runtime/src/main/java/io/quarkus/runtime/util/JavaVersionUtil.java b/core/runtime/src/main/java/io/quarkus/runtime/util/JavaVersionUtil.java index ab1f5d53c3ea4..8ad868f7da4c8 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/util/JavaVersionUtil.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/util/JavaVersionUtil.java @@ -13,6 +13,7 @@ public class JavaVersionUtil { private static boolean IS_JAVA_16_OR_OLDER; private static boolean IS_JAVA_17_OR_NEWER; private static boolean IS_JAVA_19_OR_NEWER; + private static boolean IS_JAVA_21_OR_NEWER; static { performChecks(); @@ -28,12 +29,14 @@ static void performChecks() { IS_JAVA_16_OR_OLDER = (first <= 16); IS_JAVA_17_OR_NEWER = (first >= 17); IS_JAVA_19_OR_NEWER = (first >= 19); + IS_JAVA_21_OR_NEWER = (first >= 21); } else { IS_JAVA_11_OR_NEWER = false; IS_JAVA_13_OR_NEWER = false; IS_JAVA_16_OR_OLDER = false; IS_JAVA_17_OR_NEWER = false; IS_JAVA_19_OR_NEWER = false; + IS_JAVA_21_OR_NEWER = false; } String vmVendor = System.getProperty("java.vm.vendor"); @@ -60,6 +63,10 @@ public static boolean isJava19OrHigher() { return IS_JAVA_19_OR_NEWER; } + public static boolean isJava21OrHigher() { + return IS_JAVA_21_OR_NEWER; + } + public static boolean isGraalvmJdk() { return IS_GRAALVM_JDK; } diff --git a/docs/src/main/asciidoc/virtual-threads.adoc b/docs/src/main/asciidoc/virtual-threads.adoc index 72257ae91acfc..89000b4cb259b 100644 --- a/docs/src/main/asciidoc/virtual-threads.adoc +++ b/docs/src/main/asciidoc/virtual-threads.adoc @@ -550,6 +550,38 @@ public class LoomUnitExampleTest { } ---- +== Virtual thread metrics + +You can enable the Micrometer Virtual Thread _binder_ by adding the following artifact to your application: + +[source,xml] +---- + + io.micrometer + micrometer-java21 + +---- + +This binder keeps track of the number of pinning events and the number of virtual threads failed to be started or un-parked. +See the https://docs.micrometer.io/micrometer/reference/reference/jvm.html#_java_21_metrics[MicroMeter documentation] for more information. + +You can explicitly disable the binder by setting the following property in your `application.properties`: + +[source,properties] +---- +# The binder is automatically enabled if the micrometer-java21 dependency is present +quarkus.micrometer.binder.virtual-threads.enabled=false +---- + +In addition, if the application is running on a JVM that does not support virtual threads (prior to Java 21), the binder is automatically disabled. + +You can associate tags to the collected metrics by setting the following properties in your `application.properties`: + +[source,properties] +---- +quarkus.micrometer.binder.virtual-threads.tags=tag_1=value_1, tag_2=value_2 +---- + == Additional references - https://dl.acm.org/doi/10.1145/3583678.3596895[Considerations for integrating virtual threads in a Java framework: a Quarkus example in a resource-constrained environment] diff --git a/extensions/micrometer/deployment/pom.xml b/extensions/micrometer/deployment/pom.xml index c447125f6737d..6cd5d423c2c9b 100644 --- a/extensions/micrometer/deployment/pom.xml +++ b/extensions/micrometer/deployment/pom.xml @@ -202,5 +202,19 @@ + + + Java 21+ + + [21,) + + + + io.micrometer + micrometer-java21 + test + + + diff --git a/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/binder/VirtualThreadBinderProcessor.java b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/binder/VirtualThreadBinderProcessor.java new file mode 100644 index 0000000000000..183778be979ef --- /dev/null +++ b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/binder/VirtualThreadBinderProcessor.java @@ -0,0 +1,34 @@ +package io.quarkus.micrometer.deployment.binder; + +import java.util.function.BooleanSupplier; + +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.micrometer.runtime.MicrometerRecorder; +import io.quarkus.micrometer.runtime.config.MicrometerConfig; + +/** + * Add support for virtual thread metric collections. + */ +public class VirtualThreadBinderProcessor { + static final String VIRTUAL_THREAD_COLLECTOR_CLASS_NAME = "io.quarkus.micrometer.runtime.binder.virtualthreads.VirtualThreadCollector"; + + static final String VIRTUAL_THREAD_BINDER_CLASS_NAME = "io.micrometer.java21.instrument.binder.jdk.VirtualThreadMetrics"; + static final Class VIRTUAL_THREAD_BINDER_CLASS = MicrometerRecorder.getClassForName(VIRTUAL_THREAD_BINDER_CLASS_NAME); + + static class VirtualThreadSupportEnabled implements BooleanSupplier { + MicrometerConfig mConfig; + + public boolean getAsBoolean() { + return VIRTUAL_THREAD_BINDER_CLASS != null // The binder is in another Micrometer artifact + && mConfig.checkBinderEnabledWithDefault(mConfig.binder.virtualThreads); + } + } + + @BuildStep(onlyIf = VirtualThreadSupportEnabled.class) + AdditionalBeanBuildItem createCDIEventConsumer() { + return AdditionalBeanBuildItem.builder() + .addBeanClass(VIRTUAL_THREAD_COLLECTOR_CLASS_NAME) + .setUnremovable().build(); + } +} diff --git a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsDisabledTest.java b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsDisabledTest.java new file mode 100644 index 0000000000000..11e9a3a444f1e --- /dev/null +++ b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsDisabledTest.java @@ -0,0 +1,41 @@ +package io.quarkus.micrometer.deployment.binder; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.micrometer.runtime.binder.virtualthreads.VirtualThreadCollector; +import io.quarkus.test.QuarkusUnitTest; + +public class VirtualThreadMetricsDisabledTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withConfigurationResource("test-logging.properties") + .overrideConfigKey("quarkus.micrometer.binder.virtual-threads.enabled", "true") + + .overrideConfigKey("quarkus.micrometer.binder-enabled-default", "false") + .overrideConfigKey("quarkus.micrometer.registry-enabled-default", "false") + .overrideConfigKey("quarkus.redis.devservices.enabled", "false") + .withEmptyApplication(); + + @Inject + BeanManager beans; + + @Test + void testNoInstancePresentIfDisabled() { + assertTrue( + beans.createInstance().select() + .stream().filter(this::isVirtualThreadCollector).findAny().isEmpty(), + "No VirtualThreadCollector expected"); + } + + private boolean isVirtualThreadCollector(Object bean) { + return bean.getClass().toString().equals(VirtualThreadCollector.class.toString()); + } + +} diff --git a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsTest.java b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsTest.java new file mode 100644 index 0000000000000..d5adf81b93bdb --- /dev/null +++ b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsTest.java @@ -0,0 +1,44 @@ +package io.quarkus.micrometer.deployment.binder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.micrometer.runtime.binder.virtualthreads.VirtualThreadCollector; +import io.quarkus.test.QuarkusUnitTest; + +@EnabledForJreRange(min = JRE.JAVA_21) +public class VirtualThreadMetricsTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withConfigurationResource("test-logging.properties") + .overrideConfigKey("quarkus.redis.devservices.enabled", "false") + .withEmptyApplication(); + + @Inject + Instance collector; + + @Test + void testInstancePresent() { + assertTrue(collector.isResolvable(), "VirtualThreadCollector expected"); + } + + @Test + void testBinderCreated() { + assertThat(collector.get().getBinder()).isNotNull(); + } + + @Test + void testTags() { + assertThat(collector.get().getTags()).isEmpty(); + } + +} diff --git a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsWithTagsTest.java b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsWithTagsTest.java new file mode 100644 index 0000000000000..7100ea1aa22de --- /dev/null +++ b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/binder/VirtualThreadMetricsWithTagsTest.java @@ -0,0 +1,53 @@ +package io.quarkus.micrometer.deployment.binder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.micrometer.runtime.binder.virtualthreads.VirtualThreadCollector; +import io.quarkus.test.QuarkusUnitTest; + +@EnabledForJreRange(min = JRE.JAVA_21) +public class VirtualThreadMetricsWithTagsTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withConfigurationResource("test-logging.properties") + .overrideConfigKey("quarkus.micrometer.binder.virtual-threads.tags", "k1=v1, k2=v2") + .overrideConfigKey("quarkus.redis.devservices.enabled", "false") + .withEmptyApplication(); + + @Inject + Instance collector; + + @Test + void testInstancePresent() { + assertTrue(collector.isResolvable(), "VirtualThreadCollector expected"); + } + + @Test + void testBinderCreated() { + assertThat(collector.get().getBinder()).isNotNull(); + } + + @Test + void testTags() { + assertThat(collector.get().getTags()).hasSize(2) + .anySatisfy(t -> { + assertThat(t.getKey()).isEqualTo("k1"); + assertThat(t.getValue()).isEqualTo("v1"); + }) + .anySatisfy(t -> { + assertThat(t.getKey()).isEqualTo("k2"); + assertThat(t.getValue()).isEqualTo("v2"); + }); + } + +} diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/virtualthreads/VirtualThreadCollector.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/virtualthreads/VirtualThreadCollector.java new file mode 100644 index 0000000000000..8503e03669842 --- /dev/null +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/virtualthreads/VirtualThreadCollector.java @@ -0,0 +1,112 @@ +package io.quarkus.micrometer.runtime.binder.virtualthreads; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.jboss.logging.Logger; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.binder.MeterBinder; +import io.quarkus.micrometer.runtime.config.MicrometerConfig; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import io.quarkus.runtime.util.JavaVersionUtil; + +/** + * A component collecting metrics about virtual threads. + * It will be only available when the virtual threads are enabled (Java 21+). + *

+ * Note that metrics are collected using JFR events. + */ +@ApplicationScoped +public class VirtualThreadCollector { + + private static final String VIRTUAL_THREAD_BINDER_CLASSNAME = "io.micrometer.java21.instrument.binder.jdk.VirtualThreadMetrics"; + private static final Logger LOGGER = Logger.getLogger(VirtualThreadCollector.class); + + final MeterRegistry registry = Metrics.globalRegistry; + + private final boolean enabled; + private final MeterBinder binder; + private final List tags; + + @Inject + public VirtualThreadCollector(MicrometerConfig mc) { + var config = mc.binder.virtualThreads; + this.enabled = JavaVersionUtil.isJava21OrHigher() && config.enabled.orElse(true); + MeterBinder instantiated = null; + if (enabled) { + if (config.tags.isPresent()) { + List list = config.tags.get(); + this.tags = list.stream().map(this::createTagFromEntry).collect(Collectors.toList()); + } else { + this.tags = List.of(); + } + try { + instantiated = instantiate(tags); + } catch (Exception e) { + LOGGER.warnf(e, "Failed to instantiate " + VIRTUAL_THREAD_BINDER_CLASSNAME); + } + } else { + this.tags = List.of(); + } + this.binder = instantiated; + } + + /** + * Use reflection to avoid calling a class touching Java 21+ APIs. + * + * @param tags the tags. + * @return the binder, {@code null} if the instantiation failed. + */ + public MeterBinder instantiate(List tags) { + try { + Class clazz = Class.forName(VIRTUAL_THREAD_BINDER_CLASSNAME); + return (MeterBinder) clazz.getDeclaredConstructor(Iterable.class).newInstance(tags); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate " + VIRTUAL_THREAD_BINDER_CLASSNAME, e); + } + } + + private Tag createTagFromEntry(String entry) { + String[] parts = entry.trim().split("="); + if (parts.length == 2) { + return Tag.of(parts[0], parts[1]); + } else { + throw new IllegalStateException("Invalid tag: " + entry + " (expected key=value)"); + } + } + + public MeterBinder getBinder() { + return binder; + } + + public List getTags() { + return tags; + } + + public void init(@Observes StartupEvent event) { + if (enabled && binder != null) { + binder.bindTo(registry); + } + } + + public void close(@Observes ShutdownEvent event) { + if (binder instanceof Closeable) { + try { + ((Closeable) binder).close(); + } catch (IOException e) { + LOGGER.warnf(e, "Failed to close " + VIRTUAL_THREAD_BINDER_CLASSNAME); + } + } + } + +} diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/config/MicrometerConfig.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/config/MicrometerConfig.java index cef3d1f52a5e1..b5c51a3f002fb 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/config/MicrometerConfig.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/config/MicrometerConfig.java @@ -109,6 +109,8 @@ public static class BinderConfig { public MPMetricsConfigGroup mpMetrics; + public VirtualThreadsConfigGroup virtualThreads; + /** * Micrometer System metrics support. *

diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/config/VirtualThreadsConfigGroup.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/config/VirtualThreadsConfigGroup.java new file mode 100644 index 0000000000000..e739b78471163 --- /dev/null +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/config/VirtualThreadsConfigGroup.java @@ -0,0 +1,35 @@ +package io.quarkus.micrometer.runtime.config; + +import java.util.List; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +/** + * Build / static runtime config for the virtual thread metric collection. + */ +@ConfigGroup +public class VirtualThreadsConfigGroup implements MicrometerConfig.CapabilityEnabled { + /** + * Virtual Threads metrics support. + *

+ * Support for virtual threads metrics will be enabled if Micrometer support is enabled, + * this value is set to {@code true} (default), the JVM supports virtual threads (Java 21+) and the + * {@code quarkus.micrometer.binder-enabled-default} property is true. + */ + @ConfigItem + public Optional enabled; + /** + * The tags to be added to the metrics. + * Empty by default. + * When set, tags are passed as: {@code key1=value1,key2=value2}. + */ + @ConfigItem + public Optional> tags; + + @Override + public Optional getEnabled() { + return enabled; + } +}