You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/04 06:02:08 UTC

(camel) branch fix/CAMEL-25296 updated (64edd53b17f0 -> bb54321b5f48)

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

davsclaus pushed a change to branch fix/CAMEL-25296
in repository https://gitbox.apache.org/repos/asf/camel.git


    omit 64edd53b17f0 CAMEL-25296: regenerate eip-samples.json (rest-dsl-openapi.adoc changed on main by CAMEL-24649)
    omit bc38a987d084 CAMEL-25296: camel-jbang - camel_get_log leaves out stack traces by default, keeping the error line
     add 38581a08b446 CAMEL-25274: camel-dapr - acknowledge a pub/sub event when the exchange is done, and ask for a redelivery when it failed (#27303)
     add ac19d5aac129 chore: camel-jbang - the contract-first Rest DSL ranks as a rest-openapi sample again (main build) (#27328)
     new bb54321b5f48 CAMEL-25296: camel-jbang - camel_get_log leaves out stack traces by default, keeping the error line

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (64edd53b17f0)
            \
             N -- N -- N   refs/heads/fix/CAMEL-25296 (bb54321b5f48)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../dapr/consumer/DaprPubSubConsumer.java          | 25 +++++--
 .../dapr/consumer/DaprPubSubConsumerTest.java      | 85 +++++++++++++++++++++-
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 10 +++
 .../dsl/jbang/core/commands/ai/CatalogSamples.java | 13 +++-
 .../jbang/core/commands/ai/CatalogSamplesTest.java |  6 +-
 5 files changed, 128 insertions(+), 11 deletions(-)


(camel) 01/01: CAMEL-25296: camel-jbang - camel_get_log leaves out stack traces by default, keeping the error line

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-25296
in repository https://gitbox.apache.org/repos/asf/camel.git

commit bb54321b5f48b7bd7889ef6ae057a7d56b587485
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sat Oct 3 22:22:27 2026 +0200

    CAMEL-25296: camel-jbang - camel_get_log leaves out stack traces by default, keeping the error line
    
    An error handler record is about forty lines of message history and stack
    trace behind a first line that already names the route, the source line and
    the exception; camel_get_errors has the stack trace of every failed exchange.
    Without details the record keeps that first line and level, detailLines says
    how much was left out, cause names the exception when the first line does
    not, and at is the first frame of the user's own code (a bean, a processor,
    a script), looked for from the root cause up since a Caused by section can
    end in "... N more". Only stack traces and message history are left out; a
    pretty-printed body stays a detail block. The filter still searches the
    whole record. details=true returns everything as before.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |   9 +-
 .../dsl/jbang/core/commands/ai/AuthoringTools.java |   7 +-
 .../dsl/jbang/core/commands/ai/LogFileReader.java  | 159 ++++++++++++++++++++-
 .../jbang/core/commands/ai/LogFileReaderTest.java  | 144 +++++++++++++++++++
 .../jbang/core/commands/mcp/AuthoringTools.java    |  10 +-
 .../core/commands/mcp/AuthoringToolsTest.java      |   2 +-
 6 files changed, 315 insertions(+), 16 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index e93680c9af0b..9ba4fd6371a6 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -360,8 +360,13 @@ project `directory` as an argument, the runtime tools take the integration `name
   `stop-routes`, `start-routes`, `reset-stats`.
 
 | `camel_get_log`
-| Recent log records of a running integration, newest first, filtered by level or text; a stack trace comes
-  as one record with a `detail` block.
+| Recent log records of a running integration, newest first, filtered by level or text. An error keeps its
+  first line, which names the route, the source line and the exception; its stack trace is left out, with the
+  number of lines left out in `detailLines`, the exception as `cause` when the first line does not name it, and
+  as `at` the first frame of the user's own code, looked for from the root cause up (a bean, a processor, a
+  script), so a `NullPointerException` without a message still says where it happened.
+  `details=true` returns the stack traces; `camel_get_errors` has them for every failed exchange. Other
+  multi-line text, such as a pretty-printed body, stays as a `detail` block.
 
 | `camel_get_errors`
 | The failed exchanges of a running integration: route, exchange, exception with stack trace, body and headers.
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index c529be10c390..ec5879904a42 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -287,17 +287,18 @@ public final class AuthoringTools {
                 }));
 
         registry.accept(tool("camel_get_log",
-                "Recent log records of a running integration, newest first, with optional filtering; a stack trace "
-                                              + "comes as one record with a detail block.")
+                "Recent log records of a running integration, newest first, with optional filtering; stack traces "
+                                              + "only with details.")
                 .param("name", "string", NAME_DESC, false)
                 .param("limit", "integer", "Maximum records to return (default 50)", false)
                 .param("filter", "string", "Case-insensitive substring filter on the message", false)
                 .param("level", "string", "Only this log level (INFO, WARN, ERROR, DEBUG, TRACE)", false)
+                .param("details", "boolean", "Include the stack traces (default false)", false)
                 .core(true)
                 .executor((ctx, args) -> {
                     RuntimeHelper.ProcessInfo p = selectProcess(ctx, args);
                     return LogFileReader.read(ctx.pid(), p != null ? p.name() : null, integer(args, "limit", 50),
-                            args.get("filter"), args.get("level")).toJson();
+                            args.get("filter"), args.get("level"), bool(args, "details", false)).toJson();
                 }));
 
         registry.accept(tool("camel_get_errors",
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 d648b2f7f5f4..97deb8002518 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
@@ -53,6 +53,29 @@ public final class LogFileReader {
 
     private static final Pattern ANSI = Pattern.compile("\\u001b\\[[;\\d]*[ -/]*[@-~]");
 
+    /** A line of a stack trace or of the error handler's message history: what is left out without details. */
+    private static final Pattern TRACE_LINE = Pattern.compile("^\\s+at \\S+\\(.*\\)$|^Caused by: |^\\s*\\.\\.\\. \\d+ more$"
+                                                              + "|^Message History|^Stacktrace$");
+
+    /** The line that names an exception and its message: {@code java.net.ConnectException: supplier unreachable}. */
+    private static final Pattern EXCEPTION_LINE = Pattern.compile(
+            "^(?:Caused by: )?((?:[a-zA-Z_$][\\w$]*\\.)+[\\w$]*(?:Exception|Error|Throwable)\\b.*)$");
+
+    /** A frame of a stack trace: {@code at org.example.OrderBean.process(OrderBean.java:42)}. */
+    private static final Pattern FRAME = Pattern.compile("^\\tat (\\S+\\(.*\\))$");
+
+    /** Frames of the runtime, not of the user's code: Camel, the JDK, Groovy, Vert.x and the like. */
+    private static final List<String> RUNTIME_PACKAGES = List.of(
+            "org.apache.camel.", "java.", "javax.", "jakarta.", "jdk.", "sun.", "com.sun.", "groovy.",
+            "org.codehaus.groovy.", "org.apache.groovy.", "io.vertx.", "io.netty.", "io.smallrye.", "io.quarkus.",
+            "org.springframework.", "com.fasterxml.", "org.jboss.", "kotlin.");
+
+    /** Internal fields of a record, removed before it is returned. */
+    private static final String TRACE = "_trace";
+    private static final String CAUSE = "_cause";
+    private static final String ORIGIN = "_origin";
+    private static final String DETAIL_LINES = "_detailLines";
+
     private LogFileReader() {
     }
 
@@ -78,6 +101,20 @@ public final class LogFileReader {
      * @return        lines (newest first), totalLines and returnedLines, or an error when there is no log file
      */
     public static JsonObject read(long pid, String name, int limit, String filter, String level) {
+        return read(pid, name, limit, filter, level, true);
+    }
+
+    /**
+     * @param  pid     the process
+     * @param  name    the integration name, for the log file fallback
+     * @param  limit   maximum records to return
+     * @param  filter  case-insensitive substring the message or detail must contain; null for all
+     * @param  level   only records of this level (INFO, WARN, ERROR, DEBUG, TRACE); null for all
+     * @param  details whether a stack trace stays in its record; without, an error keeps its first line, the number of
+     *                 lines left out and the exception as {@code cause} when the first line does not name it
+     * @return         lines (newest first), totalLines and returnedLines, or an error when there is no log file
+     */
+    public static JsonObject read(long pid, String name, int limit, String filter, String level, boolean details) {
         Path file = logFile(pid, name);
         JsonObject result = new JsonObject();
         result.put("file", file.toString());
@@ -93,11 +130,22 @@ public final class LogFileReader {
             result.put("error", "Cannot read " + file + ": " + e.getMessage());
             return result;
         }
-        return build(lines, limit, filter, level, result);
+        return build(lines, limit, filter, level, details, result);
     }
 
-    /** Groups raw lines into records and filters them, newest first; visible for tests. */
+    /** Groups raw lines into records and filters them, newest first, stack traces included; visible for tests. */
     static JsonObject build(List<String> lines, int limit, String filter, String level, JsonObject result) {
+        return build(lines, limit, filter, level, true, result);
+    }
+
+    /**
+     * Groups raw lines into records and filters them, newest first; visible for tests. Without details a stack trace is
+     * left out of its record (CAMEL-25296): an error handler record is about forty lines of message history and stack
+     * trace behind a first line that already names the route, the source line and the exception, and camel_get_errors
+     * has the stack trace of every failed exchange. Other multi-line text, such as a pretty-printed body, is kept.
+     */
+    static JsonObject build(
+            List<String> lines, int limit, String filter, String level, boolean details, JsonObject result) {
         List<JsonObject> records = fold(toRecords(lines));
         String needle = filter == null || filter.isBlank() ? null : filter.toLowerCase();
         JsonArray rows = new JsonArray();
@@ -113,14 +161,45 @@ public final class LogFileReader {
                     continue;
                 }
             }
-            rows.add(r);
+            rows.add(details ? r : withoutTrace(r));
+        }
+        int left = 0;
+        for (Object o : rows) {
+            JsonObject r = (JsonObject) o;
+            if (r.containsKey("detailLines")) {
+                left++;
+            }
+            r.remove(TRACE);
+            r.remove(CAUSE);
+            r.remove(ORIGIN);
+            r.remove(DETAIL_LINES);
         }
         result.put("lines", rows);
         result.put("totalLines", lines.size());
         result.put("returnedLines", rows.size());
+        if (left > 0) {
+            result.put("note", "The stack traces of " + left + " record(s) are left out (detailLines says how many lines):"
+                               + " details=true returns them, camel_get_errors has the failed exchanges.");
+        }
         return result;
     }
 
+    private static JsonObject withoutTrace(JsonObject r) {
+        if (!Boolean.TRUE.equals(r.get(TRACE))) {
+            return r;
+        }
+        r.remove("detail");
+        r.put("detailLines", r.get(DETAIL_LINES));
+        Object cause = r.get(CAUSE);
+        if (cause instanceof String c && !r.getStringOrDefault("message", "").contains(c)) {
+            r.put("cause", c);
+        }
+        if (r.get(ORIGIN) instanceof String origin) {
+            r.put("at", origin);
+        }
+        return r;
+    }
+
     private static List<String> tail(Path file) throws IOException {
         try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
             long length = raf.length();
@@ -149,11 +228,32 @@ public final class LogFileReader {
         JsonObject head = null;
         List<String> detail = null;
         int hidden = 0;
+        boolean trace = false;
+        String cause = null;
+        // per exception of the trace, outermost first: the first frame of the user's code in it, or null
+        List<String> origins = new ArrayList<>();
         for (String raw : lines) {
             String line = ANSI.matcher(raw).replaceAll("");
             Matcher m = LOG_PATTERN.matcher(line);
             boolean matches = m.matches();
             if (!matches && head != null) {
+                // the whole block counts for what it is and what threw, also past the lines that are kept
+                trace |= TRACE_LINE.matcher(line).find();
+                Matcher ex = EXCEPTION_LINE.matcher(line);
+                if (ex.matches() && (cause == null || line.startsWith("Caused by: "))) {
+                    // the first exception line, unless a Caused by follows: the last one is the root cause
+                    cause = ex.group(1);
+                    origins.add(null);
+                } else if (!origins.isEmpty() && origins.get(origins.size() - 1) == null) {
+                    // a frame directly under its exception (a Suppressed block is indented further and skipped)
+                    Matcher frame = FRAME.matcher(line);
+                    if (frame.matches()) {
+                        String f = withoutModule(frame.group(1));
+                        if (isUserCode(f)) {
+                            origins.set(origins.size() - 1, f);
+                        }
+                    }
+                }
                 if (detail.size() < MAX_DETAIL_LINES) {
                     detail.add(line);
                 } else {
@@ -162,7 +262,7 @@ public final class LogFileReader {
                 continue;
             }
             if (head != null) {
-                records.add(finish(head, detail, hidden));
+                records.add(finish(head, detail, hidden, trace, cause, origin(origins)));
             }
             head = new JsonObject();
             if (matches) {
@@ -180,9 +280,12 @@ public final class LogFileReader {
             }
             detail = new ArrayList<>();
             hidden = 0;
+            trace = false;
+            cause = null;
+            origins = new ArrayList<>();
         }
         if (head != null) {
-            records.add(finish(head, detail, hidden));
+            records.add(finish(head, detail, hidden, trace, cause, origin(origins)));
         }
         return records;
     }
@@ -217,8 +320,52 @@ public final class LogFileReader {
                 && Objects.equals(a.get("detail"), b.get("detail"));
     }
 
-    private static JsonObject finish(JsonObject head, List<String> detail, int hidden) {
+    /**
+     * Where it went wrong in the user's code: the root cause is printed last, so walk from the bottom. A Caused by
+     * section ends with "... N more" for the frames it shares with the exception above it, which may be the very frame
+     * of the user's code, so a section without one hands over to the one above.
+     */
+    private static String origin(List<String> origins) {
+        for (int i = origins.size() - 1; i >= 0; i--) {
+            if (origins.get(i) != null) {
+                return origins.get(i);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * A frame without its module or class loader: {@code java.base/java.lang.Thread.run(..)},
+     * {@code app//org.example..}.
+     */
+    private static String withoutModule(String frame) {
+        int paren = frame.indexOf('(');
+        int slash = frame.lastIndexOf('/', paren < 0 ? frame.length() : paren);
+        return slash < 0 ? frame : frame.substring(slash + 1);
+    }
+
+    private static boolean isUserCode(String frame) {
+        for (String p : RUNTIME_PACKAGES) {
+            if (frame.startsWith(p)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static JsonObject finish(
+            JsonObject head, List<String> detail, int hidden, boolean trace, String cause, String origin) {
         if (!detail.isEmpty()) {
+            if (trace) {
+                head.put(TRACE, Boolean.TRUE);
+                head.put(DETAIL_LINES, detail.size() + hidden);
+                if (cause != null) {
+                    head.put(CAUSE, cause);
+                }
+                if (origin != null) {
+                    head.put(ORIGIN, origin);
+                }
+            }
             String text = String.join("\n", detail);
             if (hidden > 0) {
                 text += "\n... " + hidden + " more lines";
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 543023809ad7..2958961f458c 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
@@ -83,4 +83,148 @@ class LogFileReaderTest {
         assertEquals(2, LogFileReader.build(LINES, 2, null, null, new JsonObject()).getInteger("returnedLines"));
         assertEquals(0, LogFileReader.build(List.of(), 2, null, null, new JsonObject()).getInteger("returnedLines"));
     }
+
+    @Test
+    void withoutDetailsAnErrorKeepsItsFirstLineAndTheCause() {
+        JsonObject result = LogFileReader.build(LINES, 50, null, null, false, new JsonObject());
+        List<JsonObject> rows = List.copyOf(result.getCollection("lines"));
+        JsonObject error = rows.get(1);
+        assertEquals("ERROR", error.getString("level"));
+        assertEquals("Failed delivery", error.getString("message"));
+        assertNull(error.get("detail"), "the stack trace is left out");
+        assertEquals(3, error.getInteger("detailLines"));
+        assertEquals("java.lang.IllegalStateException: boom", error.getString("cause"),
+                "the first line does not name the exception, so the cause does");
+        assertTrue(result.getString("note").contains("details=true"));
+        for (JsonObject row : rows) {
+            assertTrue(row.keySet().stream().noneMatch(k -> k.startsWith("_")), "no internal field leaks: " + row);
+        }
+    }
+
+    @Test
+    void theErrorHandlerLineAlreadyNamesTheExceptionSoThereIsNoCause() {
+        List<String> lines = List.of(
+                "2026-10-03 08:29:42.988 ERROR 42 --- [- timer://stock] or.errorhandler.DefaultErrorHandler : Failed delivery"
+                                     + " for (MessageId: A on ExchangeId: A) at stock-check[throwException1] cb.camel.yaml:27."
+                                     + " Exhausted after delivery attempt: 1 caught: java.net.ConnectException: supplier"
+                                     + " unreachable",
+                "Message History (source location and message history is enabled)",
+                "---------------------------------------------------------------------------------------------------",
+                "Source                    ID                             Processor                      Elapsed (ms)",
+                "cb.camel.yaml:4           stock-check/stock-check        from[timer://stock?period=1000]           0",
+                "cb.camel.yaml:27          stock-check/throwException1    throwException[java.net.ConnectException] 0",
+                "Stacktrace",
+                "---------------------------------------------------------------------------------------------------",
+                "java.net.ConnectException: supplier unreachable",
+                "\tat org.apache.camel.processor.ThrowExceptionProcessor.process(ThrowExceptionProcessor.java:68)",
+                "\tat org.apache.camel.processor.Pipeline.process(Pipeline.java:163)");
+
+        JsonObject error = (JsonObject) LogFileReader.build(lines, 50, null, null, false, new JsonObject())
+                .getCollection("lines").iterator().next();
+        assertTrue(error.getString("message").endsWith("caught: java.net.ConnectException: supplier unreachable"));
+        assertNull(error.get("detail"));
+        assertNull(error.get("cause"), "the first line has it already");
+        assertNull(error.get("at"), "a YAML route has no frame of the user's code; the first line has the source line");
+        assertEquals(10, error.getInteger("detailLines"));
+    }
+
+    @Test
+    void theCauseIsTheLastCausedByAlsoPastTheLinesThatAreKept() {
+        List<String> lines = new java.util.ArrayList<>();
+        lines.add("2026-10-03 08:00:00.000 ERROR 42 --- [ main] route1 : Failed to start");
+        lines.add("org.apache.camel.RuntimeCamelException: wrapped");
+        for (int i = 0; i < 30; i++) {
+            lines.add("\tat org.example.Frame.m" + i + "(Frame.java:" + i + ")");
+        }
+        lines.add("Caused by: java.io.IOException: disk full");
+        lines.add("\tat org.example.Disk.write(Disk.java:1)");
+
+        JsonObject error = (JsonObject) LogFileReader.build(lines, 50, null, null, false, new JsonObject())
+                .getCollection("lines").iterator().next();
+        assertEquals("java.io.IOException: disk full", error.getString("cause"));
+        assertEquals(33, error.getInteger("detailLines"));
+    }
+
+    @Test
+    void aPrettyPrintedBodyIsNotAStackTraceAndStays() {
+        List<String> lines = List.of(
+                "2026-10-03 08:00:00.000  INFO 42 --- [ timer://order] route1 : Order ORD-1001: {",
+                "  \"orderId\" : \"ORD-1001\",",
+                "  \"country\" : \"DK\"",
+                "}");
+
+        JsonObject result = LogFileReader.build(lines, 50, null, null, false, new JsonObject());
+        JsonObject row = (JsonObject) result.getCollection("lines").iterator().next();
+        assertTrue(row.getString("detail").contains("\"orderId\" : \"ORD-1001\""));
+        assertNull(row.get("detailLines"));
+        assertNull(result.get("note"), "nothing was left out");
+    }
+
+    @Test
+    void withDetailsTheRecordIsAsBeforeAndTheFilterSearchesTheLeftOutTrace() {
+        JsonObject error = (JsonObject) LogFileReader.build(LINES, 50, null, "error", true, new JsonObject())
+                .getCollection("lines").iterator().next();
+        assertTrue(error.getString("detail").startsWith("java.lang.IllegalStateException: boom"));
+        assertNull(error.get("detailLines"));
+        assertNull(error.get("cause"));
+
+        JsonObject boom = LogFileReader.build(LINES, 50, "boom", null, false, new JsonObject());
+        assertEquals(1, boom.getInteger("returnedLines"), "the filter matches the trace even when it is left out");
+    }
+
+    @Test
+    void theOriginIsTheUsersFrameOfTheRootCauseUnderTheWrapping() {
+        List<String> lines = List.of(
+                "2026-10-03 09:00:00.000 ERROR 42 --- [ timer://tick] route1 : Failed to invoke the bean",
+                "org.apache.camel.RuntimeCamelException: Error invoking method",
+                "\tat org.apache.camel.util.ObjectHelper.wrapRuntimeCamelException(ObjectHelper.java:21)",
+                "\tat org.apache.camel.component.bean.BeanProcessor.process(BeanProcessor.java:81)",
+                "Caused by: java.lang.reflect.InvocationTargetException",
+                "\tat java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:118)",
+                "\t... 2 more",
+                "Caused by: java.lang.NullPointerException",
+                "\tat app//camel.example.OrderBean.total(OrderBean.java:42)",
+                "\tat app//camel.example.OrderBean.process(OrderBean.java:17)",
+                "\t... 4 more");
+
+        JsonObject error = (JsonObject) LogFileReader.build(lines, 50, null, null, false, new JsonObject())
+                .getCollection("lines").iterator().next();
+        assertEquals("java.lang.NullPointerException", error.getString("cause"));
+        assertEquals("camel.example.OrderBean.total(OrderBean.java:42)", error.getString("at"),
+                "the top frame of the root cause, without its class loader prefix");
+    }
+
+    @Test
+    void aRootCauseCutShortByMoreHandsOverToTheExceptionAboveIt() {
+        List<String> lines = List.of(
+                "2026-10-03 09:00:00.000 ERROR 42 --- [ timer://tick] route1 : Order failed",
+                "java.lang.IllegalStateException: order failed",
+                "\tat camel.example.OrderBean.process(OrderBean.java:17)",
+                "\tat org.apache.camel.component.bean.BeanProcessor.process(BeanProcessor.java:81)",
+                "Caused by: java.lang.NumberFormatException: For input string: \"x\"",
+                "\tat java.base/java.lang.Integer.parseInt(Integer.java:661)",
+                "\t... 2 more");
+
+        JsonObject error = (JsonObject) LogFileReader.build(lines, 50, null, null, false, new JsonObject())
+                .getCollection("lines").iterator().next();
+        assertEquals("java.lang.NumberFormatException: For input string: \"x\"", error.getString("cause"));
+        assertEquals("camel.example.OrderBean.process(OrderBean.java:17)", error.getString("at"),
+                "the JDK frame of the root cause is not the user's code, and the frame that called it is above");
+    }
+
+    @Test
+    void aGroovyScriptIsTheUsersCode() {
+        List<String> lines = List.of(
+                "2026-10-03 09:00:00.000  WARN 42 --- [ timer://orders] TimerConsumer : Error processing exchange",
+                "groovy.lang.MissingPropertyException: No such property: header for class: Script1",
+                "\tat org.codehaus.groovy.runtime.ScriptBytecodeAdapter.unwrap(ScriptBytecodeAdapter.java:65)",
+                "\tat Script1.run(Script1.groovy:3)",
+                "\tat org.apache.camel.language.groovy.GroovyExpression.evaluate(GroovyExpression.java:72)");
+
+        JsonObject warn = (JsonObject) LogFileReader.build(lines, 50, null, null, false, new JsonObject())
+                .getCollection("lines").iterator().next();
+        assertEquals("Script1.run(Script1.groovy:3)", warn.getString("at"));
+        assertEquals("groovy.lang.MissingPropertyException: No such property: header for class: Script1",
+                warn.getString("cause"));
+    }
 }
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index 5423c7d2ad9e..e6fc218efa89 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -206,14 +206,16 @@ public class AuthoringTools {
     }
 
     @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false),
-          description = "Recent log records of a running integration, newest first, with optional filtering; a "
-                        + "stack trace comes as one record with a detail block.")
+          description = "Recent log records of a running integration, newest first, with optional filtering; stack "
+                        + "traces only with details.")
     public JsonObject camel_get_log(
             @ToolArg(description = NAME_DESC, required = false) String name,
             @ToolArg(description = "Maximum records to return (default 50)", required = false) Integer limit,
             @ToolArg(description = "Case-insensitive substring filter on the message", required = false) String filter,
-            @ToolArg(description = "Only this log level (INFO, WARN, ERROR, DEBUG, TRACE)", required = false) String level) {
-        return call("camel_get_log", args("name", name, "limit", limit, "filter", filter, "level", level));
+            @ToolArg(description = "Only this log level (INFO, WARN, ERROR, DEBUG, TRACE)", required = false) String level,
+            @ToolArg(description = "Include the stack traces (default false)", required = false) Boolean details) {
+        return call("camel_get_log", args("name", name, "limit", limit, "filter", filter, "level", level,
+                "details", details));
     }
 
     @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false),
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
index 07613b2420a9..6ddf954e7b9c 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
@@ -113,7 +113,7 @@ class AuthoringToolsTest {
     void anUnknownIntegrationNameIsAnError() {
         assertThatThrownBy(() -> tools.camel_eval_expression("${body}", null, "camel", "no-such-app-xyz-1"))
                 .isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
-        assertThatThrownBy(() -> tools.camel_get_log("no-such-app-xyz-1", null, null, null))
+        assertThatThrownBy(() -> tools.camel_get_log("no-such-app-xyz-1", null, null, null, null))
                 .isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
     }