This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 533096bd43 feat(logging): expose queue admission and capacity metrics 
(#7833)
533096bd43 is described below

commit 533096bd43621c5ef4194193da0363aea1c8ffcc
Author: Duansg <[email protected]>
AuthorDate: Sun Oct 18 16:35:41 2026 -0700

    feat(logging): expose queue admission and capacity metrics (#7833)
---
 .../shenyu/common/metrics/LoggingQueueMetrics.java | 163 ++++++++++
 .../common/metrics/LoggingQueueMetricsTest.java    |  47 +++
 .../shenyu-plugin-logging-common/README.md         |  62 ++++
 .../common/collector/AbstractLogCollector.java     |  33 +-
 .../common/collector/LogCollectorMetricsTest.java  | 341 +++++++++++++++++++++
 .../shenyu/plugin/metrics/constant/LabelNames.java |  12 +
 .../prometheus/LoggingQueueMetricsCollector.java   |  66 ++++
 .../prometheus/PrometheusMetricsService.java       |   1 +
 .../LoggingQueueMetricsCollectorTest.java          |  97 ++++++
 .../prometheus/PrometheusMetricsServiceTest.java   |  24 ++
 10 files changed, 841 insertions(+), 5 deletions(-)

diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/metrics/LoggingQueueMetrics.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/metrics/LoggingQueueMetrics.java
new file mode 100644
index 0000000000..1f750396a4
--- /dev/null
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/metrics/LoggingQueueMetrics.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *     http://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 org.apache.shenyu.common.metrics;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.LongAdder;
+import java.util.function.LongSupplier;
+
+/**
+ * Admission counters and live queue measurements, independent of the metrics 
backend.
+ * Only collector implementation classes and the two queue modes identify a 
series.
+ * Suppliers are evaluated when metrics are scraped, never when a record is 
admitted.
+ */
+public final class LoggingQueueMetrics {
+
+    private static final Map<String, LoggingQueueMetrics> REGISTERED = new 
ConcurrentHashMap<>();
+
+    private final String collector;
+
+    private final Mode mode;
+
+    private final LongSupplier queuedRecords;
+
+    private final LongSupplier capacity;
+
+    private final LongAdder accepted = new LongAdder();
+
+    private final LongAdder dropped = new LongAdder();
+
+    /**
+     * Create measurements for one collector and queue mode.
+     *
+     * @param collectorType implementation class, never a request or selector 
identifier
+     * @param mode queue mode
+     * @param queuedRecords current number of queued records
+     * @param capacity current admission capacity, aggregated across selector 
queues
+     */
+    public LoggingQueueMetrics(final Class<?> collectorType, final Mode mode,
+                               final LongSupplier queuedRecords, final 
LongSupplier capacity) {
+        this.collector = collectorType.getName();
+        this.mode = mode;
+        this.queuedRecords = queuedRecords;
+        this.capacity = capacity;
+    }
+
+    /**
+     * Publish measurements after the collector starts.
+     */
+    public void register() {
+        REGISTERED.put(collector + mode.name(), this);
+    }
+
+    /**
+     * Remove only this collector's measurements when it stops.
+     */
+    public void unregister() {
+        REGISTERED.remove(collector + mode.name(), this);
+    }
+
+    /**
+     * Get the currently running collectors, including collectors started 
before metrics.
+     *
+     * @return snapshot of registered measurements
+     */
+    public static Collection<LoggingQueueMetrics> registered() {
+        return List.copyOf(REGISTERED.values());
+    }
+
+    /**
+     * Record an attempted admission. Call only after lifecycle and client 
checks.
+     *
+     * @param admitted whether the record was queued; false means an admission 
rejection
+     */
+    public void recordAdmission(final boolean admitted) {
+        if (admitted) {
+            accepted.increment();
+        } else {
+            dropped.increment();
+        }
+    }
+
+    /**
+     * Get the collector implementation name.
+     *
+     * @return bounded collector label
+     */
+    public String getCollector() {
+        return collector;
+    }
+
+    /**
+     * Get the queue mode.
+     *
+     * @return queue mode
+     */
+    public Mode getMode() {
+        return mode;
+    }
+
+    /**
+     * Get admissions over this collector instance's lifetime, including 
restarts.
+     *
+     * @return admitted records, not successful backend sends
+     */
+    public long getAccepted() {
+        return accepted.sum();
+    }
+
+    /**
+     * Get queue admission rejections, excluding sampling and backend send 
failures.
+     *
+     * @return rejected records
+     */
+    public long getDropped() {
+        return dropped.sum();
+    }
+
+    /**
+     * Get the current backlog, excluding batches already drained for sending.
+     *
+     * @return queued records
+     */
+    public long getQueuedRecords() {
+        return queuedRecords.getAsLong();
+    }
+
+    /**
+     * Get effective admission capacity; a backlog retained after shrinking 
may exceed it.
+     *
+     * @return capacity in records
+     */
+    public long getCapacity() {
+        return capacity.getAsLong();
+    }
+
+    /**
+     * Fixed queue modes, independent of request and selector data.
+     */
+    public enum Mode {
+        /** Shared queue for a single client. */
+        SINGLE,
+        /** Aggregated queues for selector-specific clients. */
+        MULTI
+    }
+}
diff --git 
a/shenyu-common/src/test/java/org/apache/shenyu/common/metrics/LoggingQueueMetricsTest.java
 
b/shenyu-common/src/test/java/org/apache/shenyu/common/metrics/LoggingQueueMetricsTest.java
new file mode 100644
index 0000000000..9d96c576d8
--- /dev/null
+++ 
b/shenyu-common/src/test/java/org/apache/shenyu/common/metrics/LoggingQueueMetricsTest.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *     http://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 org.apache.shenyu.common.metrics;
+
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics.Mode;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verify replacement collectors cannot be removed by an older instance 
closing.
+ */
+class LoggingQueueMetricsTest {
+
+    @Test
+    void unregisterOnlyRemovesTheOwningCollector() {
+        final LoggingQueueMetrics previous = new 
LoggingQueueMetrics(getClass(), Mode.SINGLE, () -> 0, () -> 1);
+        final LoggingQueueMetrics current = new 
LoggingQueueMetrics(getClass(), Mode.SINGLE, () -> 0, () -> 2);
+        try {
+            previous.register();
+            current.register();
+            previous.unregister();
+            assertFalse(LoggingQueueMetrics.registered().contains(previous));
+            assertTrue(LoggingQueueMetrics.registered().contains(current));
+        } finally {
+            previous.unregister();
+            current.unregister();
+        }
+        assertFalse(LoggingQueueMetrics.registered().contains(current));
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/README.md 
b/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/README.md
new file mode 100644
index 0000000000..5a6cf01909
--- /dev/null
+++ b/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/README.md
@@ -0,0 +1,62 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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
+
+    http://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.
+-->
+
+# Logging queue metrics
+
+When ShenYu's metrics starter is enabled (`shenyu.metrics.enabled=true`),
+the existing Prometheus endpoint exposes these metrics for running collectors
+that extend `AbstractLogCollector` (Kafka, Elasticsearch and RocketMQ):
+
+| Metric | Type | Meaning |
+| --- | --- | --- |
+| `shenyu_logging_queue_accepted_total` | Counter | Records successfully 
admitted to a logging queue. This is not a backend delivery count. |
+| `shenyu_logging_queue_dropped_total` | Counter | Records rejected at queue 
admission because the queue has reached its capacity. |
+| `shenyu_logging_queue_size` | Gauge | Records currently in the queues, 
excluding batches already drained for sending. |
+| `shenyu_logging_queue_capacity` | Gauge | Effective admission capacity in 
records, summed across the queues in the indicated mode. |
+
+The only labels are `collector` (the fully qualified collector implementation
+class) and `mode` (`single` or `multi`). Selector IDs, paths, request IDs and
+backend addresses are never labels. In multi-client mode, measurements 
aggregate
+all selector queues for the collector. Consequently, an individual selector
+queue can overflow even when the aggregate occupancy is below aggregate 
capacity;
+use the dropped counter to alert on any admission loss.
+
+For single-client mode, capacity is the live configured `bufferQueueSize`, not
+the physical capacity of the underlying unbounded deque. After shrinking the
+configuration, occupancy may temporarily exceed capacity while the existing
+backlog drains. For multi-client mode, capacity is the sum of the actual bounds
+chosen when each selector queue was created. Existing queues retain their 
bounds
+after configuration changes; new queues use the latest configuration. Before 
the
+first selector queue is created, aggregate capacity is zero. Inactive modes 
have
+zero admission capacity but still expose any backlog retained after a mode 
switch.
+
+Only attempted admissions are counted. Rule sampling, null logs, collectors 
that
+have not started or have stopped, and missing clients do not increase the 
dropped
+counter. Backend send failures are also excluded: a record already accepted can
+still fail during delivery.
+
+Counters are retained across stop/start cycles of the same collector instance.
+Stopped collectors are removed from the exported series, and a new collector
+instance or gateway process starts with new counters. Metrics work regardless 
of
+whether the logging collector or the metrics service starts first. Gauges read
+live queue state at scrape time, so idle collectors do not retain stale 
occupancy
+values after draining. Concurrent scrapes are observations rather than atomic
+snapshots across all queues and counters.
+
+Admission uses the existing non-waiting capacity checks and `offer` behavior.
+Instrumentation adds only thread-safe counters to the request path; it adds no
+worker, auxiliary record queue or backend metrics dependency to logging 
plugins.
diff --git 
a/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/main/java/org/apache/shenyu/plugin/logging/common/collector/AbstractLogCollector.java
 
b/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/main/java/org/apache/shenyu/plugin/logging/common/collector/AbstractLogCollector.java
index 3576996a76..77a0b9880e 100644
--- 
a/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/main/java/org/apache/shenyu/plugin/logging/common/collector/AbstractLogCollector.java
+++ 
b/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/main/java/org/apache/shenyu/plugin/logging/common/collector/AbstractLogCollector.java
@@ -23,6 +23,8 @@ import 
org.apache.shenyu.common.concurrent.ShenyuThreadFactory;
 import org.apache.shenyu.common.concurrent.ShenyuThreadPoolExecutor;
 import org.apache.shenyu.common.config.ShenyuConfig;
 import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics.Mode;
 import org.apache.shenyu.common.utils.Singleton;
 import org.apache.shenyu.common.utils.ThreadUtils;
 import org.apache.shenyu.plugin.logging.common.client.AbstractLogConsumeClient;
@@ -44,6 +46,7 @@ import java.util.concurrent.LinkedBlockingDeque;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
 
 import static 
org.apache.shenyu.plugin.logging.desensitize.api.utils.DataDesensitizeUtils.desensitizeForBody;
 import static 
org.apache.shenyu.plugin.logging.desensitize.api.utils.DataDesensitizeUtils.desensitizeForSingleWord;
@@ -69,6 +72,16 @@ public abstract class AbstractLogCollector<T extends 
AbstractLogConsumeClient<?,
 
     private final AtomicBoolean started = new AtomicBoolean(false);
 
+    private final AtomicLong multiBufferCapacity = new AtomicLong();
+
+    private final LoggingQueueMetrics singleClientMetrics = new 
LoggingQueueMetrics(getClass(), Mode.SINGLE,
+            () -> started.get() ? bufferQueue.size() : 0,
+            () -> isActiveMode(false) ? bufferSize : 0);
+
+    private final LoggingQueueMetrics multiClientMetrics = new 
LoggingQueueMetrics(getClass(), Mode.MULTI,
+            () -> started.get() ? 
bufferQueueS.values().stream().mapToLong(BlockingQueue::size).sum() : 0,
+            () -> isActiveMode(true) ? multiBufferCapacity.get() : 0);
+
     private ShenyuThreadPoolExecutor executor;
 
     @Override
@@ -95,6 +108,12 @@ public abstract class AbstractLogCollector<T extends 
AbstractLogConsumeClient<?,
             threadExecutor.shutdownNow();
             throw e;
         }
+        singleClientMetrics.register();
+        multiClientMetrics.register();
+    }
+
+    private boolean isActiveMode(final boolean multiClient) {
+        return started.get() && getMultiClient() == multiClient;
     }
 
     private void refreshBufferSize() {
@@ -113,15 +132,15 @@ public abstract class AbstractLogCollector<T extends 
AbstractLogConsumeClient<?,
         if (getMultiClient()) {
             String selectorId = log.getSelectorId();
             BlockingQueue<L> bufferQueue = 
bufferQueueS.computeIfAbsent(selectorId, bufferQueueS -> initQueue(selectorId));
-            bufferQueue.offer(log);
+            multiClientMetrics.recordAdmission(bufferQueue.offer(log));
         } else {
             BlockingQueue<L> queue = bufferQueue;
+            boolean admitted;
             synchronized (queue) {
                 // On shrink, retain the backlog but reject new logs until it 
falls below the new limit.
-                if (queue.size() < bufferSize) {
-                    queue.offer(log);
-                }
+                admitted = queue.size() < bufferSize && queue.offer(log);
             }
+            singleClientMetrics.recordAdmission(admitted);
         }
     }
 
@@ -165,8 +184,10 @@ public abstract class AbstractLogCollector<T extends 
AbstractLogConsumeClient<?,
     }
 
     private BlockingQueue<L> initQueue(final String selectorId) {
-        BlockingQueue<L> queue = new 
LinkedBlockingDeque<>(getLogCollectConfig().getBufferQueueSize());
+        int capacity = getLogCollectConfig().getBufferQueueSize();
+        BlockingQueue<L> queue = new LinkedBlockingDeque<>(capacity);
         lastPushTimeS.put(selectorId, System.currentTimeMillis());
+        multiBufferCapacity.addAndGet(capacity);
         return queue;
     }
 
@@ -320,6 +341,8 @@ public abstract class AbstractLogCollector<T extends 
AbstractLogConsumeClient<?,
         try {
             flushBufferQueues();
         } finally {
+            singleClientMetrics.unregister();
+            multiClientMetrics.unregister();
             if (Objects.nonNull(logCollectClient)) {
                 logCollectClient.close();
             }
diff --git 
a/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/test/java/org/apache/shenyu/plugin/logging/common/collector/LogCollectorMetricsTest.java
 
b/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/test/java/org/apache/shenyu/plugin/logging/common/collector/LogCollectorMetricsTest.java
new file mode 100644
index 0000000000..bf0b9f1654
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-logging/shenyu-plugin-logging-common/src/test/java/org/apache/shenyu/plugin/logging/common/collector/LogCollectorMetricsTest.java
@@ -0,0 +1,341 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *     http://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 org.apache.shenyu.plugin.logging.common.collector;
+
+import org.apache.shenyu.common.concurrent.ShenyuThreadPoolExecutor;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics.Mode;
+import org.apache.shenyu.plugin.logging.common.AbstractLoggingPlugin;
+import org.apache.shenyu.plugin.logging.common.client.AbstractLogConsumeClient;
+import org.apache.shenyu.plugin.logging.common.config.GenericApiConfig;
+import org.apache.shenyu.plugin.logging.common.config.GenericGlobalConfig;
+import org.apache.shenyu.plugin.logging.common.entity.ShenyuRequestLog;
+import 
org.apache.shenyu.plugin.logging.common.handler.AbstractLogPluginDataHandler;
+import org.apache.shenyu.plugin.logging.common.sampler.Sampler;
+import org.apache.shenyu.plugin.logging.desensitize.api.matcher.KeyWordMatch;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedConstruction;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.BlockingQueue;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Verify admission accounting against real queues with the consumer held idle.
+ */
+class LogCollectorMetricsTest {
+
+    private TestCollector collector;
+
+    private MockedConstruction<ShenyuThreadPoolExecutor> executors;
+
+    @BeforeEach
+    void setUp() {
+        executors = mockConstruction(ShenyuThreadPoolExecutor.class);
+        collector = new TestCollector();
+        collector.config.setBufferQueueSize(1);
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        try {
+            reset(collector.client);
+            collector.close();
+        } finally {
+            executors.close();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void countsOnlyQueueAdmissionRejections(final boolean multi) throws 
Exception {
+        collector.multi = multi;
+        collector.collect(log("selector"));
+        collector.start();
+        final LoggingQueueMetrics metrics = metrics(multi);
+        assertEquals(0, metrics.getDropped());
+        collector.collect(log("selector"));
+        collector.collect(log("selector"));
+        assertEquals(1, metrics.getAccepted());
+        assertEquals(1, metrics.getDropped());
+        assertEquals(1, metrics.getQueuedRecords());
+        assertEquals(1, metrics.getCapacity());
+        collector.collect(null);
+        collector.clientAvailable = false;
+        collector.collect(log("selector"));
+        assertEquals(1, metrics.getAccepted());
+        assertEquals(1, metrics.getDropped());
+        collector.clientAvailable = true;
+        collector.close();
+        collector.collect(log("selector"));
+        assertEquals(1, metrics.getDropped());
+        assertEquals(0, metrics.getQueuedRecords());
+        assertEquals(0, metrics.getCapacity());
+        assertFalse(LoggingQueueMetrics.registered().contains(metrics));
+        verify(collector.client).consume(anyList());
+    }
+
+    @Test
+    void reflectsLiveSingleClientCapacityWithoutDiscardingBacklog() {
+        collector.start();
+        final LoggingQueueMetrics metrics = metrics(false);
+        collector.collect(log("first"));
+        collector.config.setBufferQueueSize(3);
+        collector.start();
+        collector.collect(log("second"));
+        collector.collect(log("third"));
+        assertEquals(3, metrics.getCapacity());
+        assertEquals(3, metrics.getQueuedRecords());
+        collector.config.setBufferQueueSize(1);
+        collector.start();
+        collector.collect(log("fourth"));
+        assertEquals(3, metrics.getAccepted());
+        assertEquals(1, metrics.getDropped());
+        assertEquals(1, metrics.getCapacity());
+        assertEquals(3, metrics.getQueuedRecords());
+        BlockingQueue<?> queue = (BlockingQueue<?>) 
ReflectionTestUtils.getField(collector, "bufferQueue");
+        queue.clear();
+        assertEquals(0, metrics.getQueuedRecords());
+        collector.collect(log("fifth"));
+        assertEquals(4, metrics.getAccepted());
+        assertEquals(1, metrics.getQueuedRecords());
+    }
+
+    @Test
+    void aggregatesSelectorQueuesUsingTheirActualCapacities() {
+        collector.multi = true;
+        collector.start();
+        final LoggingQueueMetrics metrics = metrics(true);
+        assertEquals(0, metrics.getCapacity());
+        collector.collect(log("first"));
+        collector.config.setBufferQueueSize(2);
+        collector.start();
+        collector.collect(log("second"));
+        collector.collect(log("second"));
+        collector.collect(log("first"));
+        collector.collect(log("second"));
+        assertEquals(3, metrics.getAccepted());
+        assertEquals(2, metrics.getDropped());
+        assertEquals(3, metrics.getQueuedRecords());
+        assertEquals(3, metrics.getCapacity());
+        assertEquals(0, metrics(false).getCapacity());
+        collector.processMultiClientBufferQueues(1, 100);
+        assertEquals(1, metrics.getQueuedRecords());
+        assertEquals(3, metrics.getCapacity());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void accountsForConcurrentProducers(final boolean multi) {
+        collector.multi = multi;
+        collector.config.setBufferQueueSize(10);
+        collector.start();
+        IntStream.range(0, 1000).parallel().forEach(index -> 
collector.collect(log("selector")));
+        final LoggingQueueMetrics metrics = metrics(multi);
+        assertEquals(10, metrics.getAccepted());
+        assertEquals(990, metrics.getDropped());
+        assertEquals(10, metrics.getQueuedRecords());
+        assertEquals(10, metrics.getCapacity());
+    }
+
+    @Test
+    void backendFailuresAreNotAdmissionDrops() throws Exception {
+        collector.multi = true;
+        collector.start();
+        collector.collect(log("selector"));
+        doThrow(new Exception("backend 
unavailable")).when(collector.client).consume(anyList());
+        collector.processMultiClientBufferQueues(1, 100);
+        final LoggingQueueMetrics metrics = metrics(true);
+        assertEquals(1, metrics.getAccepted());
+        assertEquals(0, metrics.getDropped());
+        assertEquals(0, metrics.getQueuedRecords());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void consumingUpdatesOccupancyWithoutChangingAdmissionCounters(final 
boolean multi) {
+        collector.multi = multi;
+        collector.config.setBufferQueueSize(2);
+        collector.start();
+        collector.collect(log("selector"));
+        collector.collect(log("selector"));
+        final LoggingQueueMetrics metrics = metrics(multi);
+        if (multi) {
+            collector.processMultiClientBufferQueues(1, 100);
+        } else {
+            BlockingQueue<?> queue = (BlockingQueue<?>) 
ReflectionTestUtils.getField(collector, "bufferQueue");
+            ReflectionTestUtils.invokeMethod(collector, "processBufferQueue", 
queue, 1, 100, new ArrayList<>(), 0L);
+        }
+        assertEquals(1, metrics.getQueuedRecords());
+        assertEquals(2, metrics.getCapacity());
+        assertEquals(2, metrics.getAccepted());
+        assertEquals(0, metrics.getDropped());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void samplingDoesNotCountAsQueueOverflow(final boolean multi) {
+        collector.multi = multi;
+        collector.start();
+        collector.collect(log("sampled-out-selector"));
+        GenericApiConfig apiConfig = new GenericApiConfig();
+        apiConfig.setSampler(Sampler.NEVER_SAMPLE);
+        SelectorData selector = new SelectorData();
+        selector.setId("sampled-out-selector");
+        RuleData rule = new RuleData();
+        rule.setId("sampled-out-rule");
+        rule.setSelectorId(selector.getId());
+        
AbstractLogPluginDataHandler.getSelectApiConfigMap().put(selector.getId(), 
apiConfig);
+        try {
+            AtomicBoolean chainExecuted = new AtomicBoolean();
+            
loggingPlugin().doExecute(MockServerWebExchange.from(MockServerHttpRequest.get("/sampled-out").build()),
 exchange -> {
+                chainExecuted.set(true);
+                return Mono.empty();
+            }, selector, rule).block();
+            assertTrue(chainExecuted.get());
+            assertEquals(1, metrics(multi).getQueuedRecords());
+            assertEquals(1, metrics(multi).getAccepted());
+            assertEquals(0, metrics(multi).getDropped());
+        } finally {
+            
AbstractLogPluginDataHandler.getSelectApiConfigMap().remove(selector.getId());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void modeChangesDoNotHideRetainedBacklogs(final boolean multi) {
+        collector.multi = multi;
+        collector.start();
+        collector.collect(log("selector"));
+        final LoggingQueueMetrics metrics = metrics(multi);
+        try {
+            collector.multi = !multi;
+            assertEquals(1, metrics.getQueuedRecords());
+            assertEquals(0, metrics.getCapacity());
+            assertEquals(1, metrics.getAccepted());
+            assertEquals(0, metrics.getDropped());
+        } finally {
+            collector.multi = multi;
+        }
+        assertEquals(1, metrics.getCapacity());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void retainsCountersAndRestoresGaugesOnRestart(final boolean multi) throws 
Exception {
+        collector.multi = multi;
+        collector.start();
+        collector.collect(log("selector"));
+        collector.collect(log("selector"));
+        final LoggingQueueMetrics metrics = metrics(multi);
+        collector.close();
+        collector.start();
+        assertSame(metrics, metrics(multi));
+        assertEquals(1, metrics.getDropped());
+        assertEquals(0, metrics.getQueuedRecords());
+        assertEquals(1, metrics.getCapacity());
+        collector.collect(log("selector"));
+        assertEquals(2, metrics.getAccepted());
+    }
+
+    private LoggingQueueMetrics metrics(final boolean multi) {
+        return LoggingQueueMetrics.registered().stream()
+                .filter(metric -> 
metric.getCollector().equals(TestCollector.class.getName()))
+                .filter(metric -> metric.getMode() == (multi ? Mode.MULTI : 
Mode.SINGLE))
+                .findFirst().orElseThrow(() -> new AssertionError("Collector 
did not register queue metrics"));
+    }
+
+    private AbstractLoggingPlugin<ShenyuRequestLog> loggingPlugin() {
+        return new AbstractLoggingPlugin<>() {
+            @Override
+            protected LogCollector<ShenyuRequestLog> logCollector() {
+                return collector;
+            }
+
+            @Override
+            protected PluginEnum pluginEnum() {
+                return PluginEnum.LOGGING_KAFKA;
+            }
+
+            @Override
+            protected ShenyuRequestLog doLogExecute(final ServerWebExchange 
exchange, final SelectorData selector, final RuleData rule) {
+                throw new AssertionError("A sampled-out request must not 
produce a log");
+            }
+        };
+    }
+
+    private ShenyuRequestLog log(final String selector) {
+        ShenyuRequestLog log = new ShenyuRequestLog();
+        log.setSelectorId(selector);
+        return log;
+    }
+
+    private static final class TestCollector extends 
AbstractLogCollector<AbstractLogConsumeClient<?, ShenyuRequestLog>, 
ShenyuRequestLog, GenericGlobalConfig> {
+
+        private final GenericGlobalConfig config = new GenericGlobalConfig();
+
+        private final AbstractLogConsumeClient<?, ShenyuRequestLog> client = 
mock(AbstractLogConsumeClient.class);
+
+        private boolean multi;
+
+        private boolean clientAvailable = true;
+
+        @Override
+        protected AbstractLogConsumeClient<?, ShenyuRequestLog> 
getLogConsumeClient() {
+            return clientAvailable ? client : null;
+        }
+
+        @Override
+        protected GenericGlobalConfig getLogCollectConfig() {
+            return config;
+        }
+
+        @Override
+        protected boolean getMultiClient() {
+            return multi;
+        }
+
+        @Override
+        protected void desensitizeLog(final ShenyuRequestLog log, final 
KeyWordMatch match, final String algorithm) {
+        }
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
 
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
index eccada895e..1f2f16d4da 100644
--- 
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
+++ 
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
@@ -22,6 +22,18 @@ package org.apache.shenyu.plugin.metrics.constant;
  */
 public final class LabelNames {
 
+    /** Records accepted at the logging queue admission boundary, not backend 
deliveries. */
+    public static final String LOGGING_QUEUE_ACCEPTED_TOTAL = 
"shenyu_logging_queue_accepted_total";
+
+    /** Records rejected at the logging queue admission boundary, not backend 
failures. */
+    public static final String LOGGING_QUEUE_DROPPED_TOTAL = 
"shenyu_logging_queue_dropped_total";
+
+    /** Queued records, aggregated by collector implementation and queue mode. 
*/
+    public static final String LOGGING_QUEUE_SIZE = 
"shenyu_logging_queue_size";
+
+    /** Effective admission capacity, aggregated by collector implementation 
and queue mode. */
+    public static final String LOGGING_QUEUE_CAPACITY = 
"shenyu_logging_queue_capacity";
+
     /** Completed authenticated, parsed MCP logical calls, labelled only by 
outcome. */
     public static final String AGENT_MCP_CALLS_TOTAL = 
"shenyu_agent_mcp_calls_total";
 
diff --git 
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/prometheus/LoggingQueueMetricsCollector.java
 
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/prometheus/LoggingQueueMetricsCollector.java
new file mode 100644
index 0000000000..7bee9f2932
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/prometheus/LoggingQueueMetricsCollector.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *     http://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 org.apache.shenyu.plugin.metrics.prometheus;
+
+import io.prometheus.client.Collector;
+import io.prometheus.client.CounterMetricFamily;
+import io.prometheus.client.GaugeMetricFamily;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics;
+import org.apache.shenyu.plugin.metrics.constant.LabelNames;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Scrape logging admission metrics without depending on a logging plugin or 
polling thread.
+ */
+public final class LoggingQueueMetricsCollector extends Collector implements 
Collector.Describable {
+
+    private static final List<String> LABELS = List.of("collector", "mode");
+
+    @Override
+    public List<MetricFamilySamples> collect() {
+        return samples(LoggingQueueMetrics.registered());
+    }
+
+    @Override
+    public List<MetricFamilySamples> describe() {
+        return samples(Collections.emptyList());
+    }
+
+    private List<MetricFamilySamples> samples(final 
Collection<LoggingQueueMetrics> sources) {
+        final CounterMetricFamily accepted = new 
CounterMetricFamily(LabelNames.LOGGING_QUEUE_ACCEPTED_TOTAL,
+                "Records admitted to logging queues; does not count successful 
backend deliveries", LABELS);
+        final CounterMetricFamily dropped = new 
CounterMetricFamily(LabelNames.LOGGING_QUEUE_DROPPED_TOTAL,
+                "Records rejected by logging queue capacity; excludes 
sampling, disabled collectors and backend send failures", LABELS);
+        final GaugeMetricFamily size = new 
GaugeMetricFamily(LabelNames.LOGGING_QUEUE_SIZE,
+                "Records currently queued, excluding batches already drained 
for sending; aggregated across selector queues", LABELS);
+        final GaugeMetricFamily capacity = new 
GaugeMetricFamily(LabelNames.LOGGING_QUEUE_CAPACITY,
+                "Effective logging queue admission capacity in records; summed 
across selector queues; zero for inactive modes", LABELS);
+        for (LoggingQueueMetrics source : sources) {
+            List<String> labels = List.of(source.getCollector(), 
source.getMode().name().toLowerCase(Locale.ROOT));
+            accepted.addMetric(labels, source.getAccepted());
+            dropped.addMetric(labels, source.getDropped());
+            size.addMetric(labels, source.getQueuedRecords());
+            capacity.addMetric(labels, source.getCapacity());
+        }
+        return List.of(accepted, dropped, size, capacity);
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsService.java
 
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsService.java
index 7180d47506..3a0dc2d328 100644
--- 
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsService.java
+++ 
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsService.java
@@ -101,6 +101,7 @@ public final class PrometheusMetricsService implements 
MetricsService {
         if (!registered.compareAndSet(false, true)) {
             return;
         }
+        new LoggingQueueMetricsCollector().register();
         String jvmEnabled = 
String.valueOf(config.getProps().getProperty("jvm_enabled"));
         if (StringUtils.isNotEmpty(jvmEnabled)) {
             boolean enabled = Boolean.parseBoolean(jvmEnabled);
diff --git 
a/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/prometheus/LoggingQueueMetricsCollectorTest.java
 
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/prometheus/LoggingQueueMetricsCollectorTest.java
new file mode 100644
index 0000000000..46148068bc
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/prometheus/LoggingQueueMetricsCollectorTest.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *     http://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 org.apache.shenyu.plugin.metrics.prometheus;
+
+import io.prometheus.client.CollectorRegistry;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics.Mode;
+import org.apache.shenyu.plugin.metrics.constant.LabelNames;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Verify exported values, late registration and collector lifecycle with real 
scrapes.
+ */
+class LoggingQueueMetricsCollectorTest {
+
+    private final AtomicLong size = new AtomicLong();
+
+    private final AtomicLong capacity = new AtomicLong(10);
+
+    private final LoggingQueueMetrics metrics = new 
LoggingQueueMetrics(getClass(), Mode.MULTI, size::get, capacity::get);
+
+    private final CollectorRegistry registry = new CollectorRegistry();
+
+    @AfterEach
+    void tearDown() {
+        metrics.unregister();
+        registry.clear();
+    }
+
+    @Test
+    void exportsExistingAdmissionsAndLiveQueueState() {
+        metrics.register();
+        metrics.recordAdmission(true);
+        metrics.recordAdmission(false);
+        size.set(1);
+        new LoggingQueueMetricsCollector().register(registry);
+        assertEquals(1.0, sample(LabelNames.LOGGING_QUEUE_ACCEPTED_TOTAL));
+        assertEquals(1.0, sample(LabelNames.LOGGING_QUEUE_DROPPED_TOTAL));
+        assertEquals(1.0, sample(LabelNames.LOGGING_QUEUE_SIZE));
+        assertEquals(10.0, sample(LabelNames.LOGGING_QUEUE_CAPACITY));
+        // No new admission: a scrape still observes draining and 
configuration changes.
+        size.set(0);
+        capacity.set(20);
+        assertEquals(0.0, sample(LabelNames.LOGGING_QUEUE_SIZE));
+        assertEquals(20.0, sample(LabelNames.LOGGING_QUEUE_CAPACITY));
+        assertEquals(1.0, sample(LabelNames.LOGGING_QUEUE_DROPPED_TOTAL));
+    }
+
+    @Test
+    void discoversCollectorsStartedAfterMetricsAndRemovesStoppedCollectors() {
+        new LoggingQueueMetricsCollector().register(registry);
+        assertNull(sample(LabelNames.LOGGING_QUEUE_SIZE));
+        metrics.register();
+        assertEquals(0.0, sample(LabelNames.LOGGING_QUEUE_DROPPED_TOTAL));
+        assertEquals(10.0, sample(LabelNames.LOGGING_QUEUE_CAPACITY));
+        metrics.recordAdmission(false);
+        metrics.unregister();
+        assertNull(sample(LabelNames.LOGGING_QUEUE_SIZE));
+        assertNull(sample(LabelNames.LOGGING_QUEUE_DROPPED_TOTAL));
+        metrics.register();
+        assertEquals(1.0, sample(LabelNames.LOGGING_QUEUE_DROPPED_TOTAL));
+    }
+
+    @Test
+    void exportsOnlyCollectorAndModeLabels() {
+        metrics.register();
+        new LoggingQueueMetricsCollector().register(registry);
+        registry.metricFamilySamples().asIterator().forEachRemaining(family ->
+                family.samples.forEach(sample -> 
assertEquals(List.of("collector", "mode"), sample.labelNames)));
+    }
+
+    private Double sample(final String name) {
+        return registry.getSampleValue(name, new String[]{"collector", 
"mode"}, new String[]{getClass().getName(), "multi"});
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsServiceTest.java
 
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsServiceTest.java
index 4cf59fbb0d..57ff24df36 100644
--- 
a/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsServiceTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/prometheus/PrometheusMetricsServiceTest.java
@@ -20,18 +20,24 @@ package org.apache.shenyu.plugin.metrics.prometheus;
 import io.prometheus.client.CollectorRegistry;
 import io.prometheus.client.exporter.HTTPServer;
 import org.apache.shenyu.common.config.ShenyuConfig.MetricsConfig;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics;
+import org.apache.shenyu.common.metrics.LoggingQueueMetrics.Mode;
 import org.apache.shenyu.common.utils.GsonUtils;
 import org.apache.shenyu.common.utils.ReflectUtils;
+import org.apache.shenyu.plugin.metrics.constant.LabelNames;
 import org.hamcrest.MatcherAssert;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Field;
+import java.util.Properties;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.hamcrest.CoreMatchers.is;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -46,6 +52,24 @@ public final class PrometheusMetricsServiceTest {
         CollectorRegistry.defaultRegistry.clear();
     }
     
+    @Test
+    public void testRegistersLoggingQueueMetrics() {
+        LoggingQueueMetrics metrics = new LoggingQueueMetrics(getClass(), 
Mode.SINGLE, () -> 2, () -> 10);
+        try {
+            metrics.register();
+            MetricsConfig config = new MetricsConfig();
+            config.setProps(new Properties());
+            PrometheusMetricsService service = new PrometheusMetricsService();
+            ReflectionTestUtils.invokeMethod(service, "register", config);
+            ReflectionTestUtils.invokeMethod(service, "register", config);
+            assertEquals(2.0, 
CollectorRegistry.defaultRegistry.getSampleValue(LabelNames.LOGGING_QUEUE_SIZE,
+                    new String[]{"collector", "mode"}, new 
String[]{getClass().getName(), "single"}));
+        } finally {
+            metrics.unregister();
+            CollectorRegistry.defaultRegistry.clear();
+        }
+    }
+
     @Test
     public void testRegistered() throws NoSuchFieldException, 
IllegalAccessException {
         AtomicBoolean registered = (AtomicBoolean) 
ReflectUtils.getFieldValue(SERVICE, "registered");

Reply via email to