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

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


The following commit(s) were added to refs/heads/main by this push:
     new 70bd50a379 Enhance logging to include stack traces and associated 
throwables in log messages (#7682)
70bd50a379 is described below

commit 70bd50a37999cea311ad102fd71ac16f80ea00a8
Author: kxs-mtalbi <[email protected]>
AuthorDate: Thu Jul 30 04:44:41 2026 -0400

    Enhance logging to include stack traces and associated throwables in log 
messages (#7682)
---
 .../hop/core/logging/FixedWidthLogLayout.java      | 11 ++++-
 .../org/apache/hop/core/logging/HopLogLayout.java  | 11 ++++-
 .../org/apache/hop/core/logging/ILogMessage.java   | 10 ++++
 .../org/apache/hop/core/logging/LogChannel.java    | 24 +++++++---
 .../org/apache/hop/core/logging/LogMessage.java    | 35 ++++++++++++++
 .../core/logging/Slf4jLoggingEventListener.java    | 19 +++++++-
 .../hop/core/logging/FixedWidthLogLayoutTest.java  | 53 ++++++++++++++++++++++
 .../apache/hop/core/logging/HopLogLayoutTest.java  | 29 ++++++++++++
 .../apache/hop/core/logging/LogChannelTest.java    | 27 +++++++++++
 .../apache/hop/core/logging/LogMessageTest.java    | 39 ++++++++++++++++
 .../java/org/apache/hop/git/GitPerspective.java    | 34 ++++++++------
 11 files changed, 266 insertions(+), 26 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/core/logging/FixedWidthLogLayout.java 
b/core/src/main/java/org/apache/hop/core/logging/FixedWidthLogLayout.java
index 4d76d74d6f..b7a34f55a6 100644
--- a/core/src/main/java/org/apache/hop/core/logging/FixedWidthLogLayout.java
+++ b/core/src/main/java/org/apache/hop/core/logging/FixedWidthLogLayout.java
@@ -54,8 +54,15 @@ public class FixedWidthLogLayout {
 
       String logLevel = getLogLevelPadded(message.getLevel());
 
-      String[] parts =
-          message.getMessage() == null ? new String[] {} : 
message.getMessage().split(Const.CR);
+      // Append the message's stack trace (pre-rendered, or rendered on demand 
from an attached
+      // throwable) so the GUI log browser still shows it.
+      String text = message.getMessage();
+      String stackTrace = message.getStackTrace();
+      if (stackTrace != null && !stackTrace.isEmpty()) {
+        text = (text == null || text.isEmpty()) ? stackTrace : text + Const.CR 
+ stackTrace;
+      }
+
+      String[] parts = text == null ? new String[] {} : text.split(Const.CR);
 
       for (int i = 0; i < parts.length; i++) {
         if (!message.isSimplified()) {
diff --git a/core/src/main/java/org/apache/hop/core/logging/HopLogLayout.java 
b/core/src/main/java/org/apache/hop/core/logging/HopLogLayout.java
index 8112870ac7..87c03ade41 100644
--- a/core/src/main/java/org/apache/hop/core/logging/HopLogLayout.java
+++ b/core/src/main/java/org/apache/hop/core/logging/HopLogLayout.java
@@ -57,8 +57,15 @@ public class HopLogLayout {
     Object object = event.getMessage();
     if (object instanceof LogMessage message) {
 
-      String[] parts =
-          message.getMessage() == null ? new String[] {} : 
message.getMessage().split(Const.CR);
+      // Append the message's stack trace (pre-rendered, or rendered on demand 
from an attached
+      // throwable) so console/file output still shows it.
+      String text = message.getMessage();
+      String stackTrace = message.getStackTrace();
+      if (stackTrace != null && !stackTrace.isEmpty()) {
+        text = (text == null || text.isEmpty()) ? stackTrace : text + Const.CR 
+ stackTrace;
+      }
+
+      String[] parts = text == null ? new String[] {} : text.split(Const.CR);
       for (int i = 0; i < parts.length; i++) {
         // Start every line of the output with a dateTimeString
         if (!message.isSimplified()) {
diff --git a/core/src/main/java/org/apache/hop/core/logging/ILogMessage.java 
b/core/src/main/java/org/apache/hop/core/logging/ILogMessage.java
index 177de9c2d1..756261bf5f 100644
--- a/core/src/main/java/org/apache/hop/core/logging/ILogMessage.java
+++ b/core/src/main/java/org/apache/hop/core/logging/ILogMessage.java
@@ -31,4 +31,14 @@ public interface ILogMessage {
   Object[] getArguments();
 
   boolean isSimplified();
+
+  /**
+   * The throwable associated with this message, if any, so structured 
listeners can render
+   * dedicated error fields instead of a pre-stringified stack-trace event.
+   *
+   * @return the associated throwable, or {@code null} when none.
+   */
+  default Throwable getThrowable() {
+    return null;
+  }
 }
diff --git a/core/src/main/java/org/apache/hop/core/logging/LogChannel.java 
b/core/src/main/java/org/apache/hop/core/logging/LogChannel.java
index af7c13c394..34f876e577 100644
--- a/core/src/main/java/org/apache/hop/core/logging/LogChannel.java
+++ b/core/src/main/java/org/apache/hop/core/logging/LogChannel.java
@@ -144,12 +144,24 @@ public class LogChannel implements ILogChannel {
   }
 
   public void println(ILogMessage message, Throwable e, LogLevel 
channelLogLevel) {
-    println(message, channelLogLevel);
-
-    String stackTrace = Const.getStackTracker(e);
-    LogMessage traceMessage =
-        new LogMessage(stackTrace, message.getLogChannelId(), LogLevel.ERROR, 
simplified);
-    println(traceMessage, channelLogLevel);
+    if (message instanceof LogMessage logMessage) {
+      if (e != null) {
+        // Pre-render the trace (bounded memory), attach the throwable for 
SLF4J, and bump to ERROR.
+        logMessage.setStackTrace(Const.getStackTracker(e));
+        logMessage.setThrowable(e);
+        logMessage.setLevel(LogLevel.ERROR);
+      }
+      println(message, channelLogLevel);
+      // Release the throwable so the buffered event doesn't retain the 
exception graph.
+      logMessage.setThrowable(null);
+    } else {
+      // Non-standard ILogMessage can't carry a throwable: fall back to the 
legacy two-event path.
+      println(message, channelLogLevel);
+      String stackTrace = Const.getStackTracker(e);
+      LogMessage traceMessage =
+          new LogMessage(stackTrace, message.getLogChannelId(), 
LogLevel.ERROR, simplified);
+      println(traceMessage, channelLogLevel);
+    }
   }
 
   public void logWithLevel(String s, LogLevel logMessageLevel) {
diff --git a/core/src/main/java/org/apache/hop/core/logging/LogMessage.java 
b/core/src/main/java/org/apache/hop/core/logging/LogMessage.java
index 8c0a38976f..e4852d45e7 100644
--- a/core/src/main/java/org/apache/hop/core/logging/LogMessage.java
+++ b/core/src/main/java/org/apache/hop/core/logging/LogMessage.java
@@ -33,6 +33,8 @@ public class LogMessage implements ILogMessage {
   private LogLevel level;
   private String copy;
   private boolean simplified;
+  private Throwable throwable;
+  private String stackTrace;
 
   /** Backward compatibility : no registry used, just log the subject as part 
of the message */
   public LogMessage(String subject, LogLevel level) {
@@ -215,4 +217,37 @@ public class LogMessage implements ILogMessage {
   public void setSimplified(boolean simplified) {
     this.simplified = simplified;
   }
+
+  @Override
+  public Throwable getThrowable() {
+    return throwable;
+  }
+
+  public void setThrowable(Throwable throwable) {
+    this.throwable = throwable;
+  }
+
+  /**
+   * Returns the pre-rendered trace if set, else renders it from the 
throwable, else {@code null}.
+   */
+  public String getStackTrace() {
+    if (stackTrace != null) {
+      return stackTrace;
+    }
+    if (throwable != null) {
+      return Const.getStackTracker(throwable);
+    }
+    return null;
+  }
+
+  public void setStackTrace(String stackTrace) {
+    this.stackTrace = stackTrace;
+  }
+
+  /**
+   * @param level the log level to set
+   */
+  public void setLevel(LogLevel level) {
+    this.level = level;
+  }
 }
diff --git 
a/core/src/main/java/org/apache/hop/core/logging/Slf4jLoggingEventListener.java 
b/core/src/main/java/org/apache/hop/core/logging/Slf4jLoggingEventListener.java
index ee0f75bbca..3fe8340eec 100644
--- 
a/core/src/main/java/org/apache/hop/core/logging/Slf4jLoggingEventListener.java
+++ 
b/core/src/main/java/org/apache/hop/core/logging/Slf4jLoggingEventListener.java
@@ -60,7 +60,10 @@ public class Slf4jLoggingEventListener implements 
IHopLoggingEventListener {
       if (loggingObject == null) {
         // this can happen if logObject has been discarded while log events 
are still in flight.
         logToLogger(
-            hopLogger, message.getLevel(), message.getSubject() + " " + 
message.getMessage());
+            hopLogger,
+            message.getLevel(),
+            message.getSubject() + " " + message.getMessage(),
+            message.getThrowable());
       } else if (loggingObject.getObjectType() == PIPELINE
           || loggingObject.getObjectType() == TRANSFORM
           || loggingObject.getObjectType() == DATABASE) {
@@ -77,7 +80,19 @@ public class Slf4jLoggingEventListener implements 
IHopLoggingEventListener {
   private void logToLogger(
       Logger logger, LogLevel logLevel, ILoggingObject loggingObject, 
LogMessage message) {
     logToLogger(
-        logger, logLevel, "[" + getDetailedSubject(loggingObject) + "]  " + 
message.getMessage());
+        logger,
+        logLevel,
+        "[" + getDetailedSubject(loggingObject) + "]  " + message.getMessage(),
+        message.getThrowable());
+  }
+
+  private void logToLogger(Logger logger, LogLevel logLevel, String message, 
Throwable throwable) {
+    if (throwable == null) {
+      logToLogger(logger, logLevel, message);
+      return;
+    }
+
+    logger.error(message, throwable);
   }
 
   private void logToLogger(Logger logger, LogLevel logLevel, String message) {
diff --git 
a/core/src/test/java/org/apache/hop/core/logging/FixedWidthLogLayoutTest.java 
b/core/src/test/java/org/apache/hop/core/logging/FixedWidthLogLayoutTest.java
new file mode 100644
index 0000000000..850a626b5a
--- /dev/null
+++ 
b/core/src/test/java/org/apache/hop/core/logging/FixedWidthLogLayoutTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.hop.core.logging;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/** Test for {@link FixedWidthLogLayout}. */
+class FixedWidthLogLayoutTest {
+
+  @Test
+  void testFormatRendersAttachedThrowable() {
+    LogMessage message = new LogMessage("Boom", "Channel 01", LogLevel.ERROR);
+    message.setThrowable(new IllegalStateException("kaboom"));
+
+    HopLoggingEvent event = new HopLoggingEvent(message, 0, LogLevel.ERROR);
+    final String formatted = new FixedWidthLogLayout().format(event);
+
+    assertTrue(formatted.contains("Boom"), "The message text must be 
present.");
+    assertTrue(
+        formatted.contains("IllegalStateException"),
+        "The attached throwable's stack trace must be rendered for the GUI log 
browser.");
+    assertTrue(formatted.contains("kaboom"), "The throwable message must be 
rendered.");
+  }
+
+  @Test
+  void testFormatWithoutThrowableIsUnchanged() {
+    LogMessage message = new LogMessage("No error here", "Channel 01", 
LogLevel.BASIC);
+
+    HopLoggingEvent event = new HopLoggingEvent(message, 0, LogLevel.BASIC);
+    final String formatted = new FixedWidthLogLayout().format(event);
+
+    assertFalse(
+        formatted.contains("\tat "),
+        "Messages without a throwable must not contain any stack-trace 
frames.");
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/core/logging/HopLogLayoutTest.java 
b/core/src/test/java/org/apache/hop/core/logging/HopLogLayoutTest.java
index ff7fdf0397..d1fb51708c 100644
--- a/core/src/test/java/org/apache/hop/core/logging/HopLogLayoutTest.java
+++ b/core/src/test/java/org/apache/hop/core/logging/HopLogLayoutTest.java
@@ -17,6 +17,8 @@
 package org.apache.hop.core.logging;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import org.junit.jupiter.api.Test;
 
@@ -38,4 +40,31 @@ class HopLogLayoutTest {
         formattedMsg.substring(formattedMsg.indexOf('-') + 2),
         "The log message must be formatted and not contain placeholders.");
   }
+
+  @Test
+  void testFormatRendersAttachedThrowable() {
+    LogMessage message = new LogMessage("Boom", "Channel 01", LogLevel.ERROR);
+    message.setThrowable(new IllegalStateException("kaboom"));
+
+    HopLoggingEvent event = new HopLoggingEvent(message, 0, LogLevel.ERROR);
+    final String formatted = new HopLogLayout().format(event);
+
+    assertTrue(formatted.contains("Boom"), "The message text must be 
present.");
+    assertTrue(
+        formatted.contains("IllegalStateException"),
+        "The attached throwable's stack trace must be rendered for 
console/file output.");
+    assertTrue(formatted.contains("kaboom"), "The throwable message must be 
rendered.");
+  }
+
+  @Test
+  void testFormatWithoutThrowableIsUnchanged() {
+    LogMessage message = new LogMessage("No error here", "Channel 01", 
LogLevel.BASIC);
+
+    HopLoggingEvent event = new HopLoggingEvent(message, 0, LogLevel.BASIC);
+    final String formatted = new HopLogLayout().format(event);
+
+    assertFalse(
+        formatted.contains("\tat "),
+        "Messages without a throwable must not contain any stack-trace 
frames.");
+  }
 }
diff --git a/core/src/test/java/org/apache/hop/core/logging/LogChannelTest.java 
b/core/src/test/java/org/apache/hop/core/logging/LogChannelTest.java
index 49095a3dc3..c070010b25 100644
--- a/core/src/test/java/org/apache/hop/core/logging/LogChannelTest.java
+++ b/core/src/test/java/org/apache/hop/core/logging/LogChannelTest.java
@@ -17,6 +17,10 @@
 
 package org.apache.hop.core.logging;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
@@ -110,4 +114,27 @@ class LogChannelTest {
     logChannel.println(logMsgInterfaceFil, LogLevel.BASIC);
     verify(logChFileWriterBuffer, 
times(0)).addEvent(any(HopLoggingEvent.class));
   }
+
+  @Test
+  void testPrintlnWithThrowableBumpsLevelPreRendersTraceAndReleasesThrowable() 
{
+    LoggingBuffer loggingBuffer = mock(LoggingBuffer.class);
+    mockedHopLogStore.when(HopLogStore::getAppender).thenReturn(loggingBuffer);
+    logChannel.setFilter("");
+
+    LogMessage message = new LogMessage("Boom", "1234-5678-abcd-efgh", 
LogLevel.BASIC);
+    IllegalStateException failure = new IllegalStateException("kaboom");
+
+    logChannel.println(message, failure, LogLevel.ERROR);
+
+    // Surfaced at ERROR level so error scanners/counters still detect it.
+    assertEquals(LogLevel.ERROR, message.getLevel());
+    // Live throwable released so the buffered event does not retain the 
exception object graph.
+    assertNull(message.getThrowable());
+    // Pre-rendered trace survives for text/buffer consumers (console, file, 
GUI log browser).
+    assertNotNull(message.getStackTrace());
+    assertTrue(message.getStackTrace().contains("IllegalStateException"));
+    assertTrue(message.getStackTrace().contains("kaboom"));
+    // A single combined event is emitted instead of the legacy two-event path.
+    verify(loggingBuffer, 
times(1)).addLogggingEvent(any(HopLoggingEvent.class));
+  }
 }
diff --git a/core/src/test/java/org/apache/hop/core/logging/LogMessageTest.java 
b/core/src/test/java/org/apache/hop/core/logging/LogMessageTest.java
index ccffda5f61..ead18dad52 100644
--- a/core/src/test/java/org/apache/hop/core/logging/LogMessageTest.java
+++ b/core/src/test/java/org/apache/hop/core/logging/LogMessageTest.java
@@ -18,6 +18,8 @@
 package org.apache.hop.core.logging;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import org.junit.jupiter.api.Test;
 
@@ -48,4 +50,41 @@ class LogMessageTest {
         new LogMessage("Hello {0}", "channel-1", new Object[] {"world"}, 
LogLevel.BASIC, false);
     assertEquals("Hello world", msg.getMessage());
   }
+
+  @Test
+  void getStackTrace_withoutThrowableOrRenderedTrace_returnsNull() {
+    LogMessage msg = new LogMessage("no error", "channel-1", LogLevel.BASIC);
+    assertNull(msg.getStackTrace());
+  }
+
+  @Test
+  void getStackTrace_rendersFromAttachedThrowable() {
+    LogMessage msg = new LogMessage("boom", "channel-1", LogLevel.ERROR);
+    msg.setThrowable(new IllegalStateException("kaboom"));
+
+    String stackTrace = msg.getStackTrace();
+    assertTrue(stackTrace.contains("IllegalStateException"));
+    assertTrue(stackTrace.contains("kaboom"));
+  }
+
+  @Test
+  void getStackTrace_preRenderedTraceSurvivesThrowableRelease() {
+    LogMessage msg = new LogMessage("boom", "channel-1", LogLevel.ERROR);
+    msg.setThrowable(new IllegalStateException("kaboom"));
+    // Persist the rendered form, then release the live throwable (as 
LogChannel does after
+    // dispatch) — the buffer/layout consumers must still see the trace.
+    
msg.setStackTrace(org.apache.hop.core.Const.getStackTracker(msg.getThrowable()));
+    msg.setThrowable(null);
+
+    assertNull(msg.getThrowable());
+    assertTrue(msg.getStackTrace().contains("IllegalStateException"));
+    assertTrue(msg.getStackTrace().contains("kaboom"));
+  }
+
+  @Test
+  void setLevel_overridesLevel() {
+    LogMessage msg = new LogMessage("boom", "channel-1", LogLevel.BASIC);
+    msg.setLevel(LogLevel.ERROR);
+    assertEquals(LogLevel.ERROR, msg.getLevel());
+  }
 }
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
index 68e79a38fe..c0deea7357 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
@@ -337,26 +337,26 @@ public class GitPerspective implements IHopPerspective {
     PropsUi.setLook(wSearchText);
 
     // Create a composite with toolbar and tree for the border
-    //    Composite composite = new Composite(treeComposite, SWT.BORDER);
-    //    composite.setLayout(new FormLayout());
-    //    composite.setLayoutData(FormDataBuilder.builder().top(wSearchText,
+    // Composite composite = new Composite(treeComposite, SWT.BORDER);
+    // composite.setLayout(new FormLayout());
+    // composite.setLayoutData(FormDataBuilder.builder().top(wSearchText,
     // PropsUi.getMargin()).bottom().fullWidth().result());
-    //    PropsUi.setLook(composite);
+    // PropsUi.setLook(composite);
 
     // Create toolbar
     //
-    //    IToolbarContainer toolBarContainer =
-    //        ToolbarFacade.createToolbarContainer(composite, SWT.WRAP | 
SWT.LEFT | SWT.HORIZONTAL);
+    // IToolbarContainer toolBarContainer =
+    // ToolbarFacade.createToolbarContainer(composite, SWT.WRAP | SWT.LEFT | 
SWT.HORIZONTAL);
     //
-    //    refToolBarWidgets = new GuiToolbarWidgets();
-    //    refToolBarWidgets.registerGuiPluginObject(this);
-    //    refToolBarWidgets.createToolbarWidgets(toolBarContainer,
+    // refToolBarWidgets = new GuiToolbarWidgets();
+    // refToolBarWidgets.registerGuiPluginObject(this);
+    // refToolBarWidgets.createToolbarWidgets(toolBarContainer,
     // GUI_PLUGIN_REF_TOOLBAR_PARENT_ID);
     //
-    //    wRefToolBar = toolBarContainer.getControl();
-    //    
wRefToolBar.setLayoutData(FormDataBuilder.builder().fullWidth().top().result());
-    //    wRefToolBar.pack();
-    //    PropsUi.setLook(wRefToolBar, Props.WIDGET_STYLE_TOOLBAR);
+    // wRefToolBar = toolBarContainer.getControl();
+    // 
wRefToolBar.setLayoutData(FormDataBuilder.builder().fullWidth().top().result());
+    // wRefToolBar.pack();
+    // PropsUi.setLook(wRefToolBar, Props.WIDGET_STYLE_TOOLBAR);
 
     wRefTree = new Tree(composite, SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL);
     wRefTree.setHeaderVisible(false);
@@ -620,7 +620,13 @@ public class GitPerspective implements IHopPerspective {
       }
       return isCommitInCurrentBranch(uiGit.getGit().getRepository(), 
commit.getId());
     } catch (MissingObjectException e) {
-      // Object id not present in the currently open repository (e.g. after 
project switch)
+      // Commit not in the local object store: expected (e.g. after a 
fetch/refresh), so log at
+      // debug level and report it's not in the branch.
+      LogChannel.UI.logDebug(
+          "Commit "
+              + commit.getId().getName()
+              + " is not present in the local object store while checking the 
current branch: "
+              + e.getMessage());
       return false;
     } catch (Exception e) {
       LogChannel.UI.logError("Error checking if commit is in current branch", 
e);

Reply via email to