From cccdaa7d6829bb00708870f3ffa13dd19a4851ea Mon Sep 17 00:00:00 2001 From: Ruslan Iushchenko Date: Mon, 26 Aug 2024 13:40:36 +0200 Subject: [PATCH 1/3] #476 Add configurable maximum length of errors in email notifications. --- pramen/core/src/main/resources/reference.conf | 8 ++++++++ .../notify/message/MessageBuilderHtml.scala | 9 ++++++--- .../PipelineNotificationBuilderHtml.scala | 18 +++++++++++++++--- .../absa/pramen/core/utils/StringUtils.scala | 9 +++++++-- .../message/MessageBuilderHtmlSuite.scala | 3 ++- .../PipelineNotificationBuilderHtmlSuite.scala | 13 +++++++------ .../core/tests/utils/StringUtilsSuite.scala | 6 ++++++ 7 files changed, 51 insertions(+), 15 deletions(-) diff --git a/pramen/core/src/main/resources/reference.conf b/pramen/core/src/main/resources/reference.conf index 547a7f73d..78ee3ba0d 100644 --- a/pramen/core/src/main/resources/reference.conf +++ b/pramen/core/src/main/resources/reference.conf @@ -168,6 +168,14 @@ 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. + body.error.max.length = 32758 + } } 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..0f21bf8d4 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_ERROR_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_ERROR_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..e590861d6 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_ERROR_MAX_LENGTH_KEY = "pramen.notifications.body.error.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_ERROR_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) @@ -311,7 +318,7 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot val stderrMsg = if (stderr.isEmpty) "" else s"""Last stderr 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 +606,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 +616,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 = { 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..efc840e27 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 @@ -32,6 +32,7 @@ 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 { @@ -176,7 +177,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "renderJobException" should { "render a job exception" in { val notificationBuilder = getBuilder - val messageBuilder = new MessageBuilderHtml + val messageBuilder = new MessageBuilderHtml(emptyConfig) val taskResult = TaskResultFactory.getDummyTaskResult(runStatus = TestPrototypes.runStatusFailure) @@ -192,7 +193,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "renderException" should { "render runtime exceptions" in { val notificationBuilder = getBuilder - val messageBuilder = new MessageBuilderHtml + val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = new RuntimeException("Text exception") @@ -205,7 +206,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "render command line exceptions with logs" in { val notificationBuilder = getBuilder - val messageBuilder = new MessageBuilderHtml + val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = CmdFailedException("Command line failed", Array("Log line 1", "Log line 2")) @@ -223,7 +224,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "render command line exceptions without logs" in { val notificationBuilder = getBuilder - val messageBuilder = new MessageBuilderHtml + val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = CmdFailedException("Command line failed", Array.empty) @@ -235,7 +236,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "render process execution exceptions" in { val notificationBuilder = getBuilder - val messageBuilder = new MessageBuilderHtml + val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = ProcessFailedException("Command line failed", Array("stdout line 1", "stdout line 2"), Array("stderr line 1")) @@ -258,7 +259,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "renderPipelineNotificationFailures" should { "render pipeline failure as an exception" in { val notificationBuilder = getBuilder - val messageBuilder = new MessageBuilderHtml + val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = new RuntimeException("Test exception") notificationBuilder.addPipelineNotificationFailure(PipelineNotificationFailure("com.example.MyNotification", ex)) 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) From eebfb5b2c73d53b182eea5910a861457cb9d20f1 Mon Sep 17 00:00:00 2001 From: Ruslan Iushchenko Date: Mon, 26 Aug 2024 14:14:46 +0200 Subject: [PATCH 2/3] #476 Limit the size of the exception message for the application exception. --- .../pipeline/PipelineNotificationBuilderHtml.scala | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 e590861d6..9e7e12214 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 @@ -297,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) From a013052fc8d46f211b65ba87a6693d9cf543ef89 Mon Sep 17 00:00:00 2001 From: Ruslan Iushchenko Date: Tue, 27 Aug 2024 08:13:46 +0200 Subject: [PATCH 3/3] #476 Add test suites for the new feature. --- pramen/core/src/main/resources/reference.conf | 5 +- .../notify/message/MessageBuilderHtml.scala | 4 +- .../PipelineNotificationBuilderHtml.scala | 4 +- ...PipelineNotificationBuilderHtmlSuite.scala | 194 +++++++++++++----- 4 files changed, 150 insertions(+), 57 deletions(-) diff --git a/pramen/core/src/main/resources/reference.conf b/pramen/core/src/main/resources/reference.conf index 78ee3ba0d..bf79f7d6d 100644 --- a/pramen/core/src/main/resources/reference.conf +++ b/pramen/core/src/main/resources/reference.conf @@ -173,8 +173,9 @@ pramen { 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. - body.error.max.length = 32758 + # 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 } } 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 0f21bf8d4..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 @@ -20,7 +20,7 @@ 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.notify.pipeline.PipelineNotificationBuilderHtml.NOTIFICATION_ERROR_MAX_LENGTH_KEY +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} @@ -29,7 +29,7 @@ import scala.compat.Platform.EOL class MessageBuilderHtml(conf: Config) extends MessageBuilder { private val style = ResourceUtils.getResourceString("/email_template/style.css") - private val maxExceptionLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_ERROR_MAX_LENGTH_KEY) + private val maxExceptionLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY) private val body = new ListBuffer[String] override def withParagraph(text: Seq[TextElement]): MessageBuilderHtml = { 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 9e7e12214..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 @@ -39,7 +39,7 @@ object PipelineNotificationBuilderHtml { val MIN_RPS_RECORDS = 1000 val MIN_MEGABYTES = 10 val NOTIFICATION_REASON_MAX_LENGTH_KEY = "pramen.notifications.reason.max.length" - val NOTIFICATION_ERROR_MAX_LENGTH_KEY = "pramen.notifications.body.error.max.length" + val NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY = "pramen.notifications.exception.max.length" } class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNotificationBuilder { @@ -57,7 +57,7 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot private var goodRps = Int.MaxValue private val maxReasonLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_REASON_MAX_LENGTH_KEY) - private val maxExceptionLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_ERROR_MAX_LENGTH_KEY) + private val maxExceptionLength = ConfigUtils.getOptionInt(conf, NOTIFICATION_EXCEPTION_MAX_LENGTH_KEY) var appException: Option[Throwable] = None var warningFlag: Boolean = false 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 efc840e27..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,12 +20,13 @@ 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} @@ -50,7 +51,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "renderSubject()" should { "render normal subject" in { - val builder = getBuilder + val builder = getBuilder() builder.addAppName("MyApp") @@ -58,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) @@ -67,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")) @@ -76,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)) @@ -85,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)) @@ -99,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") @@ -112,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) @@ -151,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) @@ -176,7 +177,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "renderJobException" should { "render a job exception" in { - val notificationBuilder = getBuilder + val notificationBuilder = getBuilder() val messageBuilder = new MessageBuilderHtml(emptyConfig) val taskResult = TaskResultFactory.getDummyTaskResult(runStatus = TestPrototypes.runStatusFailure) @@ -188,11 +189,30 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis assert(actual.contains("""Job Exception""")) assert(actual.contains("""
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("""DummyJob outputting to table_out at 2022-02-18 has failed with an exception:"""))
+      assert(actual.contains("""Job Except..."""))
+      assert(actual.contains("""
java.lang.Runti...
""")) + } } "renderException" should { "render runtime exceptions" in { - val notificationBuilder = getBuilder + val notificationBuilder = getBuilder() val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = new RuntimeException("Text exception") @@ -204,8 +224,24 @@ 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("""
java.lang....
""")) + } + "render command line exceptions with logs" in { - val notificationBuilder = getBuilder + val notificationBuilder = getBuilder() val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = CmdFailedException("Command line failed", Array("Log line 1", "Log line 2")) @@ -223,7 +259,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis } "render command line exceptions without logs" in { - val notificationBuilder = getBuilder + val notificationBuilder = getBuilder() val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = CmdFailedException("Command line failed", Array.empty) @@ -235,7 +271,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis } "render process execution exceptions" in { - val notificationBuilder = getBuilder + 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")) @@ -258,7 +294,7 @@ class PipelineNotificationBuilderHtmlSuite extends AnyWordSpec with TextComparis "renderPipelineNotificationFailures" should { "render pipeline failure as an exception" in { - val notificationBuilder = getBuilder + val notificationBuilder = getBuilder() val messageBuilder = new MessageBuilderHtml(emptyConfig) val ex = new RuntimeException("Test exception") @@ -279,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)) @@ -287,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))) @@ -295,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))) @@ -303,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"))) @@ -311,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))) @@ -319,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)) @@ -327,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")))) @@ -335,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))) @@ -343,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")))) @@ -353,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) @@ -364,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) @@ -375,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) @@ -386,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) @@ -399,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) @@ -407,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) @@ -417,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) @@ -428,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) @@ -439,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) @@ -450,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) @@ -461,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) @@ -472,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) @@ -483,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))) @@ -495,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) @@ -506,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) @@ -517,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) @@ -528,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) @@ -539,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) @@ -550,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) @@ -560,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")