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;