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;
+ }
+}
diff --git a/integration-tests/virtual-threads/metrics-virtual-threads/pom.xml b/integration-tests/virtual-threads/metrics-virtual-threads/pom.xml
new file mode 100644
index 0000000000000..14d522bdd9405
--- /dev/null
+++ b/integration-tests/virtual-threads/metrics-virtual-threads/pom.xml
@@ -0,0 +1,119 @@
+
+
+ 4.0.0
+
+
+ quarkus-virtual-threads-integration-tests-parent
+ io.quarkus
+ 999-SNAPSHOT
+
+
+ quarkus-integration-test-virtual-threads-micrometer
+ Quarkus - Integration Tests - Virtual Threads - Micrometer Metrics
+
+
+
+ io.quarkus
+ quarkus-reactive-routes
+
+
+ io.micrometer
+ micrometer-java21
+
+
+ io.quarkus
+ quarkus-micrometer
+
+
+ io.quarkus
+ quarkus-micrometer-registry-prometheus
+
+
+
+ io.quarkus
+ quarkus-test-vertx
+
+
+ io.quarkus
+ quarkus-junit5
+ test
+
+
+ io.quarkus.junit5
+ junit5-virtual-threads
+ test
+
+
+ io.rest-assured
+ rest-assured
+ test
+
+
+ org.awaitility
+ awaitility
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+ io.quarkus
+ quarkus-reactive-routes-deployment
+ ${project.version}
+ pom
+ test
+
+
+ *
+ *
+
+
+
+
+ io.quarkus
+ quarkus-micrometer-deployment
+ ${project.version}
+ pom
+ test
+
+
+ *
+ *
+
+
+
+
+ io.quarkus
+ quarkus-micrometer-registry-prometheus-deployment
+ ${project.version}
+ pom
+ test
+
+
+ *
+ *
+
+
+
+
+
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+
+
+
diff --git a/integration-tests/virtual-threads/metrics-virtual-threads/src/main/java/io/quarkus/virtual/vertx/web/Routes.java b/integration-tests/virtual-threads/metrics-virtual-threads/src/main/java/io/quarkus/virtual/vertx/web/Routes.java
new file mode 100644
index 0000000000000..e85a3eaa39f55
--- /dev/null
+++ b/integration-tests/virtual-threads/metrics-virtual-threads/src/main/java/io/quarkus/virtual/vertx/web/Routes.java
@@ -0,0 +1,46 @@
+package io.quarkus.virtual.vertx.web;
+
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+
+import io.quarkus.micrometer.runtime.binder.virtualthreads.VirtualThreadCollector;
+import io.quarkus.test.vertx.VirtualThreadsAssertions;
+import io.quarkus.vertx.web.Route;
+import io.smallrye.common.annotation.Blocking;
+import io.smallrye.common.annotation.RunOnVirtualThread;
+
+public class Routes {
+
+ @Inject
+ Instance collector;
+
+ void assertThatTheBinderIsAvailable() {
+ if (!collector.isResolvable()) {
+ throw new AssertionError("VirtualThreadCollector expected");
+ }
+ }
+
+ @RunOnVirtualThread
+ @Route
+ String hello() {
+ assertThatTheBinderIsAvailable();
+ VirtualThreadsAssertions.assertEverything();
+ // Quarkus specific - each VT has a unique name
+ return Thread.currentThread().getName();
+ }
+
+ @Route
+ String ping() {
+ assertThatTheBinderIsAvailable();
+ VirtualThreadsAssertions.assertWorkerOrEventLoopThread();
+ return "pong";
+ }
+
+ @Blocking
+ @Route
+ String blockingPing() {
+ assertThatTheBinderIsAvailable();
+ return ping();
+ }
+
+}
diff --git a/integration-tests/virtual-threads/metrics-virtual-threads/src/test/java/io/quarkus/virtual/vertx/web/RunOnVirtualThreadIT.java b/integration-tests/virtual-threads/metrics-virtual-threads/src/test/java/io/quarkus/virtual/vertx/web/RunOnVirtualThreadIT.java
new file mode 100644
index 0000000000000..609672a7779ef
--- /dev/null
+++ b/integration-tests/virtual-threads/metrics-virtual-threads/src/test/java/io/quarkus/virtual/vertx/web/RunOnVirtualThreadIT.java
@@ -0,0 +1,8 @@
+package io.quarkus.virtual.vertx.web;
+
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+
+@QuarkusIntegrationTest
+class RunOnVirtualThreadIT extends RunOnVirtualThreadTest {
+
+}
diff --git a/integration-tests/virtual-threads/metrics-virtual-threads/src/test/java/io/quarkus/virtual/vertx/web/RunOnVirtualThreadTest.java b/integration-tests/virtual-threads/metrics-virtual-threads/src/test/java/io/quarkus/virtual/vertx/web/RunOnVirtualThreadTest.java
new file mode 100644
index 0000000000000..9175d7536ca71
--- /dev/null
+++ b/integration-tests/virtual-threads/metrics-virtual-threads/src/test/java/io/quarkus/virtual/vertx/web/RunOnVirtualThreadTest.java
@@ -0,0 +1,35 @@
+package io.quarkus.virtual.vertx.web;
+
+import static io.restassured.RestAssured.get;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit5.virtual.ShouldNotPin;
+import io.quarkus.test.junit5.virtual.VirtualThreadUnit;
+
+@QuarkusTest
+@VirtualThreadUnit
+@ShouldNotPin
+class RunOnVirtualThreadTest {
+
+ @Test
+ void testRouteOnVirtualThread() {
+ String bodyStr = get("/hello").then().statusCode(200).extract().asString();
+ // Each VT has a unique name in quarkus
+ assertNotEquals(bodyStr, get("/hello").then().statusCode(200).extract().asString());
+ }
+
+ @Test
+ void testRouteOnEventLoop() {
+ assertEquals("pong", get("/ping").then().statusCode(200).extract().asString());
+ }
+
+ @Test
+ void testRouteOnWorker() {
+ assertEquals("pong", get("/blocking-ping").then().statusCode(200).extract().asString());
+ }
+
+}
diff --git a/integration-tests/virtual-threads/pom.xml b/integration-tests/virtual-threads/pom.xml
index a7dada855dcaf..58f6e57f31a45 100644
--- a/integration-tests/virtual-threads/pom.xml
+++ b/integration-tests/virtual-threads/pom.xml
@@ -37,6 +37,7 @@
virtual-threads-disabled
reactive-routes-virtual-threads
security-webauthn-virtual-threads
+ metrics-virtual-threads