Repository: AbsaOSS/pramen (language: python)

Issue:
Limit the size of rendered exceptions
## Background
Some exceptions coming from third-parties are so big after rendering that it breaches the maximum size of an email.

## Feature
Limit the size of rendered exceptions.

Make it configurable.

## Example [Optional]
--

## Proposed Solution [Optional]
--


Discussion / hints:

Fix patch:
diff --git a/pramen/core/src/main/resources/reference.conf b/pramen/core/src/main/resources/reference.conf
index 547a7f73d..bf79f7d6d 100644
--- a/pramen/core/src/main/resources/reference.conf
+++ b/pramen/core/src/main/resources/reference.conf
@@ -168,6 +168,15 @@ pramen {
   # one transformer are different from partitions produced by other transformers.
   # You can set it to false to have a more strict setup and disallow this behavior.
   enable.multiple.jobs.per.output.table = true
+
+  # Limits on certain elements of notiications (email etc)
+  notifications {
+    # The maximum length in characters of the Reason field in the completed tasks table.
+    reason.max.length = 1024
+    # The maximum length of errors and exceptions in the notification body. The default value is selected
+    # so that Pramen can handle at least 100 exceptions in a single email notification.
+    exception.max.length = 65536
+  }
 }
 
 pramen.py {
diff --git a/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/message/MessageBuilderHtml.scala b/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/message/MessageBuilderHtml.scala
index 897378785..01530ae09 100644
--- a/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/message/MessageBuilderHtml.scala
+++ b/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/message/MessageBuilderHtml.scala
@@ -16,17 +16,20 @@
 
 package za.co.absa.pramen.core.notify.message
 
+import com.typesafe.config.Config
 import za.co.absa.pramen.api.notification.NotificationEntry.Paragraph
 import za.co.absa.pramen.api.notification.{Style, TableHeader, TextElement}
 import za.co.absa.pramen.core.exceptions.OsSignalException
-import za.co.absa.pramen.core.utils.ResourceUtils
+import za.co.absa.pramen.core.notify.pipeline.PipelineNotificationBuilderHtml.NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY
+import za.co.absa.pramen.core.utils.{ConfigUtils, ResourceUtils}
 import za.co.absa.pramen.core.utils.StringUtils.{renderThreadDumps, renderThrowable}
 
 import scala.collection.mutable.ListBuffer
 import scala.compat.Platform.EOL
 
-class MessageBuilderHtml extends MessageBuilder {
+class MessageBuilderHtml(conf: Config) extends MessageBuilder {
   private val style = ResourceUtils.getResourceString("/email_template/style.css")
+  private val maxExceptionLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY)
   private val body = new ListBuffer[String]
 
   override def withParagraph(text: Seq[TextElement]): MessageBuilderHtml = {
@@ -73,7 +76,7 @@ class MessageBuilderHtml extends MessageBuilder {
         renderThreadDumps(signalException.threadStackTraces)
       case other =>
         withParagraph(Seq(TextElement(description, Style.Exception)))
-        renderThrowable(other)
+        renderThrowable(other, maximumLength = maxExceptionLength)
     }
 
     withUnformattedText(rendered)
diff --git a/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala b/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala
index f447b1f02..d5bc35ab4 100644
--- a/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala
+++ b/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala
@@ -38,9 +38,13 @@ object PipelineNotificationBuilderHtml {
   val MIN_RPS_JOB_DURATION_SECONDS = 60
   val MIN_RPS_RECORDS = 1000
   val MIN_MEGABYTES = 10
+  val NOTIFICATION_REASON_MAX_LENGTH_KEY = "pramen.notifications.reason.max.length"
+  val NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY = "pramen.notifications.exception.max.length"
 }
 
 class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNotificationBuilder {
+  import PipelineNotificationBuilderHtml._
+
   private val log = LoggerFactory.getLogger(this.getClass)
 
   private val timestampFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm Z")
@@ -52,6 +56,9 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
   private var minRps = 0
   private var goodRps = Int.MaxValue
 
+  private val maxReasonLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_REASON_MAX_LENGTH_KEY)
+  private val maxExceptionLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY)
+
   var appException: Option[Throwable] = None
   var warningFlag: Boolean = false
   var appName: String = "Unspecified Job"
@@ -131,7 +138,7 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
   }
 
   def renderBody(): String = {
-    val builder = new MessageBuilderHtml
+    val builder = new MessageBuilderHtml(conf)
 
     renderHeader(builder)
 
@@ -290,9 +297,16 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
         .withText(info.infoDate.toString, Style.Error)
     )
 
+    val errorMessage = ex.getMessage
+
+    val errorMessageTruncated = maxReasonLength match {
+      case Some(maxLength) if errorMessage.length > maxLength => errorMessage.substring(0, maxLength) + "..."
+      case _ => errorMessage
+    }
+
     paragraphBuilder
       .withText(" has failed with an exception: ", Style.Exception)
-      .withText(ex.getMessage, Style.Error)
+      .withText(errorMessageTruncated, Style.Error)
 
     builder.withParagraph(paragraphBuilder)
     renderException(builder, ex)
@@ -311,7 +325,7 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
         val stderrMsg = if (stderr.isEmpty) "" else s"""Last <b>stderr</b> lines:\n${stderr.mkString("", EOL, EOL)}"""
         s"$msg\n$stdoutMsg\n$stderrMsg"
       case ex: Throwable                     =>
-        renderThrowable(ex)
+        renderThrowable(ex, maximumLength = maxExceptionLength)
     }
 
     builder.withUnformattedText(text)
