diff --git a/build.sbt b/build.sbt index bcad070088..c58d000589 100644 --- a/build.sbt +++ b/build.sbt @@ -129,9 +129,6 @@ val playJsonVersion = "3.0.6" val catsEffect_3_version = "3.7.0" val fs2_3_version = "3.13.0" -// This version provides Scala Native 0.5.x support. Drop this when 3.7.0 is released. -val catsEffect_3_RC_version = "3.7.0-RC1" - val catsEffect_2_version = "2.5.5" val fs2_2_version = "2.5.13" @@ -170,8 +167,8 @@ val osLibVersion = "0.11.4" val tethysVersion = "0.29.8" val openTelemetryVersion = "1.59.0" val openTelemetrySemconvVersion = "1.40.0" -val otel4s = "0.15.2" -val otel4sSdk = "0.17.0" +val otel4s = "0.16.0" +val otel4sSdk = "0.18.0" val slf4jVersion = "1.7.36" val compileAndTest = "compile->compile;test->test" @@ -397,13 +394,10 @@ lazy val catsCe2 = (projectMatrix in file("effects/cats-ce2")) ) lazy val catsEffect = Def.setting { - val ceVersion = - if (virtualAxes.value.contains(VirtualAxis.native)) catsEffect_3_RC_version - else catsEffect_3_version Seq( - "org.typelevel" %%% "cats-effect-kernel" % ceVersion, - "org.typelevel" %%% "cats-effect-std" % ceVersion, - "org.typelevel" %%% "cats-effect" % ceVersion % Test + "org.typelevel" %%% "cats-effect-kernel" % catsEffect_3_version, + "org.typelevel" %%% "cats-effect-std" % catsEffect_3_version, + "org.typelevel" %%% "cats-effect" % catsEffect_3_version % Test ) } @@ -424,7 +418,7 @@ lazy val cats = (projectMatrix in file("effects/cats")) settings = commonJsSettings ++ commonJsBackendSettings ++ browserChromeTestSettings ++ testServerSettings ) .nativePlatform( - scalaVersions = List(scala3), + scalaVersions = scala2And3, settings = commonNativeSettings ++ testServerSettings ) @@ -975,13 +969,14 @@ lazy val otel4sMetricsBackend = (projectMatrix in file("observability/otel4s-met libraryDependencies ++= Seq( "org.typelevel" %%% "otel4s-core-metrics" % otel4s, "org.typelevel" %%% "otel4s-semconv" % otel4s, - "org.typelevel" %%% "otel4s-semconv-experimental" % otel4s, + "org.typelevel" %%% "otel4s-semconv-experimental" % otel4s % Test, "org.typelevel" %%% "otel4s-semconv-metrics-experimental" % otel4s % Test, "org.typelevel" %%% "otel4s-sdk-metrics-testkit" % otel4sSdk % Test ) ) .jvmPlatform(scalaVersions = scala2_13And3, settings = commonJvmSettings) .jsPlatform(scalaVersions = scala2_13And3, settings = commonJsSettings) + .nativePlatform(scalaVersions = scala2_13And3, settings = commonNativeSettings) .dependsOn(cats % Test) .dependsOn(core % compileAndTest) @@ -997,6 +992,7 @@ lazy val otel4sTracingBackend = (projectMatrix in file("observability/otel4s-tra ) .jvmPlatform(scalaVersions = scala2_13And3, settings = commonJvmSettings) .jsPlatform(scalaVersions = scala2_13And3, settings = commonJsSettings) + .nativePlatform(scalaVersions = scala2_13And3, settings = commonNativeSettings) .dependsOn(cats % Test) .dependsOn(core % compileAndTest) diff --git a/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlCatsBackend.scala b/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlCatsBackend.scala index f5dbf1d3a5..ce5203c3d6 100644 --- a/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlCatsBackend.scala +++ b/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlCatsBackend.scala @@ -1,7 +1,6 @@ package sttp.client4.curl.cats import cats.effect.kernel.Sync -import sttp.capabilities.Effect import sttp.client4.curl.AbstractSyncCurlBackend import sttp.client4.impl.cats.CatsMonadError import sttp.client4.wrappers.FollowRedirectsBackend @@ -9,9 +8,7 @@ import sttp.client4.Backend class CurlCatsBackend[F[_]: Sync] private (verbose: Boolean) extends AbstractSyncCurlBackend[F](new CatsMonadError[F], verbose) - with Backend[F] { - override type R = Effect[F] -} + with Backend[F] object CurlCatsBackend { diff --git a/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackend.scala b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackend.scala index b5adb5b65a..00139f0624 100644 --- a/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackend.scala +++ b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackend.scala @@ -18,7 +18,6 @@ import org.typelevel.otel4s.semconv.attributes.{ ServerAttributes, UrlAttributes } -import org.typelevel.otel4s.semconv.experimental.attributes.UrlExperimentalAttributes import sttp.client4.listener.{ListenerBackend, RequestListener} import sttp.client4._ import sttp.model.{HttpVersion, ResponseMetadata, StatusCode} diff --git a/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlExperimentalAttributes.scala b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlExperimentalAttributes.scala new file mode 100644 index 0000000000..80cef3da3c --- /dev/null +++ b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlExperimentalAttributes.scala @@ -0,0 +1,9 @@ +package sttp.client4.opentelemetry.otel4s + +import org.typelevel.otel4s.AttributeKey + +private object UrlExperimentalAttributes { + // url.template is still experimental, so we need to wait for it to be stable before pulling it from the otel4s semconv + val UrlTemplate: AttributeKey[String] = + AttributeKey("url.template") +} diff --git a/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackendTest.scala b/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackendTest.scala index a944194a35..466afbded3 100644 --- a/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackendTest.scala +++ b/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackendTest.scala @@ -1,14 +1,14 @@ package sttp.client4.opentelemetry.otel4s import cats.effect.IO -import cats.effect.unsafe.IORuntime import cats.effect.unsafe.implicits.global -import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.freespec.AsyncFreeSpec import org.scalatest.matchers.should.Matchers import org.typelevel.otel4s.metrics.MeterProvider import org.typelevel.otel4s.sdk.metrics.data.MetricData +import org.typelevel.otel4s.sdk.testkit.metrics.{MetricExpectation, MetricExpectations} import org.typelevel.otel4s.sdk.testkit.metrics.MetricsTestkit +import org.typelevel.otel4s.semconv.metrics.HttpMetrics import org.typelevel.otel4s.semconv.experimental.metrics.HttpExperimentalMetrics import org.typelevel.otel4s.semconv.{MetricSpec, Requirement} import sttp.model.{Header, StatusCode} @@ -16,17 +16,14 @@ import sttp.client4._ import sttp.client4.impl.cats.CatsMonadAsyncError import sttp.client4.testing.{BackendStub, ResponseStub, StubBody} -import scala.concurrent.ExecutionContext import scala.concurrent.duration._ -class Otel4sMetricsBackendTest extends AsyncFreeSpec with Matchers { - - override def executionContext: ExecutionContext = ExecutionContext.global +class Otel4sMetricsBackendTest extends AsyncFreeSpec with Matchers with AsyncExecutionContext { "Otel4sMetricsBackend" - { "should pass the client semantic test" in { val specs = List( - HttpExperimentalMetrics.ClientRequestDuration, + HttpMetrics.ClientRequestDuration, HttpExperimentalMetrics.ClientRequestBodySize, HttpExperimentalMetrics.ClientResponseBodySize, HttpExperimentalMetrics.ClientActiveRequests @@ -50,41 +47,41 @@ class Otel4sMetricsBackendTest extends AsyncFreeSpec with Matchers { makeBackend.use { backend => for { - r <- backend.send(basicRequest.post(uri"http://localhost:8080/success").body("payload")) + _ <- backend.send(basicRequest.post(uri"http://localhost:8080/success").body("payload")) // we use `.unsafeRunAndForget()` in the backend and JS could be slow _ <- IO.sleep(1.second) metrics <- testkit.collectMetrics - _ = specs.foreach(spec => specTest(metrics, spec)) - } yield succeed + } yield assertMetricsMatch(metrics, specs.map(metricExpectation)) } } .unsafeToFuture() } } - private def specTest(metrics: List[MetricData], spec: MetricSpec): Unit = { - val metric = metrics.find(_.name == spec.name) - assert( - metric.isDefined, - s"${spec.name} metric is missing. Available [${metrics.map(_.name).mkString(", ")}]" - ) - - metric.foreach { md => - md.name shouldBe spec.name - md.description shouldBe Some(spec.description) - md.unit shouldBe Some(spec.unit) - - val required = spec.attributeSpecs - .filter(_.requirement.level == Requirement.Level.Required) - .map(_.key) - .toSet + private def metricExpectation(spec: MetricSpec): MetricExpectation = { + val required = spec.attributeSpecs + .filter(_.requirement.level == Requirement.Level.Required) + .map(_.key) + .toSet - val current = md.data.points.toVector - .flatMap(_.attributes.map(_.key)) - .filter(key => required.contains(key)) - .toSet + MetricExpectation + .name(spec.name) + .description(spec.description) + .unit(spec.unit) + .clue(spec.name) + .where("required semantic-convention attributes are present") { metric => + metric.data.points.iterator + .flatMap(_.attributes.iterator.map(_.key)) + .filter(required.contains) + .toSet == required + } + } - current shouldBe required + private def assertMetricsMatch(metrics: List[MetricData], expectations: List[MetricExpectation]) = + MetricExpectations.checkAllDistinct(metrics, expectations) match { + case Right(_) => + succeed + case Left(mismatches) => + fail(MetricExpectations.format(mismatches)) } - } } diff --git a/observability/otel4s-metrics-backend/src/test/scalajs/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala b/observability/otel4s-metrics-backend/src/test/scalajs/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala new file mode 100644 index 0000000000..5c7557c662 --- /dev/null +++ b/observability/otel4s-metrics-backend/src/test/scalajs/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala @@ -0,0 +1,9 @@ +package sttp.client4.opentelemetry.otel4s + +import org.scalatest.AsyncTestSuite + +import scala.concurrent.ExecutionContext + +trait AsyncExecutionContext { self: AsyncTestSuite => + override def executionContext: ExecutionContext = ExecutionContext.global +} diff --git a/observability/otel4s-metrics-backend/src/test/scalajvm/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala b/observability/otel4s-metrics-backend/src/test/scalajvm/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala new file mode 100644 index 0000000000..141199e530 --- /dev/null +++ b/observability/otel4s-metrics-backend/src/test/scalajvm/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala @@ -0,0 +1,3 @@ +package sttp.client4.opentelemetry.otel4s + +trait AsyncExecutionContext {} diff --git a/observability/otel4s-metrics-backend/src/test/scalanative/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala b/observability/otel4s-metrics-backend/src/test/scalanative/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala new file mode 100644 index 0000000000..141199e530 --- /dev/null +++ b/observability/otel4s-metrics-backend/src/test/scalanative/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala @@ -0,0 +1,3 @@ +package sttp.client4.opentelemetry.otel4s + +trait AsyncExecutionContext {} diff --git a/observability/otel4s-tracing-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sTracingBackendTest.scala b/observability/otel4s-tracing-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sTracingBackendTest.scala index 3e354250ba..9ff008c7e6 100644 --- a/observability/otel4s-tracing-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sTracingBackendTest.scala +++ b/observability/otel4s-tracing-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sTracingBackendTest.scala @@ -22,23 +22,20 @@ import cats.effect.unsafe.implicits.global import org.scalatest.freespec.AsyncFreeSpec import org.scalatest.matchers.should.Matchers import org.typelevel.otel4s.sdk.data.LimitedData -import org.typelevel.otel4s.sdk.testkit.trace.TracesTestkit +import org.typelevel.otel4s.sdk.testkit.trace._ import org.typelevel.otel4s.sdk.trace.context.propagation.W3CTraceContextPropagator -import org.typelevel.otel4s.sdk.trace.data.{EventData, StatusData} -import org.typelevel.otel4s.trace.{StatusCode, TracerProvider} +import org.typelevel.otel4s.sdk.trace.data.{EventData, SpanData} +import org.typelevel.otel4s.trace.TracerProvider import org.typelevel.otel4s.{Attribute, Attributes} import sttp.client4._ import sttp.client4.impl.cats.CatsMonadAsyncError import sttp.client4.testing.{BackendStub, ResponseStub, StubBody} import sttp.model.{StatusCode => HttpStatusCode} -import scala.concurrent.ExecutionContext import scala.concurrent.duration.Duration import scala.util.control.NoStackTrace -class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { - - override def executionContext: ExecutionContext = ExecutionContext.global +class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers with AsyncExecutionContext { "Otel4sTracingBackend" - { "should add tracing headers to the request" in { @@ -86,8 +83,6 @@ class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { response <- backend.send(basicRequest.get(uri"http://user:pwd@localhost:8080/success?q=v")) spans <- testkit.finishedSpans } yield { - val status = StatusData(StatusCode.Unset) - val attributes = Attributes( Attribute("http.request.method", "GET"), Attribute("http.response.status_code", 200L), @@ -99,11 +94,19 @@ class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { response.code shouldBe HttpStatusCode.Ok - spans.map(_.attributes.elements) shouldBe List(attributes) - spans.map(_.events.elements) shouldBe List(Vector.empty) - spans.map(_.status) shouldBe List(status) - - succeed + assertSpansMatch( + spans, + TraceForestExpectation.ordered( + TraceExpectation.leaf( + SpanExpectation + .client("GET") + .noParentSpanContext + .attributesExact(attributes) + .status(StatusExpectation.unset) + .eventCount(0) + ) + ) + ) } } .unsafeToFuture() @@ -130,8 +133,6 @@ class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { response <- backend.send(basicRequest.get(uri"http://user@localhost:8080/bad-request?q=v")) spans <- testkit.finishedSpans } yield { - val status = StatusData(StatusCode.Error) - val attributes = Attributes( Attribute("http.request.method", "GET"), Attribute("http.response.status_code", 400L), @@ -144,11 +145,19 @@ class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { response.code shouldBe HttpStatusCode.BadRequest - spans.map(_.attributes.elements) shouldBe List(attributes) - spans.map(_.events.elements) shouldBe List(Vector.empty) - spans.map(_.status) shouldBe List(status) - - succeed + assertSpansMatch( + spans, + TraceForestExpectation.ordered( + TraceExpectation.leaf( + SpanExpectation + .client("GET") + .noParentSpanContext + .attributesExact(attributes) + .status(StatusExpectation.error) + .eventCount(0) + ) + ) + ) } } .unsafeToFuture() @@ -179,8 +188,6 @@ class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { response <- backend.send(basicRequest.get(uri"http://localhost/error")).attempt spans <- testkit.finishedSpans } yield { - val status = StatusData(StatusCode.Error) - val attributes = Attributes( Attribute("http.request.method", "GET"), Attribute("server.address", "localhost"), @@ -197,11 +204,21 @@ class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { response shouldBe Left(Err) - spans.map(_.attributes.elements) shouldBe List(attributes) - spans.map(_.events.elements) shouldBe List(Vector(event)) - spans.map(_.status) shouldBe List(status) - - succeed + assertSpansMatch( + spans, + TraceForestExpectation.ordered( + TraceExpectation.leaf( + SpanExpectation + .client("GET") + .noParentSpanContext + .attributesExact(attributes) + .status(StatusExpectation.error) + .exactlyEvents( + EventExpectation.any.where("matches the recorded exception event")(_ == event) + ) + ) + ) + ) } } } @@ -209,4 +226,12 @@ class Otel4sTracingBackendTest extends AsyncFreeSpec with Matchers { } } + private def assertSpansMatch(spans: List[SpanData], expectation: TraceForestExpectation) = + TraceExpectations.check(spans, expectation) match { + case Right(_) => + succeed + case Left(mismatches) => + fail(TraceExpectations.format(mismatches)) + } + } diff --git a/observability/otel4s-tracing-backend/src/test/scalajs/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala b/observability/otel4s-tracing-backend/src/test/scalajs/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala new file mode 100644 index 0000000000..5c7557c662 --- /dev/null +++ b/observability/otel4s-tracing-backend/src/test/scalajs/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala @@ -0,0 +1,9 @@ +package sttp.client4.opentelemetry.otel4s + +import org.scalatest.AsyncTestSuite + +import scala.concurrent.ExecutionContext + +trait AsyncExecutionContext { self: AsyncTestSuite => + override def executionContext: ExecutionContext = ExecutionContext.global +} diff --git a/observability/otel4s-tracing-backend/src/test/scalajvm/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala b/observability/otel4s-tracing-backend/src/test/scalajvm/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala new file mode 100644 index 0000000000..141199e530 --- /dev/null +++ b/observability/otel4s-tracing-backend/src/test/scalajvm/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala @@ -0,0 +1,3 @@ +package sttp.client4.opentelemetry.otel4s + +trait AsyncExecutionContext {} diff --git a/observability/otel4s-tracing-backend/src/test/scalanative/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala b/observability/otel4s-tracing-backend/src/test/scalanative/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala new file mode 100644 index 0000000000..141199e530 --- /dev/null +++ b/observability/otel4s-tracing-backend/src/test/scalanative/sttp/client4/opentelemetry/otel4s/AsyncExecutionContext.scala @@ -0,0 +1,3 @@ +package sttp.client4.opentelemetry.otel4s + +trait AsyncExecutionContext {}