Skip to content

Commit

Permalink
Add support to collect messages without throwables (#11)
Browse files Browse the repository at this point in the history
* Add support to collect messages wihout throwables

  - Periskop's aggregation logic in combination with the ability to
    collect HTTP contexts makes it a very powerful tool to expose events
    of interest beyond regular exceptions. Examples include
    branch-and-compare diffs and inconsistency detection.
  - This change introduces ExceptionOccurrence as a type representing
    both regular messages and exceptions.
  - Periskop has already been updated to handle these cases nicely.
  - See periskop-dev/periskop#174
  • Loading branch information
jcreixell authored Jun 30, 2021
1 parent e8951b6 commit 2620d9e
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 127 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ 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
val maxExceptions = 10

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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,27 @@ 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.
*/
def getExceptionAggregates: Seq[ExceptionAggregate] = {
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] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] =
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
*
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
)
)
)

Expand Down Expand Up @@ -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
| }
| ]
| }
| ]
|}
Expand Down
Loading

0 comments on commit 2620d9e

Please sign in to comment.