@@ -599,7 +613,7 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
   }
 
   private[core] def getFailureReason(task: TaskResult): String = {
-    task.runStatus.getReason match {
+    val reason = task.runStatus.getReason match {
       case Some(reason) => reason
       case None         =>
         if (task.dependencyWarnings.isEmpty) {
@@ -609,6 +623,11 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
           s"Optional dependencies failed for: $tables"
         }
     }
+
+    maxReasonLength match {
+      case Some(maxLength) if reason.length > maxLength =>  reason.substring(0, maxLength) + "..."
+      case _ => reason
+    }
   }
 
   private[core] def getFinishTime(task: TaskResult): String = {
diff --git a/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala b/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala
index 0522b08d8..aac920484 100644
--- a/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala
+++ b/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala
@@ -169,14 +169,19 @@ object StringUtils {
   }
 
   /** Renders an exception as a string */
-  def renderThrowable(ex: Throwable, level: Int = 1): String = {
+  def renderThrowable(ex: Throwable, level: Int = 1, maximumLength: Option[Int] = None): String = {
     val prefix = " " * (level * 2)
     val base = s"""${ex.toString}\n${ex.getStackTrace.map(s => s"$prefix$s").mkString("", EOL, EOL)}"""
     val cause = Option(ex.getCause) match {
       case Some(c) if level < 6 => s"\n${prefix}Caused by " + renderThrowable(c, level + 1)
       case _                    => ""
     }
-    base + cause
+    val fullText = base + cause
+
+    maximumLength match {
+      case Some(len) if fullText.length > len => fullText.substring(0, len) + "..."
+      case _ => fullText
+    }
   }
 
   def renderThreadDumps(threadStackTraces: Seq[ThreadStackTrace]): String = {


Test patch:
diff --git a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/message/MessageBuilderHtmlSuite.scala b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/message/MessageBuilderHtmlSuite.scala
index 5d8c09484..b0f21be59 100644
--- a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/message/MessageBuilderHtmlSuite.scala
+++ b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/message/MessageBuilderHtmlSuite.scala
@@ -16,6 +16,7 @@
 
 package za.co.absa.pramen.core.tests.notify.message
 
+import com.typesafe.config.ConfigFactory
 import org.scalatest.wordspec.AnyWordSpec
 import za.co.absa.pramen.api.notification.NotificationEntry.Paragraph
 import za.co.absa.pramen.api.notification.{Style, TableHeader, TextElement}
@@ -28,7 +29,7 @@ class MessageBuilderHtmlSuite extends AnyWordSpec with TextComparisonFixture {
     "render an email with the requested elements" in {
       val expected = ResourceUtils.getResourceString("/test/notify/expectedMessage.dat")
 
-      val builder = new MessageBuilderHtml
+      val builder = new MessageBuilderHtml(ConfigFactory.empty())
       val tb = new TableBuilderHtml
 
       val actual = builder
diff --git a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/pipeline/PipelineNotificationBuilderHtmlSuite.scala b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/pipeline/PipelineNotificationBuilderHtmlSuite.scala
index 5654e351c..b3210dcad 100644
--- a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/pipeline/PipelineNotificationBuilderHtmlSuite.scala
+++ b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/notify/pipeline/PipelineNotificationBuilderHtmlSuite.scala
@@ -20,18 +20,20 @@ import com.typesafe.config.{Config, ConfigFactory}
 import org.scalatest.wordspec.AnyWordSpec
 import za.co.absa.pramen.api.notification.NotificationEntry.Paragraph
 import za.co.absa.pramen.api.notification._
-import za.co.absa.pramen.api.status.{NotificationFailure, PipelineNotificationFailure, RunStatus, TaskRunReason}
+import za.co.absa.pramen.api.status.{DependencyWarning, NotificationFailure, PipelineNotificationFailure, RunStatus, TaskRunReason}
 import za.co.absa.pramen.core.exceptions.{CmdFailedException, ProcessFailedException}
 import za.co.absa.pramen.core.fixtures.TextComparisonFixture
 import za.co.absa.pramen.core.mocks.{RunStatusFactory, SchemaDifferenceFactory, TaskResultFactory, TestPrototypes}
 import za.co.absa.pramen.core.notify.message.{MessageBuilderHtml, ParagraphBuilder}
 import za.co.absa.pramen.core.notify.pipeline.PipelineNotificationBuilderHtml
+import za.co.absa.pramen.core.notify.pipeline.PipelineNotificationBuilderHtml.{NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY, NOTIFICATION_REASON_MAX_LENGTH_KEY}
 import za.co.absa.pramen.core.utils.ResourceUtils
 
 import java.time.{Instant, LocalDate}
 
 class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparisonFixture {
   private val megabyte = 1024L * 1024L
+  private val emptyConfig = ConfigFactory.empty()
 
   "constructor" should {
     "be able to initialize the builder with the default timezone" in {
@@ -49,7 +51,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "renderSubject()" should {
     "render normal subject" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       builder.addAppName("MyApp")
 
@@ -57,7 +59,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "render a dry run subject" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       builder.addAppName("MyNewApp")
       builder.addDryRun(true)
@@ -66,7 +68,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "render failure" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       builder.addAppName("MyNewApp")
       builder.addFailureException(new RuntimeException("Test exception"))
@@ -75,7 +77,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "render warning" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       builder.addAppName("MyNewApp")
       builder.addCompletedTask(TaskResultFactory.getDummyTaskResult(runStatus = TestPrototypes.runStatusWarning))
@@ -84,7 +86,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "render partial failure" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       builder.addAppName("MyNewApp")
       builder.addCompletedTask(TaskResultFactory.getDummyTaskResult(runStatus = TestPrototypes.runStatusWarning))
@@ -98,7 +100,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     "render a default notification body" in {
       val expected = ResourceUtils.getResourceString("/test/notify/test_pipeline_email_body_default.txt")
 
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.renderBody()
         .split("\n")
@@ -111,7 +113,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     "render a notification body with completed tasks and schema changes and custom entries" in {
       val expected = ResourceUtils.getResourceString("/test/notify/test_pipeline_email_body_complex.txt")
 
-      val builder = getBuilder
+      val builder = getBuilder()
 
       builder.addDryRun(true)
       builder.addUndercover(true)
@@ -150,7 +152,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "render a notification body with an exception" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val nestedCause1 = new RuntimeException("Cause 1")
       val nestedCause2 = new RuntimeException("Cause 2", nestedCause1)
@@ -175,8 +177,8 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "renderJobException" should {
     "render a job exception" in {
-      val notificationBuilder = getBuilder
-      val messageBuilder = new MessageBuilderHtml
+      val notificationBuilder = getBuilder()
+      val messageBuilder = new MessageBuilderHtml(emptyConfig)
 
       val taskResult = TaskResultFactory.getDummyTaskResult(runStatus = TestPrototypes.runStatusFailure)
 
@@ -187,12 +189,31 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
       assert(actual.contains("""</span><span class="tderr">Job Exception</span>"""))
       assert(actual.contains("""<pre>java.lang.RuntimeException: Job Exception"""))
     }
+
+    "render a job exception with a limit on size" in {
+      val conf = ConfigFactory.parseString(
+        s"""$NOTIFICATION_REASON_MAX_LENGTH_KEY = 10
+           |$NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY = 15
+           |""".stripMargin
+      )
+      val notificationBuilder = getBuilder(conf)
+      val messageBuilder = new MessageBuilderHtml(emptyConfig)
+
+      val taskResult = TaskResultFactory.getDummyTaskResult(runStatus = TestPrototypes.runStatusFailure)
+
+      val actual = notificationBuilder.renderJobException(messageBuilder, taskResult, new RuntimeException("Job Exception"))
+        .renderBody
+
+      assert(actual.contains("""<span class="tderr">DummyJob</span><span class="tdred"> outputting to </span><span class="tderr">table_out</span><span class="tdred"> at </span><span class="tderr">2022-02-18</span><span class="tdred"> has failed with an exception:"""))
+      assert(actual.contains("""<span class="tderr">Job Except...</span>"""))
+      assert(actual.contains("""<pre>java.lang.Runti...</pre>"""))
+    }
   }
 
   "renderException" should {
     "render runtime exceptions" in {
-      val notificationBuilder = getBuilder
-      val messageBuilder = new MessageBuilderHtml
+      val notificationBuilder = getBuilder()
+      val messageBuilder = new MessageBuilderHtml(emptyConfig)
 
       val ex = new RuntimeException("Text exception")
 
@@ -203,9 +224,25 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
       assert(actual.contains("""za.co.absa.pramen.core.tests.notify.pipeline.PipelineNotificationBuilderHtmlSuite"""))
     }
 
+    "render runtime exceptions with a limit on its size" in {
+      val conf = ConfigFactory.parseString(
+        s"""$NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY = 10
+          |""".stripMargin
+      )
+      val notificationBuilder = getBuilder(conf)
+      val messageBuilder = new MessageBuilderHtml(conf)
+
+      val ex = new RuntimeException("Text exception")
+
+      val actual = notificationBuilder.renderException(messageBuilder, ex)
+        .renderBody
+
+      assert(actual.contains("""<pre>java.lang....</pre>"""))
+    }
+
     "render command line exceptions with logs" in {
-      val notificationBuilder = getBuilder
-      val messageBuilder = new MessageBuilderHtml
+      val notificationBuilder = getBuilder()
+      val messageBuilder = new MessageBuilderHtml(emptyConfig)
 
       val ex = CmdFailedException("Command line failed", Array("Log line 1", "Log line 2"))
 
@@ -222,8 +259,8 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "render command line exceptions without logs" in {
-      val notificationBuilder = getBuilder
-      val messageBuilder = new MessageBuilderHtml
+      val notificationBuilder = getBuilder()
+      val messageBuilder = new MessageBuilderHtml(emptyConfig)
 
       val ex = CmdFailedException("Command line failed", Array.empty)
 
@@ -234,8 +271,8 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "render process execution exceptions" in {
-      val notificationBuilder = getBuilder
-      val messageBuilder = new MessageBuilderHtml
+      val notificationBuilder = getBuilder()
+      val messageBuilder = new MessageBuilderHtml(emptyConfig)
 
       val ex = ProcessFailedException("Command line failed", Array("stdout line 1", "stdout line 2"), Array("stderr line 1"))
 
@@ -257,8 +294,8 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "renderPipelineNotificationFailures" should {
     "render pipeline failure as an exception" in {
-      val notificationBuilder = getBuilder
-      val messageBuilder = new MessageBuilderHtml
+      val notificationBuilder = getBuilder()
+      val messageBuilder = new MessageBuilderHtml(emptyConfig)
 
       val ex = new RuntimeException("Test exception")
       notificationBuilder.addPipelineNotificationFailure(PipelineNotificationFailure("com.example.MyNotification", ex))
@@ -278,7 +315,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "getStatus" should {
     "work for succeeded" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = TestPrototypes.runStatusSuccess))
 
@@ -286,7 +323,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for insufficient data" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.InsufficientData(100, 200, None)))
 
@@ -294,7 +331,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for no data" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.NoData(true)))
 
@@ -302,7 +339,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for skipped" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.Skipped("dummy")))
 
@@ -310,7 +347,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for skipped with warnings" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.Skipped("dummy", isWarning = true)))
 
@@ -318,7 +355,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for not ran" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.NotRan))
 
@@ -326,7 +363,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for validation failure" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.ValidationFailed(new RuntimeException("dummy"))))
 
@@ -334,7 +371,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for failed dependencies" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.FailedDependencies(isFailure = true, Seq.empty)))
 
@@ -342,7 +379,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for failed jobs" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getStatus(TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.Failed(new RuntimeException("dummy"))))
 
@@ -352,7 +389,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "getThroughputRps" should {
     "work for a failed tasks" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummyFailure()
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -363,7 +400,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for a task without a run info" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(None, 1000000, reason = TaskRunReason.New)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus, runInfo = None)
@@ -374,7 +411,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for a normal successful task" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(None, 1000000, reason = TaskRunReason.New)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -385,7 +422,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for a raw file task" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(None, 1000 * megabyte, reason = TaskRunReason.New)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus, isRawFilesJob = true)
@@ -398,7 +435,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "getBytesPerSecondsText" should {
     "return an empty string for files smaller than the minimum size" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getBytesPerSecondsText(1000, 10)
 
@@ -406,7 +443,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "return the throughput for usual inputs" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val actual = builder.getBytesPerSecondsText(100L * 1024L * 1024L, 10)
 
@@ -416,7 +453,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "getRecordCountText" should {
     "work for a failure" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummyFailure()
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -427,7 +464,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success file based job" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(Some(100), 100, reason = TaskRunReason.Update)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus, isRawFilesJob = true)
@@ -438,7 +475,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success new" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(None, 100, reason = TaskRunReason.New)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -449,7 +486,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success unchanged" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(Some(100), 100, reason = TaskRunReason.Update)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -460,7 +497,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success increased" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(Some(100), 110, reason = TaskRunReason.Update)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -471,7 +508,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success decreased" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(Some(100), 90, reason = TaskRunReason.Update)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -482,7 +519,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for insufficient data" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val task = TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.InsufficientData(90, 96, Some(100)))
 
@@ -494,7 +531,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
 
   "getSizeText" should {
     "work for a failure" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummyFailure()
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
@@ -505,7 +542,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success new" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(None, 100 * megabyte, reason = TaskRunReason.New)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus, isRawFilesJob = true)
@@ -516,7 +553,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success unchanged" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(Some(100 * megabyte), 100 * megabyte, reason = TaskRunReason.Update)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus, isRawFilesJob = true)
@@ -527,7 +564,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success increased" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(Some(100 * megabyte), 110 * megabyte, reason = TaskRunReason.Update)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus, isRawFilesJob = true)
@@ -538,7 +575,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for success decreased" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val runStatus = RunStatusFactory.getDummySuccess(Some(100 * megabyte), 90 * megabyte, reason = TaskRunReason.Update)
       val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus, isRawFilesJob = true)
@@ -549,7 +586,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
 
     "work for insufficient data" in {
-      val builder = getBuilder
+      val builder = getBuilder()
 
       val task = TaskResultFactory.getDummyTaskResult(runStatus = RunStatus.InsufficientData(90 * megabyte, 96 * megabyte, Some(100 * megabyte)), isRawFilesJob = true)
 
@@ -559,15 +596,71 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis
     }
   }
 
-  def getBuilder: PipelineNotificationBuilderHtml = {
-    implicit val conf: Config = ConfigFactory.parseString(
-      """pramen {
-        |  application.version = 1.0.0
-        |  timezone = "Africa/Johannesburg"
-        |}
-        |""".stripMargin)
+  "getFailureReason" should {
+    "get the underlying reason" in {
+      val builder = getBuilder()
+
+      val runStatus = RunStatusFactory.getDummyFailure()
+      val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
+
+      val actual = builder.getFailureReason(task)
+
+      assert(actual == "Dummy failure")
+    }
+
+    "get an empty reason if dependency warning list is empty" in {
+      val builder = getBuilder()
+
+      val runStatus = RunStatusFactory.getDummySuccess()
+      val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus)
+
+      val actual = builder.getFailureReason(task)
+
+      assert(actual == "")
+    }
+
+    "get an non-empty reason if dependency warning list is non-empty" in {
+      val builder = getBuilder()
+
+      val runStatus = RunStatusFactory.getDummySuccess()
+      val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus,
+        dependencyWarnings = Seq(DependencyWarning("t1"),DependencyWarning("t2")))
+
+      val actual = builder.getFailureReason(task)
+
+      assert(actual == "Optional dependencies failed for: t1, t2")
+    }
+
+    "get an non-empty reason if the limit on reason size is specified" in {
+      val conf = ConfigFactory.parseString(
+        s"""$NOTIFICATION_REASON_MAX_LENGTH_KEY = 15
+           |""".stripMargin
+      )
+
+      val builder = getBuilder(conf)
+
+      val runStatus = RunStatusFactory.getDummySuccess()
+      val task = TaskResultFactory.getDummyTaskResult(runStatus = runStatus,
+        dependencyWarnings = Seq(DependencyWarning("t1"),DependencyWarning("t2")))
+
+      val actual = builder.getFailureReason(task)
+
+      assert(actual == "Optional depend...")
+    }
+  }
+
+  def getBuilder(conf: Config = emptyConfig): PipelineNotificationBuilderHtml  = {
+    implicit val implicitConfig: Config =
+      conf.withFallback(
+        ConfigFactory.parseString(
+          """pramen {
+            |  application.version = 1.0.0
+            |  timezone = "Africa/Johannesburg"
+            |}
+            |""".stripMargin)
+      )
 
-    val builder = new PipelineNotificationBuilderHtml()
+    val builder = new PipelineNotificationBuilderHtml
 
     builder.addAppName("MyApp")
     builder.addEnvironmentName("MyEnv")
diff --git a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala
index c9e34971d..16a7b6edf 100644
--- a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala
+++ b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala
@@ -188,6 +188,12 @@ class StringUtilsSuite extends AnyWordSpec {
       assert(s.contains("java.lang.RuntimeException: test"))
     }
 
+    "render a throwable with a length limit" in {
+      val ex = new RuntimeException("test")
+      val s = renderThrowable(ex, maximumLength = Some(4))
+      assert(s.contains("java..."))
+    }
+
     "render a throwable with a cause" in {
       val ex = new RuntimeException("test", new RuntimeException("cause"))
       val s = renderThrowable(ex)