From 4f6ad320ecfd6136bb437ae62e7211f195d84893 Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Mon, 27 Apr 2026 09:21:33 +0000 Subject: [PATCH 1/3] Add metrics for client event loops Use the Netty task queue interceptor to expose queue metrics for configured event loop groups, including groups selected by HTTP clients. Keep transport replacement bean names aligned with Micronaut's transport names and avoid wrapping EventLoopGroup beans with executor metrics. Co-Authored-By: Codex with GPT-5 --- .../ExecutorServiceMetricsBinder.java | 13 ++- ...nstrumentedEpollEventLoopGroupFactory.java | 5 +- ...InstrumentedEventLoopTaskQueueFactory.java | 90 ++++++++++--------- ...strumentedKQueueEventLoopGroupFactory.java | 5 +- .../InstrumentedNioEventLoopGroupFactory.java | 2 + ...cronautNettyQueuesMetricsBinderSpec.groovy | 27 ++++++ src/main/docs/guide/metricsConcepts.adoc | 6 +- 7 files changed, 95 insertions(+), 53 deletions(-) diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java index 7d60cb969..07fea0adf 100644 --- a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java @@ -51,6 +51,7 @@ public class ExecutorServiceMetricsBinder implements BeanCreatedEventListener { private static final String THREAD_PER_TASK_EXECUTOR = "java.util.concurrent.ThreadPerTaskExecutor"; + private static final String NETTY_EVENT_LOOP_GROUP = "io.netty.channel.EventLoopGroup"; private final BeanProvider meterRegistryProvider; @@ -70,7 +71,7 @@ public ExecutorService onCreated(BeanCreatedEvent event) { unwrapped = ((InstrumentedExecutorService) unwrapped).getTarget(); } // Netty EventLoopGroups require separate instrumentation. - if (unwrapped.getClass().getName().startsWith("io.netty")) { + if (isNettyEventLoopGroup(unwrapped.getClass()) || unwrapped.getClass().getName().startsWith("io.netty")) { return unwrapped; } // ExecutorServiceMetrics does not provide metrics for virtual threads @@ -125,4 +126,14 @@ public Runnable instrument(Runnable command) { }; } } + + private static boolean isNettyEventLoopGroup(Class type) { + for (Class interfaceType : type.getInterfaces()) { + if (NETTY_EVENT_LOOP_GROUP.equals(interfaceType.getName()) || isNettyEventLoopGroup(interfaceType)) { + return true; + } + } + Class superclass = type.getSuperclass(); + return superclass != null && isNettyEventLoopGroup(superclass); + } } diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEpollEventLoopGroupFactory.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEpollEventLoopGroupFactory.java index a1b97d827..996dd1df6 100644 --- a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEpollEventLoopGroupFactory.java +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEpollEventLoopGroupFactory.java @@ -22,7 +22,6 @@ import org.jspecify.annotations.Nullable; import io.micronaut.http.netty.channel.EpollAvailabilityCondition; import io.micronaut.http.netty.channel.EpollEventLoopGroupFactory; -import io.micronaut.http.netty.channel.EventLoopGroupFactory; import io.netty.channel.DefaultSelectStrategyFactory; import io.netty.channel.EventLoopGroup; import io.netty.channel.epoll.Epoll; @@ -47,8 +46,8 @@ */ @Singleton @Internal -@Replaces(bean = EpollEventLoopGroupFactory.class, named = EventLoopGroupFactory.NATIVE) -@Named(EventLoopGroupFactory.NATIVE) +@Replaces(bean = EpollEventLoopGroupFactory.class, named = EpollEventLoopGroupFactory.NAME) +@Named(EpollEventLoopGroupFactory.NAME) @Requires(classes = Epoll.class, condition = EpollAvailabilityCondition.class) @RequiresMetrics @Requires(property = MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled", defaultValue = FALSE, notEquals = FALSE) diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java index 22d0b8c90..6acee498f 100644 --- a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java @@ -24,12 +24,15 @@ import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Internal; import io.micronaut.http.server.netty.NettyHttpServer; +import io.micronaut.http.netty.channel.TaskQueueInterceptor; import io.netty.channel.EventLoopTaskQueueFactory; import io.netty.util.internal.PlatformDependent; import jakarta.inject.Named; import jakarta.inject.Singleton; import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.COUNT; @@ -58,65 +61,44 @@ @Requires(property = MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled", defaultValue = FALSE, notEquals = FALSE) @Requires(classes = EventLoopTaskQueueFactory.class) @Internal -final class InstrumentedEventLoopTaskQueueFactory implements EventLoopTaskQueueFactory { - - private static final AtomicInteger PARENT_COUNTER = new AtomicInteger(-1); - private static final AtomicInteger WORKER_COUNTER = new AtomicInteger(-1); +final class InstrumentedEventLoopTaskQueueFactory implements EventLoopTaskQueueFactory, TaskQueueInterceptor { private final BeanProvider meterRegistryProvider; - private final Counter parentTaskCounter; - private final Counter workerTaskCounter; - private final Timer globalParentWaitTimeTimer; - private final Timer globalParentExecutionTimer; - private final Timer globalWorkerWaitTimeTimer; - private final Timer globalWorkerExecutionTimer; + private final ConcurrentMap metrics = new ConcurrentHashMap<>(); /** * @param meterRegistryProvider the metric registry provider */ public InstrumentedEventLoopTaskQueueFactory(BeanProvider meterRegistryProvider) { this.meterRegistryProvider = meterRegistryProvider; - globalParentWaitTimeTimer = Timer.builder(dot(NETTY, QUEUE, GLOBAL, WAIT_TIME)) - .description("Global wait time spent in the parent Queues.") - .tag(GROUP, PARENT) - .publishPercentileHistogram() - .register(meterRegistryProvider.get()); - globalParentExecutionTimer = Timer.builder(dot(NETTY, QUEUE, GLOBAL, EXECUTION_TIME)) - .description("Global parent runnable execution time.") - .tag(GROUP, PARENT) - .publishPercentileHistogram() - .register(meterRegistryProvider.get()); - globalWorkerWaitTimeTimer = Timer.builder(dot(NETTY, QUEUE, GLOBAL, WAIT_TIME)) - .description("Global wait time spent in the worker Queues.") - .tag(GROUP, WORKER) - .publishPercentileHistogram() - .register(meterRegistryProvider.get()); - globalWorkerExecutionTimer = Timer.builder(dot(NETTY, QUEUE, GLOBAL, EXECUTION_TIME)) - .description("Global worker runnable execution time.") - .tag(GROUP, WORKER) - .publishPercentileHistogram() - .register(meterRegistryProvider.get()); - parentTaskCounter = Counter.builder(dot(NETTY, QUEUE, GLOBAL, ELEMENT, COUNT)) - .tag(GROUP, PARENT) - .register(meterRegistryProvider.get()); - workerTaskCounter = Counter.builder(dot(NETTY, QUEUE, GLOBAL, ELEMENT, COUNT)) - .tag(GROUP, WORKER) - .register(meterRegistryProvider.get()); + metrics.put(PARENT, EventLoopGroupMetrics.create(meterRegistryProvider.get(), PARENT)); + metrics.put(WORKER, EventLoopGroupMetrics.create(meterRegistryProvider.get(), WORKER)); } @Override public Queue newTaskQueue(int maxCapacity) { final String kind = findOrigin(); - final boolean parent = PARENT.equals(kind); - return new MonitoredQueue(parent ? PARENT_COUNTER.incrementAndGet() : WORKER_COUNTER.incrementAndGet(), - meterRegistryProvider.get(), - Tag.of(GROUP, kind), - parent ? parentTaskCounter : workerTaskCounter, - parent ? globalParentWaitTimeTimer : globalWorkerWaitTimeTimer, - parent ? globalParentExecutionTimer : globalWorkerExecutionTimer, + return newMonitoredQueue(kind, maxCapacity == Integer.MAX_VALUE ? PlatformDependent.newMpscQueue() : PlatformDependent.newMpscQueue(maxCapacity)); } + @Override + public Queue wrapTaskQueue(String groupName, Queue original) { + return newMonitoredQueue(groupName, original); + } + + private Queue newMonitoredQueue(String groupName, Queue queue) { + String name = groupName == null ? WORKER : groupName; + EventLoopGroupMetrics groupMetrics = metrics.computeIfAbsent(name, group -> EventLoopGroupMetrics.create(meterRegistryProvider.get(), group)); + return new MonitoredQueue(groupMetrics.queueCounter.incrementAndGet(), + meterRegistryProvider.get(), + Tag.of(GROUP, name), + groupMetrics.taskCounter, + groupMetrics.globalWaitTimeTimer, + groupMetrics.globalExecutionTimer, + queue); + } + private String findOrigin() { for (StackTraceElement elt: Thread.currentThread().getStackTrace()) { if (NettyHttpServer.class.getName().equals(elt.getClassName()) && "createWorkerEventLoopGroup".equals(elt.getMethodName())) { @@ -129,4 +111,26 @@ private String findOrigin() { return WORKER; } + private record EventLoopGroupMetrics(AtomicInteger queueCounter, + Counter taskCounter, + Timer globalWaitTimeTimer, + Timer globalExecutionTimer) { + static EventLoopGroupMetrics create(MeterRegistry meterRegistry, String group) { + Timer globalWaitTimeTimer = Timer.builder(dot(NETTY, QUEUE, GLOBAL, WAIT_TIME)) + .description("Global wait time spent in the event loop group queues.") + .tag(GROUP, group) + .publishPercentileHistogram() + .register(meterRegistry); + Timer globalExecutionTimer = Timer.builder(dot(NETTY, QUEUE, GLOBAL, EXECUTION_TIME)) + .description("Global runnable execution time for the event loop group.") + .tag(GROUP, group) + .publishPercentileHistogram() + .register(meterRegistry); + Counter taskCounter = Counter.builder(dot(NETTY, QUEUE, GLOBAL, ELEMENT, COUNT)) + .tag(GROUP, group) + .register(meterRegistry); + return new EventLoopGroupMetrics(new AtomicInteger(-1), taskCounter, globalWaitTimeTimer, globalExecutionTimer); + } + } + } diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedKQueueEventLoopGroupFactory.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedKQueueEventLoopGroupFactory.java index 3b5950773..e6d6bf3c3 100644 --- a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedKQueueEventLoopGroupFactory.java +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedKQueueEventLoopGroupFactory.java @@ -20,7 +20,6 @@ import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Internal; import org.jspecify.annotations.Nullable; -import io.micronaut.http.netty.channel.EventLoopGroupFactory; import io.micronaut.http.netty.channel.KQueueAvailabilityCondition; import io.micronaut.http.netty.channel.KQueueEventLoopGroupFactory; import io.netty.channel.DefaultSelectStrategyFactory; @@ -47,8 +46,8 @@ */ @Singleton @Internal -@Replaces(bean = KQueueEventLoopGroupFactory.class, named = EventLoopGroupFactory.NATIVE) -@Named(EventLoopGroupFactory.NATIVE) +@Replaces(bean = KQueueEventLoopGroupFactory.class, named = KQueueEventLoopGroupFactory.NAME) +@Named(KQueueEventLoopGroupFactory.NAME) @Requires(classes = KQueue.class, condition = KQueueAvailabilityCondition.class) @RequiresMetrics @Requires(property = MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled", defaultValue = FALSE, notEquals = FALSE) diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNioEventLoopGroupFactory.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNioEventLoopGroupFactory.java index d3a126a9b..a62f10596 100644 --- a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNioEventLoopGroupFactory.java +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNioEventLoopGroupFactory.java @@ -27,6 +27,7 @@ import io.netty.util.concurrent.DefaultEventExecutorChooserFactory; import io.netty.util.concurrent.RejectedExecutionHandlers; import io.netty.util.concurrent.ThreadPerTaskExecutor; +import jakarta.inject.Named; import jakarta.inject.Singleton; import java.nio.channels.spi.SelectorProvider; @@ -43,6 +44,7 @@ * @since 2.0 */ @Singleton +@Named(NioEventLoopGroupFactory.NAME) @Internal @Replaces(bean = NioEventLoopGroupFactory.class) @RequiresMetrics diff --git a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy index ee3f5fb06..faf5a7dc8 100644 --- a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy +++ b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy @@ -11,7 +11,9 @@ import io.micronaut.http.annotation.Get import io.micronaut.http.client.annotation.Client import io.micronaut.http.netty.channel.EventLoopGroupFactory import io.micronaut.http.netty.channel.NettyChannelType +import io.micronaut.inject.qualifiers.Qualifiers import io.micronaut.runtime.server.EmbeddedServer +import io.netty.channel.EventLoopGroup import spock.lang.Unroll import spock.lang.Ignore import spock.lang.Specification @@ -24,6 +26,7 @@ import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.GROUP import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.NETTY import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.PARENT import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.QUEUE +import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.SIZE import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.WAIT_TIME import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.WORKER import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.dot @@ -97,6 +100,30 @@ class MicronautNettyQueuesMetricsBinderSpec extends Specification { new InstrumentedKQueueEventLoopGroupFactory(null) | true } + void "test configured client event loop queue metrics are present"() { + given: + ApplicationContext context = ApplicationContext.run( + [MICRONAUT_METRICS_ENABLED : true, + (MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled"): true, + "micronaut.netty.event-loops.client.num-threads" : 1, + "micronaut.http.client.event-loop-group" : "client"] + ) + + when: + EventLoopGroup eventLoopGroup = context.getBean(EventLoopGroup, Qualifiers.byName("client")) + MeterRegistry registry = context.getBean(MeterRegistry) + + then: + eventLoopGroup + registry.get(dot(NETTY, QUEUE, SIZE)) + .tags(Tags.of(GROUP, "client")) + .gauges() + .size() > 0 + + cleanup: + context.close() + } + @Ignore void "test queue metrics are present"() { when: diff --git a/src/main/docs/guide/metricsConcepts.adoc b/src/main/docs/guide/metricsConcepts.adoc index 6d63a86c8..f4c8e8b81 100644 --- a/src/main/docs/guide/metricsConcepts.adoc +++ b/src/main/docs/guide/metricsConcepts.adoc @@ -243,11 +243,11 @@ If you use `io.micronaut.sql:micronaut-jdbc-tomcat` or `io.micronaut.sql:microna | jdbc.connections.min |======= -===== Netty Server Metrics +===== Netty Metrics -Currently, the following binders are provided to instrument Netty server: +Currently, the following binders are provided to instrument Netty: -* *EventLoopGroupFactoryBinder*: Instrument and expose event loop group queues metrics; use `micronaut.metrics.binders.netty.queues.enabled` to toggle. Default is *false*. The queues' size and tasks wait and execution time are exposed. +* *EventLoopGroupFactoryBinder*: Instrument and expose event loop group queues metrics; use `micronaut.metrics.binders.netty.queues.enabled` to toggle. Default is *false*. The queues' size and tasks wait and execution time are exposed. Metrics are tagged with the event loop group name for configured Netty event loops, including event loops selected by HTTP clients. * *ByteBufAllocatorMetricsBinder*: Expose `ByteBuf` default allocators (https://netty.io/4.1/api/io/netty/buffer/ByteBufAllocatorMetric.html[UnpooledByteBufAllocator], https://netty.io/4.1/api/io/netty/buffer/PooledByteBufAllocatorMetric.html[PooledByteBufAllocator]) metrics; use `micronaut.metrics.binders.netty.bytebuf-allocators.enabled` to toggle. Default is *false*. You can customize what metrics are exposed using `micronaut.metrics.binders.netty.bytebuf-allocators.metrics`. By default, all available metrics are exposed. These flags are supported: ** `POOLED_ALLOCATOR`: expose `PooledByteBufAllocator` metrics, From 376867eea985aa40dc3306672ec45cfff8b47c53 Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Mon, 27 Apr 2026 17:15:29 +0000 Subject: [PATCH 2/3] Improve event loop metrics binding Co-Authored-By: Codex with GPT-5 --- .../ExecutorServiceMetricsBinder.java | 13 +++++ ...InstrumentedEventLoopTaskQueueFactory.java | 22 +++++++- .../ExecutorServiceMetricsBinderSpec.groovy | 23 ++++++++ ...cronautNettyQueuesMetricsBinderSpec.groovy | 53 +++++++++++++++++++ src/main/docs/guide/metricsConcepts.adoc | 2 +- 5 files changed, 110 insertions(+), 3 deletions(-) diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java index 07fea0adf..1f65f0854 100644 --- a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinder.java @@ -33,6 +33,8 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; @@ -52,6 +54,7 @@ public class ExecutorServiceMetricsBinder implements BeanCreatedEventListener, Boolean> NETTY_EVENT_LOOP_GROUP_TYPES = new ConcurrentHashMap<>(); private final BeanProvider meterRegistryProvider; @@ -128,6 +131,16 @@ public Runnable instrument(Runnable command) { } private static boolean isNettyEventLoopGroup(Class type) { + Boolean cached = NETTY_EVENT_LOOP_GROUP_TYPES.get(type); + if (cached != null) { + return cached; + } + boolean result = hasNettyEventLoopGroup(type); + NETTY_EVENT_LOOP_GROUP_TYPES.putIfAbsent(type, result); + return result; + } + + private static boolean hasNettyEventLoopGroup(Class type) { for (Class interfaceType : type.getInterfaces()) { if (NETTY_EVENT_LOOP_GROUP.equals(interfaceType.getName()) || isNettyEventLoopGroup(interfaceType)) { return true; diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java index 6acee498f..599a071ad 100644 --- a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedEventLoopTaskQueueFactory.java @@ -20,11 +20,14 @@ import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Timer; import io.micronaut.configuration.metrics.annotation.RequiresMetrics; +import io.micronaut.context.BeanContext; import io.micronaut.context.BeanProvider; import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Internal; import io.micronaut.http.server.netty.NettyHttpServer; +import io.micronaut.http.netty.channel.EventLoopGroupConfiguration; import io.micronaut.http.netty.channel.TaskQueueInterceptor; +import io.micronaut.inject.qualifiers.Qualifiers; import io.netty.channel.EventLoopTaskQueueFactory; import io.netty.util.internal.PlatformDependent; import jakarta.inject.Named; @@ -61,16 +64,21 @@ @Requires(property = MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled", defaultValue = FALSE, notEquals = FALSE) @Requires(classes = EventLoopTaskQueueFactory.class) @Internal +@SuppressWarnings("java:S1874") final class InstrumentedEventLoopTaskQueueFactory implements EventLoopTaskQueueFactory, TaskQueueInterceptor { private final BeanProvider meterRegistryProvider; + private final BeanContext beanContext; private final ConcurrentMap metrics = new ConcurrentHashMap<>(); /** * @param meterRegistryProvider the metric registry provider + * @param beanContext the bean context */ - public InstrumentedEventLoopTaskQueueFactory(BeanProvider meterRegistryProvider) { + public InstrumentedEventLoopTaskQueueFactory(BeanProvider meterRegistryProvider, + BeanContext beanContext) { this.meterRegistryProvider = meterRegistryProvider; + this.beanContext = beanContext; metrics.put(PARENT, EventLoopGroupMetrics.create(meterRegistryProvider.get(), PARENT)); metrics.put(WORKER, EventLoopGroupMetrics.create(meterRegistryProvider.get(), WORKER)); } @@ -88,7 +96,7 @@ public Queue wrapTaskQueue(String groupName, Queue original) } private Queue newMonitoredQueue(String groupName, Queue queue) { - String name = groupName == null ? WORKER : groupName; + String name = normalizeGroupName(groupName); EventLoopGroupMetrics groupMetrics = metrics.computeIfAbsent(name, group -> EventLoopGroupMetrics.create(meterRegistryProvider.get(), group)); return new MonitoredQueue(groupMetrics.queueCounter.incrementAndGet(), meterRegistryProvider.get(), @@ -99,6 +107,16 @@ private Queue newMonitoredQueue(String groupName, Queue queu queue); } + private String normalizeGroupName(String groupName) { + if (groupName == null || groupName.isEmpty()) { + return WORKER; + } + if (PARENT.equals(groupName) || WORKER.equals(groupName) || EventLoopGroupConfiguration.DEFAULT.equals(groupName)) { + return groupName; + } + return beanContext.findBean(EventLoopGroupConfiguration.class, Qualifiers.byName(groupName)).isPresent() ? groupName : WORKER; + } + private String findOrigin() { for (StackTraceElement elt: Thread.currentThread().getStackTrace()) { if (NettyHttpServer.class.getName().equals(elt.getClassName()) && "createWorkerEventLoopGroup".equals(elt.getMethodName())) { diff --git a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinderSpec.groovy b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinderSpec.groovy index ecd856562..555978d69 100644 --- a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinderSpec.groovy +++ b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/executor/ExecutorServiceMetricsBinderSpec.groovy @@ -9,6 +9,7 @@ import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Requires import io.micronaut.inject.qualifiers.Qualifiers import io.micronaut.scheduling.TaskExecutors +import io.micronaut.scheduling.instrument.InstrumentedExecutorService import io.netty.channel.DefaultEventLoop import io.netty.channel.EventLoopGroup import jakarta.inject.Named @@ -79,6 +80,19 @@ class ExecutorServiceMetricsBinderSpec extends Specification { context.close() } + void "test event loop group outside netty package not instrumented"() { + when: + ApplicationContext context = ApplicationContext.run() + ExecutorService executorService = context.getBean(ExecutorService, Qualifiers.byName("delegating")) + + then: + executorService instanceof TestEventLoopGroup + !(executorService instanceof InstrumentedExecutorService) + + cleanup: + context.close() + } + @Issue("https://github.com/micronaut-projects/micronaut-micrometer/issues/679") void "test the virtual task executor is unbound with no warnings logged"() { when: @@ -128,6 +142,15 @@ class ExecutorServiceMetricsBinderSpec extends Specification { EventLoopGroup eventLoopGroup() { return new DefaultEventLoop() } + + @Singleton + @Named("delegating") + TestEventLoopGroup delegatingEventLoopGroup() { + return new TestEventLoopGroup() + } + } + + static class TestEventLoopGroup extends DefaultEventLoop { } class SimpleStreamsListener implements IStandardStreamsListener { diff --git a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy index faf5a7dc8..aa66e91ec 100644 --- a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy +++ b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy @@ -9,8 +9,11 @@ import io.micronaut.context.ApplicationContext import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Get import io.micronaut.http.client.annotation.Client +import io.micronaut.http.netty.channel.EpollEventLoopGroupFactory import io.micronaut.http.netty.channel.EventLoopGroupFactory +import io.micronaut.http.netty.channel.KQueueEventLoopGroupFactory import io.micronaut.http.netty.channel.NettyChannelType +import io.micronaut.http.netty.channel.NioEventLoopGroupFactory import io.micronaut.inject.qualifiers.Qualifiers import io.micronaut.runtime.server.EmbeddedServer import io.netty.channel.EventLoopGroup @@ -18,6 +21,8 @@ import spock.lang.Unroll import spock.lang.Ignore import spock.lang.Specification +import java.util.concurrent.ConcurrentLinkedQueue + import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.COUNT import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.ELEMENT import static io.micronaut.configuration.metrics.binder.netty.NettyMetrics.EXECUTION_TIME @@ -62,6 +67,28 @@ class MicronautNettyQueuesMetricsBinderSpec extends Specification { MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled" | false | false } + @Unroll + void "test instrumented event loop group factory replaces #factoryName factory by name"() { + when: + ApplicationContext context = ApplicationContext.run( + [(MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled"): true] + ) + + then: + context.findBean(instrumentedClass).isPresent() == context.findBean(EventLoopGroupFactory, Qualifiers.byName(factoryName)) + .map { instrumentedClass.isInstance(it) } + .orElse(false) + + cleanup: + context.close() + + where: + instrumentedClass | factoryName + InstrumentedNioEventLoopGroupFactory | NioEventLoopGroupFactory.NAME + InstrumentedEpollEventLoopGroupFactory | EpollEventLoopGroupFactory.NAME + InstrumentedKQueueEventLoopGroupFactory | KQueueEventLoopGroupFactory.NAME + } + @Unroll("test getting #channelType channel class from #eventLoopGroupFactory.getClass().getSimpleName()") void "test getting channel class from instrumented event loop group factory"( NettyChannelType channelType, @@ -124,6 +151,32 @@ class MicronautNettyQueuesMetricsBinderSpec extends Specification { context.close() } + void "test unexpected event loop queue group names use worker metrics"() { + given: + ApplicationContext context = ApplicationContext.run( + [MICRONAUT_METRICS_ENABLED : true, + (MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled"): true] + ) + + when: + InstrumentedEventLoopTaskQueueFactory factory = context.getBean(InstrumentedEventLoopTaskQueueFactory) + MeterRegistry registry = context.getBean(MeterRegistry) + factory.wrapTaskQueue("unexpected", new ConcurrentLinkedQueue()) + + then: + registry.find(dot(NETTY, QUEUE, SIZE)) + .tags(Tags.of(GROUP, "unexpected")) + .gauges() + .isEmpty() + registry.get(dot(NETTY, QUEUE, SIZE)) + .tags(Tags.of(GROUP, WORKER)) + .gauges() + .size() == 1 + + cleanup: + context.close() + } + @Ignore void "test queue metrics are present"() { when: diff --git a/src/main/docs/guide/metricsConcepts.adoc b/src/main/docs/guide/metricsConcepts.adoc index f4c8e8b81..3fa9e3fe6 100644 --- a/src/main/docs/guide/metricsConcepts.adoc +++ b/src/main/docs/guide/metricsConcepts.adoc @@ -247,7 +247,7 @@ If you use `io.micronaut.sql:micronaut-jdbc-tomcat` or `io.micronaut.sql:microna Currently, the following binders are provided to instrument Netty: -* *EventLoopGroupFactoryBinder*: Instrument and expose event loop group queues metrics; use `micronaut.metrics.binders.netty.queues.enabled` to toggle. Default is *false*. The queues' size and tasks wait and execution time are exposed. Metrics are tagged with the event loop group name for configured Netty event loops, including event loops selected by HTTP clients. +* *EventLoopGroupFactoryBinder*: Instrument and expose event loop group queues metrics; use `micronaut.metrics.binders.netty.queues.enabled` to toggle. Default is *false*. The queues' size and tasks wait and execution time are exposed. Metrics are tagged with the event loop group name for configured Netty server and client event loops. * *ByteBufAllocatorMetricsBinder*: Expose `ByteBuf` default allocators (https://netty.io/4.1/api/io/netty/buffer/ByteBufAllocatorMetric.html[UnpooledByteBufAllocator], https://netty.io/4.1/api/io/netty/buffer/PooledByteBufAllocatorMetric.html[PooledByteBufAllocator]) metrics; use `micronaut.metrics.binders.netty.bytebuf-allocators.enabled` to toggle. Default is *false*. You can customize what metrics are exposed using `micronaut.metrics.binders.netty.bytebuf-allocators.metrics`. By default, all available metrics are exposed. These flags are supported: ** `POOLED_ALLOCATOR`: expose `PooledByteBufAllocator` metrics, From b2e153e0574ccbad2029cc8f9a327b10f1115e2a Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Mon, 27 Apr 2026 17:30:12 +0000 Subject: [PATCH 3/3] Preserve native event loop factory qualifier Co-Authored-By: Codex with GPT-5 --- ...tedNativeEventLoopGroupFactoryAliases.java | 47 +++++++++++++++++++ ...cronautNettyQueuesMetricsBinderSpec.groovy | 17 +++++++ 2 files changed, 64 insertions(+) create mode 100644 micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNativeEventLoopGroupFactoryAliases.java diff --git a/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNativeEventLoopGroupFactoryAliases.java b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNativeEventLoopGroupFactoryAliases.java new file mode 100644 index 000000000..748bcc2d8 --- /dev/null +++ b/micrometer-core/src/main/java/io/micronaut/configuration/metrics/binder/netty/InstrumentedNativeEventLoopGroupFactoryAliases.java @@ -0,0 +1,47 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.metrics.binder.netty; + +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.http.netty.channel.EventLoopGroupFactory; +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +/** + * Backwards-compatible native event loop group factory aliases. + * + * @since 6.0.0 + */ +@Factory +@Internal +final class InstrumentedNativeEventLoopGroupFactoryAliases { + + @Singleton + @Named(EventLoopGroupFactory.NATIVE) + @Requires(beans = InstrumentedEpollEventLoopGroupFactory.class) + EventLoopGroupFactory epollNativeEventLoopGroupFactory(InstrumentedEpollEventLoopGroupFactory factory) { + return factory; + } + + @Singleton + @Named(EventLoopGroupFactory.NATIVE) + @Requires(beans = InstrumentedKQueueEventLoopGroupFactory.class) + EventLoopGroupFactory kQueueNativeEventLoopGroupFactory(InstrumentedKQueueEventLoopGroupFactory factory) { + return factory; + } +} diff --git a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy index aa66e91ec..8b92e08ff 100644 --- a/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy +++ b/micrometer-core/src/test/groovy/io/micronaut/configuration/metrics/binder/netty/MicronautNettyQueuesMetricsBinderSpec.groovy @@ -89,6 +89,23 @@ class MicronautNettyQueuesMetricsBinderSpec extends Specification { InstrumentedKQueueEventLoopGroupFactory | KQueueEventLoopGroupFactory.NAME } + void "test instrumented native event loop group factory is available by legacy name"() { + when: + ApplicationContext context = ApplicationContext.run( + [(MICRONAUT_METRICS_BINDERS + ".netty.queues.enabled"): true] + ) + Optional nativeFactory = context.findBean(EventLoopGroupFactory, Qualifiers.byName(EventLoopGroupFactory.NATIVE)) + List availableNativeFactories = [InstrumentedEpollEventLoopGroupFactory, InstrumentedKQueueEventLoopGroupFactory] + .findAll { context.findBean(it).isPresent() } + + then: + nativeFactory.isPresent() == !availableNativeFactories.isEmpty() + !nativeFactory.isPresent() || availableNativeFactories.any { it.isInstance(nativeFactory.get()) } + + cleanup: + context.close() + } + @Unroll("test getting #channelType channel class from #eventLoopGroupFactory.getClass().getSimpleName()") void "test getting channel class from instrumented event loop group factory"( NettyChannelType channelType,