diff --git a/build.sbt b/build.sbt index fbd448c91..55d194cb7 100644 --- a/build.sbt +++ b/build.sbt @@ -181,6 +181,10 @@ lazy val `core-logs` = crossProject(JVMPlatform, JSPlatform, NativePlatform) libraryDependencies ++= Seq( "org.typelevel" %%% "cats-laws" % CatsVersion % Test, "org.typelevel" %%% "discipline-munit" % MUnitDisciplineVersion % Test + ), + mimaBinaryIssueFilters ++= Seq( + // LogRecordBuilder is sealed + ProblemFilters.exclude[ReversedMissingMethodProblem]("org.typelevel.otel4s.logs.LogRecordBuilder.withException") ) ) diff --git a/core/logs/src/main/scala/org/typelevel/otel4s/logs/LogRecordBuilder.scala b/core/logs/src/main/scala/org/typelevel/otel4s/logs/LogRecordBuilder.scala index 7f431f154..28c0c07a2 100644 --- a/core/logs/src/main/scala/org/typelevel/otel4s/logs/LogRecordBuilder.scala +++ b/core/logs/src/main/scala/org/typelevel/otel4s/logs/LogRecordBuilder.scala @@ -114,6 +114,13 @@ sealed trait LogRecordBuilder[F[_], Ctx] { */ def withEventName(eventName: String): LogRecordBuilder[F, Ctx] + /** Sets `exception.*` attributes based on the given `Throwable`: + * - `exception.type` is set to the exception class name + * - `exception.message` is set to the exception message + * - `exception.stacktrace` is set to the exception stack trace as a string + */ + def withException(exception: Throwable): LogRecordBuilder[F, Ctx] + /** Adds the given attribute to the builder. * * @note @@ -162,6 +169,7 @@ object LogRecordBuilder { def withSeverityText(severityText: String): LogRecordBuilder[F, Ctx] = this def withBody(body: AnyValue): LogRecordBuilder[F, Ctx] = this def withEventName(eventName: String): LogRecordBuilder[F, Ctx] = this + def withException(exception: Throwable): LogRecordBuilder[F, Ctx] = this def addAttribute[A](attribute: Attribute[A]): LogRecordBuilder[F, Ctx] = this def addAttributes(attributes: Attribute[_]*): LogRecordBuilder[F, Ctx] = this def addAttributes(attributes: immutable.Iterable[Attribute[_]]): LogRecordBuilder[F, Ctx] = this @@ -200,6 +208,9 @@ object LogRecordBuilder { def withEventName(eventName: String): LogRecordBuilder[G, Ctx] = builder.withEventName(eventName).liftTo + def withException(exception: Throwable): LogRecordBuilder[G, Ctx] = + builder.withException(exception).liftTo + def addAttribute[A](attribute: Attribute[A]): LogRecordBuilder[G, Ctx] = builder.addAttribute(attribute).liftTo diff --git a/docs/instrumentation/logs.md b/docs/instrumentation/logs.md index ff2a4a514..3ff9cdcc7 100644 --- a/docs/instrumentation/logs.md +++ b/docs/instrumentation/logs.md @@ -90,12 +90,10 @@ import cats.syntax.all._ import org.typelevel.otel4s.{AnyValue, Attribute, Attributes} import org.typelevel.otel4s.logs.{LogRecordBuilder, LoggerProvider, Severity} import org.typelevel.otel4s.logs.{Logger => OtelLogger} -import org.typelevel.otel4s.semconv.attributes.{CodeAttributes, ExceptionAttributes} +import org.typelevel.otel4s.semconv.attributes.CodeAttributes import scribe._ -import java.io.{PrintWriter, StringWriter} - import scala.concurrent.duration._ import scala.util.chaining._ @@ -162,7 +160,7 @@ final class ScriberLoggerSupport[F[_]: Monad, Ctx]( .collect { case scribe.throwable.TraceLoggableMessage(throwable) => throwable } - .foldLeft(builder)((b, t) => b.addAttributes(exceptionAttributes(t))) + .foldLeft(builder)((b, t) => b.withException(t)) } // context // MDC @@ -193,28 +191,6 @@ final class ScriberLoggerSupport[F[_]: Monad, Ctx]( builder.result() } - - private def exceptionAttributes(exception: Throwable): Attributes = { - val builder = Attributes.newBuilder - - builder += ExceptionAttributes.ExceptionType(exception.getClass.getName) - - val message = exception.getMessage - if (message != null) { - builder += ExceptionAttributes.ExceptionMessage(message) - - } - - if (exception.getStackTrace.nonEmpty) { - val stringWriter = new StringWriter() - val printWriter = new PrintWriter(stringWriter) - - exception.printStackTrace(printWriter) - builder += ExceptionAttributes.ExceptionStacktrace(stringWriter.toString) - } - - builder.result() - } private def dataAttributes(data: Map[String, () => Any]): Attributes = { val builder = Attributes.newBuilder diff --git a/oteljava/logs/src/main/scala/org/typelevel/otel4s/oteljava/logs/LogRecordBuilderImpl.scala b/oteljava/logs/src/main/scala/org/typelevel/otel4s/oteljava/logs/LogRecordBuilderImpl.scala index 1781ac10b..55b7b6607 100644 --- a/oteljava/logs/src/main/scala/org/typelevel/otel4s/oteljava/logs/LogRecordBuilderImpl.scala +++ b/oteljava/logs/src/main/scala/org/typelevel/otel4s/oteljava/logs/LogRecordBuilderImpl.scala @@ -67,6 +67,9 @@ private[oteljava] final case class LogRecordBuilderImpl[F[_]: Sync: AskContext]( def withEventName(eventName: String): LogRecordBuilder[F, Context] = copy(jBuilder = jBuilder.setEventName(eventName)) + def withException(exception: Throwable): LogRecordBuilder[F, Context] = + copy(jBuilder = jBuilder.setException(exception)) + def addAttribute[A](attribute: Attribute[A]): LogRecordBuilder[F, Context] = copy(jBuilder = jBuilder.setAttribute(attribute.key.toJava.asInstanceOf[JAttributeKey[Any]], attribute.value)) diff --git a/oteljava/logs/src/test/scala/org/typelevel/otel4s/oteljava/logs/LogsSuite.scala b/oteljava/logs/src/test/scala/org/typelevel/otel4s/oteljava/logs/LogsSuite.scala index 9d4980e57..0af6f0505 100644 --- a/oteljava/logs/src/test/scala/org/typelevel/otel4s/oteljava/logs/LogsSuite.scala +++ b/oteljava/logs/src/test/scala/org/typelevel/otel4s/oteljava/logs/LogsSuite.scala @@ -17,6 +17,7 @@ package org.typelevel.otel4s.oteljava.logs import cats.effect.IO +import io.opentelemetry.api.common.{AttributeKey => JAttributeKey} import io.opentelemetry.api.common.{Value => JValue} import io.opentelemetry.api.common.KeyValue import io.opentelemetry.api.common.ValueType @@ -36,6 +37,7 @@ import org.scalacheck.Arbitrary import org.scalacheck.Gen import org.scalacheck.effect.PropF import org.typelevel.otel4s.AnyValue +import org.typelevel.otel4s.Attribute import org.typelevel.otel4s.Attributes import org.typelevel.otel4s.logs.Severity import org.typelevel.otel4s.logs.scalacheck.Arbitraries._ @@ -65,14 +67,7 @@ class LogsSuite extends CatsEffectSuite with ScalaCheckEffectSuite { attributes: Attributes, ) => val exporter = InMemoryLogRecordExporter.create() - val sdk = OpenTelemetrySdk - .builder() - .setLoggerProvider( - SdkLoggerProvider.builder().addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)).build() - ) - .build() - - val logs = Logs.fromJOpenTelemetry[IO](sdk) + val logs = createLogsModule(exporter) val loggerName = "test-logger" val loggerVersion = "1.0.0" @@ -127,6 +122,73 @@ class LogsSuite extends CatsEffectSuite with ScalaCheckEffectSuite { } } + test("withException emits exception attributes") { + val exporter = InMemoryLogRecordExporter.create() + val logs = createLogsModule(exporter) + val exception = new RuntimeException("error") + + for { + logger <- logs.loggerProvider.logger("test-logger").get + _ <- logger.logRecordBuilder.withException(exception).emit + items <- IO.delay(exporter.getFinishedLogRecordItems.asScala.toList) + } yield { + assertEquals(items.size, 1) + val attributes = items.head.getAttributes + + assertEquals( + attributes.get(JAttributeKey.stringKey("exception.type")), + classOf[RuntimeException].getCanonicalName + ) + assertEquals( + attributes.get(JAttributeKey.stringKey("exception.message")), + "error" + ) + assert( + attributes.get(JAttributeKey.stringKey("exception.stacktrace")) != null + ) + } + } + + test("withException keeps user-supplied exception attributes") { + val exporter = InMemoryLogRecordExporter.create() + val logs = createLogsModule(exporter) + + for { + logger <- logs.loggerProvider.logger("test-logger").get + _ <- logger.logRecordBuilder + .addAttribute(Attribute("exception.message", "custom message")) + .withException(new RuntimeException("error")) + .emit + items <- IO.delay(exporter.getFinishedLogRecordItems.asScala.toList) + } yield { + assertEquals(items.size, 1) + val attributes = items.head.getAttributes + + assertEquals( + attributes.get(JAttributeKey.stringKey("exception.type")), + classOf[RuntimeException].getCanonicalName + ) + assertEquals( + attributes.get(JAttributeKey.stringKey("exception.message")), + "custom message" + ) + assert( + attributes.get(JAttributeKey.stringKey("exception.stacktrace")) != null + ) + } + } + + private def createLogsModule(exporter: InMemoryLogRecordExporter): Logs[IO] = { + val sdk = OpenTelemetrySdk + .builder() + .setLoggerProvider( + SdkLoggerProvider.builder().addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)).build() + ) + .build() + + Logs.fromJOpenTelemetry[IO](sdk) + } + private implicit val compareJValue: Compare[JValue[Any], JValue[Any]] = (left, right) => { (left.getType, right.getType) match {