diff --git a/src/main/scala/com/soundcloud/periskop/client/ExceptionAggregate.scala b/src/main/scala/com/soundcloud/periskop/client/ExceptionAggregate.scala index 06495df..133c66b 100644 --- a/src/main/scala/com/soundcloud/periskop/client/ExceptionAggregate.scala +++ b/src/main/scala/com/soundcloud/periskop/client/ExceptionAggregate.scala @@ -7,7 +7,7 @@ import scala.collection.immutable.Queue private[client] case class ExceptionAggregate( totalCount: Long = 0, severity: Severity = Severity.Error, - latestExceptions: Queue[ExceptionWithContext] = Queue.empty, + latestExceptions: Queue[ExceptionOccurrence] = Queue.empty, createdAt: ZonedDateTime = ZonedDateTime.now() ) { // limit memory consumption keep only N exceptions per aggregation key @@ -15,14 +15,14 @@ private[client] case class ExceptionAggregate( def aggregationKey: String = latestExceptions.head.aggregationKey - def add(exceptionWithContext: ExceptionWithContext): ExceptionAggregate = { - require(latestExceptions.isEmpty || aggregationKey == exceptionWithContext.aggregationKey) + def add(exceptionOcurrence: ExceptionOccurrence): ExceptionAggregate = { + require(latestExceptions.isEmpty || aggregationKey == exceptionOcurrence.aggregationKey) val truncatedLatest = if (latestExceptions.size < maxExceptions) latestExceptions else latestExceptions.dequeue._2 copy( totalCount = totalCount + 1, - severity = exceptionWithContext.severity, - latestExceptions = truncatedLatest enqueue exceptionWithContext + severity = exceptionOcurrence.severity, + latestExceptions = truncatedLatest enqueue exceptionOcurrence ) } } diff --git a/src/main/scala/com/soundcloud/periskop/client/ExceptionCollector.scala b/src/main/scala/com/soundcloud/periskop/client/ExceptionCollector.scala index 65feb7f..da3a4c9 100644 --- a/src/main/scala/com/soundcloud/periskop/client/ExceptionCollector.scala +++ b/src/main/scala/com/soundcloud/periskop/client/ExceptionCollector.scala @@ -9,13 +9,19 @@ class ExceptionCollector { /** Collect an exception without providing an HTTP context. */ - def add(throwable: Throwable, severity: Severity = Severity.Error): Unit = addExceptionWithContext( + def add(throwable: Throwable, severity: Severity = Severity.Error): Unit = addExceptionOccurrence( ExceptionWithContext(throwable, severity) ) + /** Collect a message without providing an HTTP context. + */ + def addMessage(key: String, message: String, severity: Severity = Severity.Info): Unit = addExceptionOccurrence( + ExceptionMessage(key, message, severity) + ) + /** Collect an exception providing an HTTP context. */ - def addWithContext(exceptionWithContext: ExceptionWithContext): Unit = addExceptionWithContext(exceptionWithContext) + def addWithContext(ExceptionOccurrence: ExceptionOccurrence): Unit = addExceptionOccurrence(ExceptionOccurrence) /** Get a dump of all exception aggregates. */ @@ -23,7 +29,7 @@ class ExceptionCollector { exceptions.values.asScala.toSeq } - private def addExceptionWithContext(exception: ExceptionWithContext): Unit = { + private def addExceptionOccurrence(exception: ExceptionOccurrence): Unit = { exceptions.compute( exception.aggregationKey, new BiFunction[String, ExceptionAggregate, ExceptionAggregate] { diff --git a/src/main/scala/com/soundcloud/periskop/client/ExceptionExporter.scala b/src/main/scala/com/soundcloud/periskop/client/ExceptionExporter.scala index fbfde1b..137e686 100644 --- a/src/main/scala/com/soundcloud/periskop/client/ExceptionExporter.scala +++ b/src/main/scala/com/soundcloud/periskop/client/ExceptionExporter.scala @@ -22,11 +22,15 @@ class ExceptionExporter(exceptionCollector: ExceptionCollector) { jsonMapper.writeValueAsString(payload) } - private def jsonException(t: Throwable): Map[String, Any] = Map( + private def jsonExceptionWithContext(t: Throwable): Map[String, Any] = Map( "class" -> t.getClass.getName, "message" -> t.getMessage, "stacktrace" -> t.getStackTrace.map(_.toString), - "cause" -> Option(t.getCause).map(jsonException) + "cause" -> Option(t.getCause).map(jsonExceptionWithContext) + ) + + private def jsonExceptionMessage(m: String): Map[String, Any] = Map( + "message" -> m ) private def jsonHttpContext(httpContext: HttpContext): Map[String, Any] = @@ -37,13 +41,21 @@ class ExceptionExporter(exceptionCollector: ExceptionCollector) { "request_body" -> httpContext.requestBody ) - private def jsonErrorWithContext(e: ExceptionWithContext): Map[String, Any] = Map( - "error" -> jsonException(e.throwable), - "severity" -> Severity.toString(e.severity), - "uuid" -> e.uuid.toString, - "timestamp" -> e.timestamp.format(rfc3339TimeFormat), - "http_context" -> e.httpContext.map(jsonHttpContext) - ) + private def jsonErrorWithContext(e: ExceptionOccurrence): Map[String, Any] = { + val error = e match { + case ExceptionWithContext(throwable, _, _, _, _) => jsonExceptionWithContext(throwable) + case ExceptionMessage(_, message, _, _, _, _) => jsonExceptionMessage(message) + case _ => Map.empty + } + + Map( + "error" -> error, + "severity" -> Severity.toString(e.severity), + "uuid" -> e.uuid.toString, + "timestamp" -> e.timestamp.format(rfc3339TimeFormat), + "http_context" -> e.httpContext.map(jsonHttpContext) + ) + } private def jsonAggregatedErrors(aggregate: ExceptionAggregate): Map[String, Any] = Map( "aggregation_key" -> aggregate.aggregationKey, diff --git a/src/main/scala/com/soundcloud/periskop/client/ExceptionWithContext.scala b/src/main/scala/com/soundcloud/periskop/client/ExceptionOccurrence.scala similarity index 57% rename from src/main/scala/com/soundcloud/periskop/client/ExceptionWithContext.scala rename to src/main/scala/com/soundcloud/periskop/client/ExceptionOccurrence.scala index 7985188..f0ecd07 100644 --- a/src/main/scala/com/soundcloud/periskop/client/ExceptionWithContext.scala +++ b/src/main/scala/com/soundcloud/periskop/client/ExceptionOccurrence.scala @@ -2,7 +2,6 @@ package com.soundcloud.periskop.client import java.time.ZonedDateTime import java.util.UUID - import scala.util.hashing.MurmurHash3 /** Additional HTTP-related context for ExceptionWithContext. @@ -14,16 +13,28 @@ case class HttpContext( requestBody: Option[String] ) +trait ExceptionOccurrence { + def exceptionThrowable: Option[Throwable] + def severity: Severity + def uuid: UUID + def timestamp: ZonedDateTime + def httpContext: Option[HttpContext] + def aggregationKey: String + def message: String +} + /** Wraps an exception with useful metadata. */ case class ExceptionWithContext( throwable: Throwable, - severity: Severity, - uuid: UUID = UUID.randomUUID, - timestamp: ZonedDateTime = ZonedDateTime.now, - httpContext: Option[HttpContext] = None -) { + val severity: Severity, + val uuid: UUID = UUID.randomUUID, + val timestamp: ZonedDateTime = ZonedDateTime.now, + val httpContext: Option[HttpContext] = None +) extends ExceptionOccurrence { val className: String = throwable.getClass.getName + val message: String = throwable.getMessage() + val exceptionThrowable = Some(throwable) /** Key used to group exceptions (and then limit the number of kept exceptions FIFO-style). * @@ -39,3 +50,16 @@ case class ExceptionWithContext( s"$className@${backtraceHash.toHexString}" } } + +/** A simple message without throwable. + */ +case class ExceptionMessage( + val aggregationKey: String, + val message: String, + val severity: Severity, + val uuid: UUID = UUID.randomUUID, + val timestamp: ZonedDateTime = ZonedDateTime.now, + val httpContext: Option[HttpContext] = None +) extends ExceptionOccurrence { + lazy val exceptionThrowable = None +} diff --git a/src/test/scala/com/soundcloud/periskop/client/ExceptionAggregateSpec.scala b/src/test/scala/com/soundcloud/periskop/client/ExceptionAggregateSpec.scala index bde4354..c0d23fc 100644 --- a/src/test/scala/com/soundcloud/periskop/client/ExceptionAggregateSpec.scala +++ b/src/test/scala/com/soundcloud/periskop/client/ExceptionAggregateSpec.scala @@ -24,10 +24,12 @@ class ExceptionAggregateSpec extends Specification { a.totalCount ==== 3 a.latestExceptions.length ==== 3 - a.latestExceptions.head.throwable ==== e + a.latestExceptions.head.asInstanceOf[ExceptionWithContext].throwable ==== e a.latestExceptions.head.severity ==== Severity.Error - a.latestExceptions.last.throwable ==== a.latestExceptions.head.throwable + a.latestExceptions.last.asInstanceOf[ExceptionWithContext].throwable ==== a.latestExceptions.head + .asInstanceOf[ExceptionWithContext] + .throwable a.latestExceptions.last !=== a.latestExceptions.head } @@ -45,8 +47,8 @@ class ExceptionAggregateSpec extends Specification { a.totalCount ==== 15 a.latestExceptions.length ==== 10 - a.latestExceptions.head.throwable.getMessage ==== "foo 6" - a.latestExceptions.last.throwable.getMessage ==== "foo 15" + a.latestExceptions.head.asInstanceOf[ExceptionWithContext].throwable.getMessage ==== "foo 6" + a.latestExceptions.last.asInstanceOf[ExceptionWithContext].throwable.getMessage ==== "foo 15" } "throws if the aggregation key does not match" in new Context { diff --git a/src/test/scala/com/soundcloud/periskop/client/ExceptionCollectorSpec.scala b/src/test/scala/com/soundcloud/periskop/client/ExceptionCollectorSpec.scala index 2368055..545993a 100644 --- a/src/test/scala/com/soundcloud/periskop/client/ExceptionCollectorSpec.scala +++ b/src/test/scala/com/soundcloud/periskop/client/ExceptionCollectorSpec.scala @@ -6,33 +6,55 @@ import org.specs2.specification.Scope class ExceptionCollectorSpec extends Specification with Mockito { - class FooException(msg: String) extends RuntimeException + "#add" >> { + class FooException(msg: String) extends RuntimeException - trait Context extends Scope { + trait Context extends Scope { - val collector = new ExceptionCollector + val collector = new ExceptionCollector - val e: Throwable = new FooException("bar") - val ewc = ExceptionWithContext(e, Severity.Error) - } + val e: Throwable = new FooException("bar") + val ewc = ExceptionWithContext(e, Severity.Error) + } + + "saves exceptions internally and exposes them" in new Context { + collector.add(e) + collector.add(e) + collector.add(new RuntimeException("bar")) + + val a: Seq[ExceptionAggregate] = collector.getExceptionAggregates.sortBy(_.aggregationKey) + a.length ==== 2 - "saves exceptions internally and exposes them" in new Context { - collector.add(e) - collector.add(e) - collector.add(new RuntimeException("bar")) + a.head.totalCount === 2 + a.head.latestExceptions.head.asInstanceOf[ExceptionWithContext].throwable ==== e - val a: Seq[ExceptionAggregate] = collector.getExceptionAggregates.sortBy(_.aggregationKey) - a.length ==== 2 + a.last.totalCount === 1 + } - a.head.totalCount === 2 - a.head.latestExceptions.head.throwable ==== e + "accepts raw exceptions" in new Context { + collector.add(e) - a.last.totalCount === 1 + collector.getExceptionAggregates.head.latestExceptions.head.asInstanceOf[ExceptionWithContext].throwable ==== e + } } - "accepts raw exceptions" in new Context { - collector.add(e) + "#addMessage" >> { + trait Context extends Scope { + val collector = new ExceptionCollector + } + + "saves messages internally and exposes them" in new Context { + collector.addMessage("key1", "message1", Severity.Info) + collector.addMessage("key1", "message2", Severity.Info) + collector.addMessage("key2", "message3", Severity.Info) + + val a: Seq[ExceptionAggregate] = collector.getExceptionAggregates.sortBy(_.aggregationKey) + a.length ==== 2 + + a.head.totalCount === 2 + a.head.latestExceptions.head.asInstanceOf[ExceptionMessage].message ==== "message1" - collector.getExceptionAggregates.head.latestExceptions.head.throwable ==== e + a.last.totalCount === 1 + } } } diff --git a/src/test/scala/com/soundcloud/periskop/client/ExceptionExporterSpec.scala b/src/test/scala/com/soundcloud/periskop/client/ExceptionExporterSpec.scala index b53c7c4..fcd0e4d 100644 --- a/src/test/scala/com/soundcloud/periskop/client/ExceptionExporterSpec.scala +++ b/src/test/scala/com/soundcloud/periskop/client/ExceptionExporterSpec.scala @@ -28,9 +28,11 @@ class ExceptionExporterSpec extends Specification with Mockito { val uuid1 = UUID.fromString("c3c24195-27ae-4455-ba5a-7b504a7699a4") val uuid2 = UUID.fromString("f12feecd-7518-46c3-88a6-38d57804e81a") val uuid3 = UUID.fromString("ceeefdf5-cdee-4f1b-b139-b2c739d16dcf") + val uuid4 = UUID.fromString("e42e2212-fdf9-4550-95bd-0b9b4996904c") val timestamp1 = ZonedDateTime.parse("2018-01-02T11:22:33+00:00") val timestamp2 = ZonedDateTime.parse("2018-01-02T11:22:55+00:00") + val timestamp3 = ZonedDateTime.parse("2018-01-03T11:22:55+00:00") val exceptionAggregates: Seq[ExceptionAggregate] = Seq( ExceptionAggregate( @@ -88,6 +90,21 @@ class ExceptionExporterSpec extends Specification with Mockito { httpContext = None ) ) + ), + ExceptionAggregate( + totalCount = 1, + severity = Severity.Info, + createdAt = timestamp3, + latestExceptions = Queue( + ExceptionMessage( + severity = Severity.Info, + message = "some info", + aggregationKey = "some-key", + uuid = uuid4, + timestamp = timestamp3, + httpContext = None + ) + ) ) ) @@ -206,6 +223,23 @@ class ExceptionExporterSpec extends Specification with Mockito { | "http_context": null | } | ] + | }, + | { + | "aggregation_key": "some-key", + | "total_count": 1, + | "severity": "info", + | "created_at": "2018-01-03T11:22:55.000Z", + | "latest_errors": [ + | { + | "error": { + | "message": "some info" + | }, + | "severity": "info", + | "uuid": "e42e2212-fdf9-4550-95bd-0b9b4996904c", + | "timestamp": "2018-01-03T11:22:55.000Z", + | "http_context": null + | } + | ] | } | ] |} diff --git a/src/test/scala/com/soundcloud/periskop/client/ExceptionOccurrenceSpec.scala b/src/test/scala/com/soundcloud/periskop/client/ExceptionOccurrenceSpec.scala new file mode 100644 index 0000000..bc354ec --- /dev/null +++ b/src/test/scala/com/soundcloud/periskop/client/ExceptionOccurrenceSpec.scala @@ -0,0 +1,95 @@ +package com.soundcloud.periskop.client + +import org.specs2.mutable.Specification +import org.specs2.specification.Scope + +class ExceptionOccurrenceSpec extends Specification { + + class TestExceptionWithStacktrace(stacktrace: Array[StackTraceElement]) extends RuntimeException { + override def getStackTrace: Array[StackTraceElement] = stacktrace + } + + "ExceptionWithContext" >> { + trait Context extends Scope { + val e: Throwable = new RuntimeException("foo") + } + + "className is the exception class name" in new Context { + ExceptionWithContext(e, Severity.Error).className == "java.lang.RuntimeException" + } + + "aggregationKey is based on the class name and stacktrace only" in new Context { + val eArr = Array(1, 2).map { i => new RuntimeException(s"foo $i") } + val (e1, e2) = (eArr(0), eArr(1)) + val e3: Throwable = new RuntimeException("foo 2") + + // do not match on exact string, backtrace hash changes when we change code + ExceptionWithContext(e1, Severity.Error).aggregationKey must beMatching( + """\Ajava.lang.RuntimeException@[0-9a-f]{4,8}\z""".r + ) + + ExceptionWithContext(e1, Severity.Error).aggregationKey === + ExceptionWithContext(e2, Severity.Error).aggregationKey + + ExceptionWithContext(e2, Severity.Error).aggregationKey !=== + ExceptionWithContext(e3, Severity.Error).aggregationKey + } + + "aggregationKey is using only first 5 lines of stacktrace" in new Context { + val e1: Throwable = new TestExceptionWithStacktrace( + Array( + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1) + ) + ) + + val e2: Throwable = new TestExceptionWithStacktrace( + Array( + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 99) + ) + ) + + val e3: Throwable = new TestExceptionWithStacktrace( + Array( + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 1), + new StackTraceElement("x", "x", "x", 99), + new StackTraceElement("x", "x", "x", 1) + ) + ) + + ExceptionWithContext(e1, Severity.Error).aggregationKey === + ExceptionWithContext(e2, Severity.Error).aggregationKey + + ExceptionWithContext(e1, Severity.Error).aggregationKey !=== + ExceptionWithContext(e3, Severity.Error).aggregationKey + } + + "UUID values are different for each instance" in new Context { + ExceptionWithContext(e, Severity.Error).uuid !=== ExceptionWithContext(e, Severity.Error).uuid + } + } + + "ExceptionMessage" >> { + trait Context extends Scope {} + + "UUID values are different for each instance" in new Context { + ExceptionMessage("key", "message", Severity.Info).uuid !=== ExceptionMessage( + "key2", + "message2", + Severity.Info + ).uuid + } + } +} diff --git a/src/test/scala/com/soundcloud/periskop/client/ExceptionWithContextSpec.scala b/src/test/scala/com/soundcloud/periskop/client/ExceptionWithContextSpec.scala deleted file mode 100644 index 40e8486..0000000 --- a/src/test/scala/com/soundcloud/periskop/client/ExceptionWithContextSpec.scala +++ /dev/null @@ -1,82 +0,0 @@ -package com.soundcloud.periskop.client - -import org.specs2.mutable.Specification -import org.specs2.specification.Scope - -class ExceptionWithContextSpec extends Specification { - - class TestExceptionWithStacktrace(stacktrace: Array[StackTraceElement]) extends RuntimeException { - override def getStackTrace: Array[StackTraceElement] = stacktrace - } - - trait Context extends Scope { - val e: Throwable = new RuntimeException("foo") - } - - "className is the exception class name" in new Context { - ExceptionWithContext(e, Severity.Error).className == "java.lang.RuntimeException" - } - - "aggregationKey is based on the class name and stacktrace only" in new Context { - val eArr = Array(1, 2).map { i => new RuntimeException(s"foo $i") } - val (e1, e2) = (eArr(0), eArr(1)) - val e3: Throwable = new RuntimeException("foo 2") - - // do not match on exact string, backtrace hash changes when we change code - ExceptionWithContext(e1, Severity.Error).aggregationKey must beMatching( - """\Ajava.lang.RuntimeException@[0-9a-f]{4,8}\z""".r - ) - - ExceptionWithContext(e1, Severity.Error).aggregationKey === - ExceptionWithContext(e2, Severity.Error).aggregationKey - - ExceptionWithContext(e2, Severity.Error).aggregationKey !=== - ExceptionWithContext(e3, Severity.Error).aggregationKey - } - - "aggregationKey is using only first 5 lines of stacktrace" in new Context { - val e1: Throwable = new TestExceptionWithStacktrace( - Array( - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1) - ) - ) - - val e2: Throwable = new TestExceptionWithStacktrace( - Array( - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 99) - ) - ) - - val e3: Throwable = new TestExceptionWithStacktrace( - Array( - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 1), - new StackTraceElement("x", "x", "x", 99), - new StackTraceElement("x", "x", "x", 1) - ) - ) - - ExceptionWithContext(e1, Severity.Error).aggregationKey === - ExceptionWithContext(e2, Severity.Error).aggregationKey - - ExceptionWithContext(e1, Severity.Error).aggregationKey !=== - ExceptionWithContext(e3, Severity.Error).aggregationKey - } - - "UUID values are different for each instance" in new Context { - ExceptionWithContext(e, Severity.Error).uuid !=== ExceptionWithContext(e, Severity.Error).uuid - } - -}