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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 74a29b81551f CAMEL-24911: Collapse a storm of the same error in the 
error registry and log (#26754)
74a29b81551f is described below

commit 74a29b81551f11dbc02d2f27350aed9e239b8443
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 23 10:28:48 2026 +0200

    CAMEL-24911: Collapse a storm of the same error in the error registry and 
log (#26754)
    
    Errors are counted per kind (route, node, exception type): only the newest 
few exchanges of a kind are kept while its counter keeps rising, so a burst of 
one failure no longer evicts every other error. The count reaches the dev 
console, camel get errors, the camel-jbang views and camel_get_errors, and 
camel_get_log folds a run of identical lines into one.
---
 .../org/apache/camel/catalog/docs/main.adoc        |   3 +-
 .../main/camel-main-configuration-metadata.json    |   1 +
 .../apache/camel/spi/BacklogErrorEventMessage.java |  25 +++++
 .../java/org/apache/camel/spi/ErrorRegistry.java   |  18 ++++
 .../camel/impl/engine/DefaultErrorRegistry.java    | 118 +++++++++++++++++++++
 .../camel/impl/console/ErrorRegistryConsole.java   |   4 +
 .../apache/camel/impl/ErrorRegistryRepeatTest.java | 111 +++++++++++++++++++
 ...rRegistryConfigurationPropertiesConfigurer.java |   7 ++
 .../camel-main-configuration-metadata.json         |   1 +
 core/camel-main/src/main/docs/main.adoc            |   3 +-
 .../org/apache/camel/main/BaseMainSupport.java     |   1 +
 .../main/ErrorRegistryConfigurationProperties.java |  24 +++++
 .../mbean/ManagedErrorRegistryMBean.java           |   6 ++
 .../management/mbean/ManagedErrorRegistry.java     |  10 ++
 .../dsl/jbang/core/commands/ai/LogFileReader.java  |  33 +++++-
 .../dsl/jbang/core/commands/process/ListError.java |   5 +
 .../jbang/core/commands/ai/LogFileReaderTest.java  |  20 ++++
 .../dsl/jbang/core/commands/tui/ErrorInfo.java     |   3 +
 .../dsl/jbang/core/commands/tui/ErrorsTab.java     |  24 ++++-
 .../dsl/jbang/core/commands/tui/LogEntry.java      |   2 +
 .../camel/dsl/jbang/core/commands/tui/LogTab.java  |  32 ++++--
 .../dsl/jbang/core/commands/tui/StatusParser.java  |   6 ++
 .../core/commands/tui/ErrorsTabRenderTest.java     |  14 +++
 .../jbang/core/commands/tui/LogTabRenderTest.java  |  20 ++++
 24 files changed, 481 insertions(+), 10 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
index ab26b442926f..5e3ffea4d8df 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
@@ -748,7 +748,7 @@ The camel.lra supports 5 options, which are listed below.
 
 
 === Camel Error Registry configurations
-The camel.errorRegistry supports 8 options, which are listed below.
+The camel.errorRegistry supports 9 options, which are listed below.
 
 [width="100%",cols="2,5,^1,2",options="header"]
 |===
@@ -760,6 +760,7 @@ The camel.errorRegistry supports 8 options, which are 
listed below.
 | `camel.errorRegistry.includeExchangeProperties` | Whether to include the 
exchange properties in the captured error data. | true | boolean
 | `camel.errorRegistry.includeExchangeVariables` | Whether to include the 
exchange variables in the captured error data. | true | boolean
 | `camel.errorRegistry.maximumEntries` | The maximum number of error entries 
to keep in the registry. When the limit is exceeded, the oldest entries are 
evicted. | 100 | int
+| `camel.errorRegistry.maximumEntriesPerKind` | The maximum number of error 
entries of the same kind (same route, node and exception type) to keep, so a 
storm of one failure does not evict all the other errors. The counter of that 
kind keeps rising even when its older entries are evicted. | 3 | int
 | `camel.errorRegistry.timeToLiveSeconds` | The time-to-live in seconds for 
error entries. Entries older than this are evicted. The default value is 0 
(disabled). | 0 | int
 |===
 
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
index 63884fca7e36..561978a50e29 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
@@ -267,6 +267,7 @@
     { "name": "camel.errorRegistry.includeExchangeProperties", "required": 
false, "description": "Whether to include the exchange properties in the 
captured error data.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defaultValue": true, "secret": false },
     { "name": "camel.errorRegistry.includeExchangeVariables", "required": 
false, "description": "Whether to include the exchange variables in the 
captured error data.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defaultValue": true, "secret": false },
     { "name": "camel.errorRegistry.maximumEntries", "required": false, 
"description": "The maximum number of error entries to keep in the registry. 
When the limit is exceeded, the oldest entries are evicted.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"integer", "javaType": "int", "defaultValue": 100, "secret": false },
+    { "name": "camel.errorRegistry.maximumEntriesPerKind", "required": false, 
"description": "The maximum number of error entries of the same kind (same 
route, node and exception type) to keep, so a storm of one failure does not 
evict all the other errors. The counter of that kind keeps rising even when its 
older entries are evicted.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"integer", "javaType": "int", "defaultValue": 3, "secret": false },
     { "name": "camel.errorRegistry.timeToLiveSeconds", "required": false, 
"description": "The time-to-live in seconds for error entries. Entries older 
than this are evicted. The default value is 0 (disabled).", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"integer", "javaType": "int", "defaultValue": 0, "secret": false },
     { "name": "camel.faulttolerance.bulkheadEnabled", "required": false, 
"description": "Whether bulkhead is enabled or not on the circuit breaker. 
Default is false.", "sourceType": 
"org.apache.camel.main.FaultToleranceConfigurationProperties", "type": 
"boolean", "javaType": "java.lang.Boolean", "defaultValue": false, "secret": 
false },
     { "name": "camel.faulttolerance.bulkheadMaxConcurrentCalls", "required": 
false, "description": "Configures the max amount of concurrent calls the 
bulkhead will support. Default value is 10.", "sourceType": 
"org.apache.camel.main.FaultToleranceConfigurationProperties", "type": 
"integer", "javaType": "java.lang.Integer", "defaultValue": 10, "secret": false 
},
diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
 
b/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
index 9b1fee6dd0ac..c07dd75f40a8 100644
--- 
a/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
+++ 
b/core/camel-api/src/main/java/org/apache/camel/spi/BacklogErrorEventMessage.java
@@ -85,6 +85,31 @@ public interface BacklogErrorEventMessage extends 
BacklogEventMessage {
      */
     void markHandled();
 
+    /**
+     * How many errors of this kind (same route, node and exception type) have 
been captured so far. The count keeps
+     * rising for as long as the same error keeps happening, also after older 
entries of that kind have been evicted, so
+     * a storm of the same failure shows as a growing number instead of 
filling up the registry.
+     * <p/>
+     * Is 1 when the error only happened once.
+     *
+     * @since 4.23
+     */
+    long getRepeatCount();
+
+    /**
+     * Timestamp (millis) of the first error of this kind that was captured.
+     *
+     * @since 4.23
+     */
+    long getRepeatFirstTimestamp();
+
+    /**
+     * Timestamp (millis) of the latest error of this kind that was captured.
+     *
+     * @since 4.23
+     */
+    long getRepeatLastTimestamp();
+
     /**
      * The fully qualified class name of the exception (e.g. 
"java.lang.IllegalArgumentException").
      */
diff --git 
a/core/camel-api/src/main/java/org/apache/camel/spi/ErrorRegistry.java 
b/core/camel-api/src/main/java/org/apache/camel/spi/ErrorRegistry.java
index b95810777e6c..17b505be71b4 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/ErrorRegistry.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/ErrorRegistry.java
@@ -74,6 +74,24 @@ public interface ErrorRegistry extends ErrorRegistryView, 
StaticService {
      */
     void setMaximumEntries(int maximumEntries);
 
+    /**
+     * The maximum number of error entries of the same kind (same route, node 
and exception type) to keep
+     *
+     * @since 4.23
+     */
+    int getMaximumEntriesPerKind();
+
+    /**
+     * Sets the maximum number of error entries of the same kind (same route, 
node and exception type) to keep, so a
+     * storm of one failure does not evict all the other errors. The counter 
of that kind keeps rising even when its
+     * older entries are evicted.
+     * <p/>
+     * The default value is 3.
+     *
+     * @since 4.23
+     */
+    void setMaximumEntriesPerKind(int maximumEntriesPerKind);
+
     /**
      * The time-to-live for error entries
      */
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
index 22b576b023e9..9a41c9a57055 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultErrorRegistry.java
@@ -24,6 +24,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentLinkedDeque;
 import java.util.concurrent.atomic.AtomicLong;
 
@@ -47,9 +48,13 @@ import org.apache.camel.util.json.Jsoner;
 public class DefaultErrorRegistry extends EventNotifierSupport implements 
ErrorRegistry {
 
     private final ConcurrentLinkedDeque<BacklogErrorEventMessage> entries = 
new ConcurrentLinkedDeque<>();
+    /** How often each kind of error happened, so a storm is counted while 
only a few of its exchanges are kept. */
+    private final Map<String, Repeat> repeats = new ConcurrentHashMap<>();
     private final AtomicLong uidCounter = new AtomicLong();
     private volatile boolean enabled;
     private volatile int maximumEntries = 100;
+    /** How many exchanges of the same kind of error are kept, so one storm 
does not push out the other errors. */
+    private volatile int maximumEntriesPerKind = 3;
     private volatile Duration timeToLive = Duration.ZERO;
     private volatile int bodyMaxChars = 32 * 1024;
     private volatile boolean bodyIncludeStreams;
@@ -206,10 +211,82 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
                 }
             }
         }
+        // count this kind of error and keep only a few of its exchanges, so a 
storm of one failure neither hides
+        // the count nor evicts everything else (CAMEL-24911)
+        String kind = kindOf(entry);
+        Repeat repeat = repeats.computeIfAbsent(kind, k -> new 
Repeat(timestamp));
+        long count = repeat.record(timestamp);
+        entry.setRepeat(count, repeat.first(), timestamp);
         entries.addFirst(entry);
+        evictKind(kind);
         evict();
     }
 
+    /**
+     * What makes two errors the same kind: the route, the node that failed, 
and the type of the exception. The
+     * exception message is deliberately left out, because a real storm 
usually carries the failing payload in its
+     * message (an order id, a url), which would make every entry its own kind 
and let the storm flood the registry
+     * again. The messages are still there to read on the entries that are 
kept.
+     */
+    private static String kindOf(BacklogErrorEventMessage entry) {
+        return entry.getRouteId() + "|" + entry.getToNode() + "|" + 
entry.getExceptionType();
+    }
+
+    /** Keeps at most {@link #maximumEntriesPerKind} entries of one kind, the 
newest ones. */
+    private void evictKind(String kind) {
+        int seen = 0;
+        var it = entries.iterator();
+        while (it.hasNext()) {
+            BacklogErrorEventMessage e = it.next();
+            if (kind.equals(kindOf(e))) {
+                seen++;
+                if (seen > maximumEntriesPerKind) {
+                    it.remove();
+                }
+            }
+        }
+        // a counter costs little, but do not keep more of them than the 
registry keeps entries
+        while (repeats.size() > maximumEntries) {
+            String oldest = null;
+            long oldestTime = Long.MAX_VALUE;
+            for (Map.Entry<String, Repeat> en : repeats.entrySet()) {
+                if (en.getValue().last() < oldestTime) {
+                    oldestTime = en.getValue().last();
+                    oldest = en.getKey();
+                }
+            }
+            if (oldest == null) {
+                break;
+            }
+            repeats.remove(oldest);
+        }
+    }
+
+    /** How often one kind of error happened, and when it first and last did. 
*/
+    private static final class Repeat {
+        private final AtomicLong count = new AtomicLong();
+        private final long first;
+        private volatile long last;
+
+        private Repeat(long first) {
+            this.first = first;
+            this.last = first;
+        }
+
+        private long record(long timestamp) {
+            this.last = timestamp;
+            return count.incrementAndGet();
+        }
+
+        private long first() {
+            return first;
+        }
+
+        private long last() {
+            return last;
+        }
+    }
+
     @SuppressWarnings("unchecked")
     private static String[] captureMessageHistory(Exchange exchange) {
         List<MessageHistory> history
@@ -289,6 +366,7 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
     @Override
     public void clear() {
         entries.clear();
+        repeats.clear();
     }
 
     // -- Scoped view --
@@ -325,6 +403,16 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
         this.maximumEntries = maximumEntries;
     }
 
+    @Override
+    public int getMaximumEntriesPerKind() {
+        return maximumEntriesPerKind;
+    }
+
+    @Override
+    public void setMaximumEntriesPerKind(int maximumEntriesPerKind) {
+        this.maximumEntriesPerKind = maximumEntriesPerKind;
+    }
+
     @Override
     public Duration getTimeToLive() {
         return timeToLive;
@@ -456,6 +544,9 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
         private final JsonObject data;
         private final Throwable exception;
         private volatile boolean handled;
+        private volatile long repeatCount = 1;
+        private volatile long repeatFirstTimestamp;
+        private volatile long repeatLastTimestamp;
         private final String[] messageHistory;
 
         private volatile String dataAsJson;
@@ -594,6 +685,28 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
             this.handled = true;
         }
 
+        @Override
+        public long getRepeatCount() {
+            return repeatCount;
+        }
+
+        @Override
+        public long getRepeatFirstTimestamp() {
+            return repeatFirstTimestamp;
+        }
+
+        @Override
+        public long getRepeatLastTimestamp() {
+            return repeatLastTimestamp;
+        }
+
+        /** How often this kind of error happened so far, and when it first 
and last did (CAMEL-24911). */
+        void setRepeat(long count, long firstTimestamp, long lastTimestamp) {
+            this.repeatCount = count;
+            this.repeatFirstTimestamp = firstTimestamp;
+            this.repeatLastTimestamp = lastTimestamp;
+        }
+
         @Override
         public String getExceptionType() {
             return exception.getClass().getName();
@@ -655,6 +768,11 @@ public class DefaultErrorRegistry extends 
EventNotifierSupport implements ErrorR
             jo.put("elapsed", elapsed);
             jo.put("threadName", threadName);
             jo.put("handled", handled);
+            if (repeatCount > 1) {
+                jo.put("repeatCount", repeatCount);
+                jo.put("repeatFirstTimestamp", repeatFirstTimestamp);
+                jo.put("repeatLastTimestamp", repeatLastTimestamp);
+            }
             // message data (body, headers)
             Map<String, Object> msg = data.getMap("message");
             jo.put("message", msg);
diff --git 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ErrorRegistryConsole.java
 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ErrorRegistryConsole.java
index a07ac095abf2..2cebf02fe25c 100644
--- 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ErrorRegistryConsole.java
+++ 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ErrorRegistryConsole.java
@@ -87,6 +87,10 @@ public class ErrorRegistryConsole extends AbstractDevConsole 
{
                     entry.getExceptionType(), entry.getExceptionMessage()));
             sb.append(String.format("%n      Timestamp: %s, Thread: %s",
                     entry.getTimestamp(), entry.getProcessingThreadName()));
+            if (entry.getRepeatCount() > 1) {
+                sb.append(String.format("%n      Repeated: %s times (first: 
%s, last: %s)",
+                        entry.getRepeatCount(), 
entry.getRepeatFirstTimestamp(), entry.getRepeatLastTimestamp()));
+            }
             if (entry.getMessageHistory() != null) {
                 sb.append(String.format("%n      Message History:"));
                 for (String step : entry.getMessageHistory()) {
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/ErrorRegistryRepeatTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/ErrorRegistryRepeatTest.java
new file mode 100644
index 000000000000..7b3cf08dabee
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/ErrorRegistryRepeatTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.camel.impl;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.BacklogErrorEventMessage;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * CAMEL-24911: a storm of the same error keeps only a few exchanges but keeps 
counting, and does not push the other
+ * errors out of the registry.
+ */
+public class ErrorRegistryRepeatTest extends ContextTestSupport {
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        context.getErrorRegistry().setEnabled(true);
+        context.getErrorRegistry().setMaximumEntries(10);
+        return context;
+    }
+
+    @Test
+    public void testStormIsCountedAndDoesNotEvictTheOtherError() throws 
Exception {
+        template.sendBody("direct:other", "Hello");
+
+        for (int i = 0; i < 30; i++) {
+            template.sendBody("direct:storm", "Hello " + i);
+        }
+
+        Collection<BacklogErrorEventMessage> entries = 
context.getErrorRegistry().browse();
+        List<BacklogErrorEventMessage> storm
+                = entries.stream().filter(e -> 
"storm".equals(e.getRouteId())).toList();
+        List<BacklogErrorEventMessage> other
+                = entries.stream().filter(e -> 
"other".equals(e.getRouteId())).toList();
+
+        assertEquals(3, storm.size(), "only the newest exchanges of the storm 
are kept: " + entries);
+        assertEquals(1, other.size(), "the other error must survive the storm: 
" + entries);
+
+        // the newest entry is first, and its counter has kept rising past the 
entries that were evicted
+        BacklogErrorEventMessage newest = storm.get(0);
+        assertEquals(30, newest.getRepeatCount());
+        assertEquals(29, storm.get(1).getRepeatCount(), "the 3 kept entries 
are the newest: 30, 29, 28");
+        assertEquals(1, other.get(0).getRepeatCount(), "an error that happened 
once is not a repeat");
+        assertTrue(newest.getRepeatFirstTimestamp() <= 
newest.getRepeatLastTimestamp());
+        assertEquals(newest.getTimestamp(), newest.getRepeatLastTimestamp());
+    }
+
+    @Test
+    public void testAStormWhoseMessagesDifferIsStillOneKind() throws Exception 
{
+        template.sendBody("direct:other", "Hello");
+
+        // a real storm carries the failing payload in its message, so no two 
messages are the same
+        for (int i = 0; i < 20; i++) {
+            template.sendBody("direct:payload", "sku-" + i);
+        }
+
+        Collection<BacklogErrorEventMessage> entries = 
context.getErrorRegistry().browse();
+        List<BacklogErrorEventMessage> storm
+                = entries.stream().filter(e -> 
"payload".equals(e.getRouteId())).toList();
+
+        assertEquals(3, storm.size(), "the kind is the route, node and 
exception type, not the message: " + entries);
+        assertEquals(20, storm.get(0).getRepeatCount());
+        assertEquals("unknown sku sku-19", storm.get(0).getExceptionMessage(), 
"the messages are still readable");
+        assertEquals(1, entries.stream().filter(e -> 
"other".equals(e.getRouteId())).count(),
+                "the other error must survive the storm: " + entries);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(0));
+
+                from("direct:storm").routeId("storm")
+                        .throwException(new IllegalArgumentException("Forced 
error"));
+
+                from("direct:payload").routeId("payload")
+                        .process(e -> {
+                            throw new IllegalArgumentException("unknown sku " 
+ e.getMessage().getBody(String.class));
+                        });
+
+                from("direct:other").routeId("other")
+                        .throwException(new IllegalStateException("Something 
else"));
+            }
+        };
+    }
+}
diff --git 
a/core/camel-main/src/generated/java/org/apache/camel/main/ErrorRegistryConfigurationPropertiesConfigurer.java
 
b/core/camel-main/src/generated/java/org/apache/camel/main/ErrorRegistryConfigurationPropertiesConfigurer.java
index a2fb37bf6e69..71765daf0be9 100644
--- 
a/core/camel-main/src/generated/java/org/apache/camel/main/ErrorRegistryConfigurationPropertiesConfigurer.java
+++ 
b/core/camel-main/src/generated/java/org/apache/camel/main/ErrorRegistryConfigurationPropertiesConfigurer.java
@@ -29,6 +29,7 @@ public class ErrorRegistryConfigurationPropertiesConfigurer 
extends org.apache.c
         map.put("IncludeExchangeProperties", boolean.class);
         map.put("IncludeExchangeVariables", boolean.class);
         map.put("MaximumEntries", int.class);
+        map.put("MaximumEntriesPerKind", int.class);
         map.put("TimeToLiveSeconds", int.class);
         ALL_OPTIONS = map;
     }
@@ -50,6 +51,8 @@ public class ErrorRegistryConfigurationPropertiesConfigurer 
extends org.apache.c
         case "includeExchangeVariables": 
target.setIncludeExchangeVariables(property(camelContext, boolean.class, 
value)); return true;
         case "maximumentries":
         case "maximumEntries": target.setMaximumEntries(property(camelContext, 
int.class, value)); return true;
+        case "maximumentriesperkind":
+        case "maximumEntriesPerKind": 
target.setMaximumEntriesPerKind(property(camelContext, int.class, value)); 
return true;
         case "timetoliveseconds":
         case "timeToLiveSeconds": 
target.setTimeToLiveSeconds(property(camelContext, int.class, value)); return 
true;
         default: return false;
@@ -77,6 +80,8 @@ public class ErrorRegistryConfigurationPropertiesConfigurer 
extends org.apache.c
         case "includeExchangeVariables": return boolean.class;
         case "maximumentries":
         case "maximumEntries": return int.class;
+        case "maximumentriesperkind":
+        case "maximumEntriesPerKind": return int.class;
         case "timetoliveseconds":
         case "timeToLiveSeconds": return int.class;
         default: return null;
@@ -100,6 +105,8 @@ public class ErrorRegistryConfigurationPropertiesConfigurer 
extends org.apache.c
         case "includeExchangeVariables": return 
target.isIncludeExchangeVariables();
         case "maximumentries":
         case "maximumEntries": return target.getMaximumEntries();
+        case "maximumentriesperkind":
+        case "maximumEntriesPerKind": return target.getMaximumEntriesPerKind();
         case "timetoliveseconds":
         case "timeToLiveSeconds": return target.getTimeToLiveSeconds();
         default: return null;
diff --git 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
index 63884fca7e36..561978a50e29 100644
--- 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
+++ 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
@@ -267,6 +267,7 @@
     { "name": "camel.errorRegistry.includeExchangeProperties", "required": 
false, "description": "Whether to include the exchange properties in the 
captured error data.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defaultValue": true, "secret": false },
     { "name": "camel.errorRegistry.includeExchangeVariables", "required": 
false, "description": "Whether to include the exchange variables in the 
captured error data.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"boolean", "javaType": "boolean", "defaultValue": true, "secret": false },
     { "name": "camel.errorRegistry.maximumEntries", "required": false, 
"description": "The maximum number of error entries to keep in the registry. 
When the limit is exceeded, the oldest entries are evicted.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"integer", "javaType": "int", "defaultValue": 100, "secret": false },
+    { "name": "camel.errorRegistry.maximumEntriesPerKind", "required": false, 
"description": "The maximum number of error entries of the same kind (same 
route, node and exception type) to keep, so a storm of one failure does not 
evict all the other errors. The counter of that kind keeps rising even when its 
older entries are evicted.", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"integer", "javaType": "int", "defaultValue": 3, "secret": false },
     { "name": "camel.errorRegistry.timeToLiveSeconds", "required": false, 
"description": "The time-to-live in seconds for error entries. Entries older 
than this are evicted. The default value is 0 (disabled).", "sourceType": 
"org.apache.camel.main.ErrorRegistryConfigurationProperties", "type": 
"integer", "javaType": "int", "defaultValue": 0, "secret": false },
     { "name": "camel.faulttolerance.bulkheadEnabled", "required": false, 
"description": "Whether bulkhead is enabled or not on the circuit breaker. 
Default is false.", "sourceType": 
"org.apache.camel.main.FaultToleranceConfigurationProperties", "type": 
"boolean", "javaType": "java.lang.Boolean", "defaultValue": false, "secret": 
false },
     { "name": "camel.faulttolerance.bulkheadMaxConcurrentCalls", "required": 
false, "description": "Configures the max amount of concurrent calls the 
bulkhead will support. Default value is 10.", "sourceType": 
"org.apache.camel.main.FaultToleranceConfigurationProperties", "type": 
"integer", "javaType": "java.lang.Integer", "defaultValue": 10, "secret": false 
},
diff --git a/core/camel-main/src/main/docs/main.adoc 
b/core/camel-main/src/main/docs/main.adoc
index ab26b442926f..5e3ffea4d8df 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -748,7 +748,7 @@ The camel.lra supports 5 options, which are listed below.
 
 
 === Camel Error Registry configurations
-The camel.errorRegistry supports 8 options, which are listed below.
+The camel.errorRegistry supports 9 options, which are listed below.
 
 [width="100%",cols="2,5,^1,2",options="header"]
 |===
@@ -760,6 +760,7 @@ The camel.errorRegistry supports 8 options, which are 
listed below.
 | `camel.errorRegistry.includeExchangeProperties` | Whether to include the 
exchange properties in the captured error data. | true | boolean
 | `camel.errorRegistry.includeExchangeVariables` | Whether to include the 
exchange variables in the captured error data. | true | boolean
 | `camel.errorRegistry.maximumEntries` | The maximum number of error entries 
to keep in the registry. When the limit is exceeded, the oldest entries are 
evicted. | 100 | int
+| `camel.errorRegistry.maximumEntriesPerKind` | The maximum number of error 
entries of the same kind (same route, node and exception type) to keep, so a 
storm of one failure does not evict all the other errors. The counter of that 
kind keeps rising even when its older entries are evicted. | 3 | int
 | `camel.errorRegistry.timeToLiveSeconds` | The time-to-live in seconds for 
error entries. Entries older than this are evicted. The default value is 0 
(disabled). | 0 | int
 |===
 
diff --git 
a/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java 
b/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java
index d745afab7a6d..92dd2ff00a3e 100644
--- a/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java
+++ b/core/camel-main/src/main/java/org/apache/camel/main/BaseMainSupport.java
@@ -2619,6 +2619,7 @@ public abstract class BaseMainSupport extends BaseService 
{
         ErrorRegistry registry = camelContext.getErrorRegistry();
         registry.setEnabled(config.isEnabled());
         registry.setMaximumEntries(config.getMaximumEntries());
+        registry.setMaximumEntriesPerKind(config.getMaximumEntriesPerKind());
         
registry.setTimeToLive(Duration.ofSeconds(config.getTimeToLiveSeconds()));
         registry.setBodyMaxChars(config.getBodyMaxChars());
         registry.setBodyIncludeStreams(config.isBodyIncludeStreams());
diff --git 
a/core/camel-main/src/main/java/org/apache/camel/main/ErrorRegistryConfigurationProperties.java
 
b/core/camel-main/src/main/java/org/apache/camel/main/ErrorRegistryConfigurationProperties.java
index 86dde828527b..8404baf80097 100644
--- 
a/core/camel-main/src/main/java/org/apache/camel/main/ErrorRegistryConfigurationProperties.java
+++ 
b/core/camel-main/src/main/java/org/apache/camel/main/ErrorRegistryConfigurationProperties.java
@@ -32,6 +32,8 @@ public class ErrorRegistryConfigurationProperties implements 
BootstrapCloseable
     private boolean enabled;
     @Metadata(defaultValue = "100")
     private int maximumEntries = 100;
+    @Metadata(defaultValue = "3")
+    private int maximumEntriesPerKind = 3;
     @Metadata(defaultValue = "0")
     private int timeToLiveSeconds;
     @Metadata(label = "advanced", defaultValue = "32768")
@@ -81,6 +83,19 @@ public class ErrorRegistryConfigurationProperties implements 
BootstrapCloseable
         this.maximumEntries = maximumEntries;
     }
 
+    public int getMaximumEntriesPerKind() {
+        return maximumEntriesPerKind;
+    }
+
+    /**
+     * The maximum number of error entries of the same kind (same route, node 
and exception type) to keep, so a storm of
+     * one failure does not evict all the other errors. The counter of that 
kind keeps rising even when its older
+     * entries are evicted.
+     */
+    public void setMaximumEntriesPerKind(int maximumEntriesPerKind) {
+        this.maximumEntriesPerKind = maximumEntriesPerKind;
+    }
+
     public int getTimeToLiveSeconds() {
         return timeToLiveSeconds;
     }
@@ -170,6 +185,15 @@ public class ErrorRegistryConfigurationProperties 
implements BootstrapCloseable
         return this;
     }
 
+    /**
+     * The maximum number of error entries of the same kind (same route, node 
and exception type) to keep, so a storm of
+     * one failure does not evict all the other errors.
+     */
+    public ErrorRegistryConfigurationProperties withMaximumEntriesPerKind(int 
maximumEntriesPerKind) {
+        this.maximumEntriesPerKind = maximumEntriesPerKind;
+        return this;
+    }
+
     /**
      * The time-to-live in seconds for error entries. Entries older than this 
are evicted.
      */
diff --git 
a/core/camel-management-api/src/main/java/org/apache/camel/api/management/mbean/ManagedErrorRegistryMBean.java
 
b/core/camel-management-api/src/main/java/org/apache/camel/api/management/mbean/ManagedErrorRegistryMBean.java
index b9a57809b161..0f0dc2624c9a 100644
--- 
a/core/camel-management-api/src/main/java/org/apache/camel/api/management/mbean/ManagedErrorRegistryMBean.java
+++ 
b/core/camel-management-api/src/main/java/org/apache/camel/api/management/mbean/ManagedErrorRegistryMBean.java
@@ -38,6 +38,12 @@ public interface ManagedErrorRegistryMBean extends 
ManagedServiceMBean {
     @ManagedAttribute(description = "Maximum number of error entries to keep")
     void setMaximumEntries(int maximumEntries);
 
+    @ManagedAttribute(description = "Maximum number of error entries of the 
same kind to keep")
+    int getMaximumEntriesPerKind();
+
+    @ManagedAttribute(description = "Maximum number of error entries of the 
same kind to keep")
+    void setMaximumEntriesPerKind(int maximumEntriesPerKind);
+
     @ManagedAttribute(description = "Time-to-live in seconds for error 
entries")
     long getTimeToLiveSeconds();
 
diff --git 
a/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedErrorRegistry.java
 
b/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedErrorRegistry.java
index 3160a08923da..dc651f38470a 100644
--- 
a/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedErrorRegistry.java
+++ 
b/core/camel-management/src/main/java/org/apache/camel/management/mbean/ManagedErrorRegistry.java
@@ -72,6 +72,16 @@ public class ManagedErrorRegistry extends ManagedService 
implements ManagedError
         errorRegistry.setMaximumEntries(maximumEntries);
     }
 
+    @Override
+    public int getMaximumEntriesPerKind() {
+        return errorRegistry.getMaximumEntriesPerKind();
+    }
+
+    @Override
+    public void setMaximumEntriesPerKind(int maximumEntriesPerKind) {
+        errorRegistry.setMaximumEntriesPerKind(maximumEntriesPerKind);
+    }
+
     @Override
     public long getTimeToLiveSeconds() {
         return errorRegistry.getTimeToLive().toSeconds();
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
index c57fb7c9d378..d648b2f7f5f4 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReader.java
@@ -23,6 +23,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -97,7 +98,7 @@ public final class LogFileReader {
 
     /** Groups raw lines into records and filters them, newest first; visible 
for tests. */
     static JsonObject build(List<String> lines, int limit, String filter, 
String level, JsonObject result) {
-        List<JsonObject> records = toRecords(lines);
+        List<JsonObject> records = fold(toRecords(lines));
         String needle = filter == null || filter.isBlank() ? null : 
filter.toLowerCase();
         JsonArray rows = new JsonArray();
         for (int i = records.size() - 1; i >= 0 && rows.size() < limit; i--) {
@@ -186,6 +187,36 @@ public final class LogFileReader {
         return records;
     }
 
+    /**
+     * Folds a run of identical records into one with a {@code repeated} count 
and the time of its first occurrence, so
+     * a storm of the same failure is one line to read instead of hundreds 
that push everything else out of the window
+     * (CAMEL-24911).
+     */
+    private static List<JsonObject> fold(List<JsonObject> records) {
+        List<JsonObject> folded = new ArrayList<>();
+        for (JsonObject r : records) {
+            JsonObject last = folded.isEmpty() ? null : 
folded.get(folded.size() - 1);
+            if (last != null && sameRecord(last, r)) {
+                // keep the newest occurrence, and remember when the run 
started and how long it is
+                String firstTime = last.getStringOrDefault("firstTime", 
last.getStringOrDefault("time", ""));
+                long repeated = last.getLongOrDefault("repeated", 1) + 1;
+                r.put("firstTime", firstTime);
+                r.put("repeated", repeated);
+                folded.set(folded.size() - 1, r);
+            } else {
+                folded.add(r);
+            }
+        }
+        return folded;
+    }
+
+    private static boolean sameRecord(JsonObject a, JsonObject b) {
+        return Objects.equals(a.get("level"), b.get("level"))
+                && Objects.equals(a.get("logger"), b.get("logger"))
+                && Objects.equals(a.get("message"), b.get("message"))
+                && Objects.equals(a.get("detail"), b.get("detail"));
+    }
+
     private static JsonObject finish(JsonObject head, List<String> detail, int 
hidden) {
         if (!detail.isEmpty()) {
             String text = String.join("\n", detail);
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
index 88f8df312dc9..d915a662ee9c 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListError.java
@@ -178,6 +178,8 @@ public class ListError extends ProcessWatchCommand {
                                     if (ts != null) {
                                         row.timestamp = ts;
                                     }
+                                    Long rc = jo.getLong("repeatCount");
+                                    row.repeatCount = rc != null ? rc : 1;
                                     row.location = jo.getString("location");
                                     row.rawJson = jo;
 
@@ -248,6 +250,8 @@ public class ListError extends ProcessWatchCommand {
                                 .with(r -> r.nodeId),
                         new 
Column().header("HANDLED").dataAlign(HorizontalAlign.CENTER)
                                 .with(r -> r.handled ? "true" : "false"),
+                        new 
Column().header("COUNT").dataAlign(HorizontalAlign.RIGHT)
+                                .with(r -> r.repeatCount > 1 ? 
Long.toString(r.repeatCount) : ""),
                         new 
Column().header("EXCEPTION").dataAlign(HorizontalAlign.LEFT)
                                 .maxWidth(40, OverflowBehaviour.ELLIPSIS_RIGHT)
                                 .with(r -> 
shortExceptionType(r.exceptionType)),
@@ -549,6 +553,7 @@ public class ListError extends ProcessWatchCommand {
         String nodeId;
         String exchangeId;
         boolean handled;
+        long repeatCount;
         String location;
         String exceptionType;
         String exceptionMessage;
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
index 253a614ae02d..543023809ad7 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/LogFileReaderTest.java
@@ -52,6 +52,26 @@ class LogFileReaderTest {
         assertNull(rows.get(0).get("detail"));
     }
 
+    @Test
+    void foldsAStormOfTheSameLineIntoOneRecord() {
+        List<String> storm = new java.util.ArrayList<>();
+        storm.add(LINES.get(0));
+        for (int i = 0; i < 100; i++) {
+            storm.add("2026-09-12 10:01:" + String.format("%02d", i % 60)
+                      + ".000 ERROR 42 --- [ timer://tick] route1 : Failed to 
call the API");
+        }
+        storm.add(LINES.get(6));
+
+        JsonObject result = LogFileReader.build(storm, 50, null, null, new 
JsonObject());
+        assertEquals(3, result.getInteger("returnedLines"), "the storm is one 
record between the two others");
+        List<JsonObject> rows = List.copyOf(result.getCollection("lines"));
+        JsonObject folded = rows.get(1);
+        assertEquals(100, folded.getLong("repeated"));
+        assertEquals("10:01:00.000", folded.getString("firstTime"), "when the 
storm started");
+        assertEquals("10:01:39.000", folded.getString("time"), "the newest 
occurrence");
+        assertNull(rows.get(0).get("repeated"), "a line that happened once has 
no repeat count");
+    }
+
     @Test
     void filtersByLevelAndTextAndHonoursTheLimit() {
         JsonObject errors = LogFileReader.build(LINES, 50, null, "error", new 
JsonObject());
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorInfo.java
index ad94f33fe897..f5bd84c2f053 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorInfo.java
@@ -25,6 +25,9 @@ class ErrorInfo {
     String exchangeId;
     boolean handled;
     long timestamp;
+    /** How often this kind of error happened (CAMEL-24911); 1 when it only 
happened once. */
+    long repeatCount = 1;
+    long repeatFirstTimestamp;
     String location;
     String threadName;
     long elapsed;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
index bc39693b6517..7626a5e8ac65 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
@@ -343,12 +343,14 @@ class ErrorsTab extends AbstractTableTab {
                     Cell.from(Span.styled(ei.routeId != null ? ei.routeId : 
"", Style.EMPTY.fg(Theme.accent()))),
                     Cell.from(ei.nodeId != null ? ei.nodeId : ""),
                     Cell.from(Span.styled(handledStr, handledStyle)),
+                    Cell.from(ei.repeatCount > 1
+                            ? Span.styled(Long.toString(ei.repeatCount), 
Theme.error().bold()) : Span.raw("")),
                     Cell.from(shortException),
                     Cell.from(ei.exceptionMessage != null ? 
ei.exceptionMessage : "")));
         }
 
         if (rows.isEmpty()) {
-            rows.add(emptyRow("No errors captured", 7));
+            rows.add(emptyRow("No errors captured", 8));
         }
 
         ErrorInfo selectedError = null;
@@ -371,6 +373,7 @@ class ErrorsTab extends AbstractTableTab {
                         Cell.from(Span.styled(sortLabel("ROUTE", "route"), 
sortStyle("route"))),
                         Cell.from(Span.styled(sortLabel("NODE", "node"), 
sortStyle("node"))),
                         Cell.from(Span.styled("HANDLED", Style.EMPTY.bold())),
+                        Cell.from(Span.styled("COUNT", Style.EMPTY.bold())),
                         Cell.from(Span.styled(sortLabel("EXCEPTION", 
"exception"), sortStyle("exception"))),
                         Cell.from(Span.styled("MESSAGE", Style.EMPTY.bold()))))
                 .widths(
@@ -379,6 +382,7 @@ class ErrorsTab extends AbstractTableTab {
                         Constraint.length(20),
                         Constraint.length(20),
                         Constraint.length(8),
+                        Constraint.length(6),
                         Constraint.length(30),
                         Constraint.fill())
                 .highlightStyle(Theme.selectionBg())
@@ -442,12 +446,26 @@ class ErrorsTab extends AbstractTableTab {
         hint(spans, "w", "wrap [" + (wordWrap ? "on" : "off") + "]");
     }
 
+    /** How often this kind of error happened, and how long it has been going 
on (CAMEL-24911). */
+    private static void addRepeatLine(List<Line> lines, ErrorInfo ei) {
+        if (ei.repeatCount <= 1) {
+            return;
+        }
+        String since = ei.repeatFirstTimestamp > 0
+                ? " (since " + 
org.apache.camel.util.TimeUtils.printSince(ei.repeatFirstTimestamp) + " ago)" : 
"";
+        lines.add(Line.from(
+                Span.styled(" Repeated: ", Theme.muted()),
+                Span.styled(ei.repeatCount + " times", Theme.error().bold()),
+                Span.styled(since, Style.EMPTY.dim())));
+    }
+
     private void renderDetail(Frame frame, Rect area, ErrorInfo ei) {
         List<Line> lines = new ArrayList<>();
 
         HistoryTab.addExchangeInfoLines(lines,
                 ei.exchangeId, ei.routeId, ei.nodeId, null, ei.location,
                 ei.elapsed, ei.threadName, !ei.handled);
+        addRepeatLine(lines, ei);
 
         // exception with stack trace
         String exception = null;
@@ -662,6 +680,7 @@ class ErrorsTab extends AbstractTableTab {
         lines.add(Line.from(
                 Span.styled(" Handled:  ", Theme.muted()),
                 Span.styled(ei.handled ? "true" : "false", handledStyle)));
+        addRepeatLine(lines, ei);
 
         if (ei.exceptionType != null) {
             lines.add(Line.from(Span.raw("")));
@@ -794,6 +813,9 @@ class ErrorsTab extends AbstractTableTab {
             row.put("nodeId", ei.nodeId);
             row.put("exchangeId", ei.exchangeId);
             row.put("handled", ei.handled);
+            if (ei.repeatCount > 1) {
+                row.put("repeatCount", ei.repeatCount);
+            }
             row.put("timestamp", ei.timestamp);
             row.put("elapsed", ei.elapsed);
             if (ei.exceptionType != null) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogEntry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogEntry.java
index 7638d4d7da17..c051c50b45b9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogEntry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogEntry.java
@@ -22,4 +22,6 @@ class LogEntry {
     String level = "INFO";
     String logger;
     String message = "";
+    /** How many identical lines in a row this entry stands for (CAMEL-24911); 
1 for a line that happened once. */
+    int repeat = 1;
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
index 466016a1b664..e73134ca7304 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
@@ -24,6 +24,7 @@ import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -348,11 +349,12 @@ class LogTab extends AbstractTab {
                 if (!wordWrap) {
                     maxW = Math.max(maxW, 
CharWidth.of(TuiHelper.stripAnsi(raw)));
                 }
-                if (raw.indexOf('\u001B') >= 0) {
-                    built.add(TuiHelper.ansiToLine(raw, hSkip));
-                } else {
-                    built.add(colorizePlainLog(raw, entry));
+                Line line = raw.indexOf('\u001B') >= 0
+                        ? TuiHelper.ansiToLine(raw, hSkip) : 
colorizePlainLog(raw, entry);
+                if (entry.repeat > 1) {
+                    line = line.append(Span.styled("  (x" + entry.repeat + 
")", Theme.warning().bold()));
                 }
+                built.add(line);
             }
             cachedLogMaxWidth = maxW;
             cachedLogLines = built;
@@ -507,7 +509,7 @@ class LogTab extends AbstractTab {
                 changed = true;
                 List<LogEntry> olderEntries = new ArrayList<>();
                 for (String line : olderLines) {
-                    olderEntries.add(parseLogLine(line));
+                    addFolded(olderEntries, parseLogLine(line));
                 }
                 mutableFilteredEntries.addAll(0, olderEntries);
                 logTotalLinesRead += olderLines.size();
@@ -520,7 +522,7 @@ class LogTab extends AbstractTab {
         if (changed) {
             logTotalLinesRead += newRawLines.size();
             for (String line : newRawLines) {
-                mutableFilteredEntries.add(parseLogLine(line));
+                addFolded(mutableFilteredEntries, parseLogLine(line));
             }
             if (mutableFilteredEntries.size() > MAX_LOG_LINES) {
                 mutableFilteredEntries.subList(0, 
mutableFilteredEntries.size() - MAX_LOG_LINES)
@@ -673,6 +675,24 @@ class LogTab extends AbstractTab {
                 Span.raw(" " + message));
     }
 
+    /**
+     * Adds a log line, folding it into the one before it when they only 
differ in their timestamp, so a storm of the
+     * same failure is one line with a count instead of a screen that hides 
everything else (CAMEL-24911).
+     */
+    static void addFolded(List<LogEntry> entries, LogEntry entry) {
+        LogEntry last = entries.isEmpty() ? null : entries.get(entries.size() 
- 1);
+        if (last != null && last.level.equals(entry.level)
+                && Objects.equals(last.logger, entry.logger)
+                && last.message.equals(entry.message)
+                && !entry.message.isEmpty()) {
+            // keep the newest line, so its timestamp is the last time this 
happened
+            entry.repeat = last.repeat + 1;
+            entries.set(entries.size() - 1, entry);
+        } else {
+            entries.add(entry);
+        }
+    }
+
     static LogEntry parseLogLine(String line) {
         LogEntry entry = new LogEntry();
         entry.raw = line;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
index 38a6a42692c0..21f50203c370 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
@@ -1415,6 +1415,12 @@ final class StatusParser {
             ei.nodeId = ej.getString("nodeId");
             ei.exchangeId = ej.getString("exchangeId");
             ei.handled = Boolean.TRUE.equals(ej.get("handled"));
+            Long rc = ej.getLong("repeatCount");
+            ei.repeatCount = rc != null ? rc : 1;
+            Long rf = ej.getLong("repeatFirstTimestamp");
+            if (rf != null) {
+                ei.repeatFirstTimestamp = rf;
+            }
             Long ts = ej.getLong("timestamp");
             if (ts != null) {
                 ei.timestamp = ts;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
index 07c4310aef9d..30bf5da8c013 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTabRenderTest.java
@@ -158,6 +158,20 @@ class ErrorsTabRenderTest {
                 "Should show placeholder when no errors exist");
     }
 
+    @Test
+    void renderShowsTheRepeatCountOfAStorm() {
+        addError("ID-001", "route1", "to1", "IOException", "Connection 
refused", false);
+        info.errors.get(0).repeatCount = 188;
+        info.errors.get(0).repeatFirstTimestamp = System.currentTimeMillis() - 
60000;
+        addError("ID-002", "route2", "to2", "IllegalStateException", "boom", 
false);
+
+        ErrorsTab tab = new ErrorsTab(ctx);
+        String rendered = renderToString(tab, 160, 30);
+
+        assertTrue(rendered.contains("COUNT"), "Should show COUNT header");
+        assertTrue(rendered.contains("188"), "Should show how often this kind 
of error happened");
+    }
+
     @Test
     void renderShowsErrorCount() {
         addError("ID-001", "r1", "n1", "Ex", "m1", false);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
index 6b79e1791e27..e7b9ae0bece0 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
@@ -24,6 +24,7 @@ import dev.tamboui.text.Span;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -47,6 +48,25 @@ class LogTabRenderTest {
         ctx.selectedPid = "1234";
     }
 
+    @Test
+    void foldsAStormOfTheSameLineIntoOneEntry() {
+        List<LogEntry> entries = new ArrayList<>();
+        LogTab.addFolded(entries, LogTab.parseLogLine(
+                "2026-09-22 10:00:00.001  INFO 42 --- [           main] route1 
: Started"));
+        for (int i = 0; i < 100; i++) {
+            LogTab.addFolded(entries, LogTab.parseLogLine(
+                    "2026-09-22 10:01:" + String.format("%02d", i % 60)
+                                                          + ".000 ERROR 42 --- 
[ timer://tick] route1 : Failed to call the API"));
+        }
+        LogTab.addFolded(entries, LogTab.parseLogLine(
+                "2026-09-22 10:02:00.001  INFO 42 --- [           main] route1 
: Done"));
+
+        assertEquals(3, entries.size(), "the storm is one entry between the 
two others");
+        assertEquals(100, entries.get(1).repeat);
+        assertEquals("10:01:39.000", entries.get(1).time, "the entry keeps the 
newest timestamp");
+        assertEquals(1, entries.get(0).repeat, "a line that happened once is 
not a repeat");
+    }
+
     @Test
     void renderNoSelectionShowsPrompt() {
         ctx.selectedPid = null;

Reply via email to