diff --git a/.devcontainer/Dockerfile.app b/.devcontainer/Dockerfile.app index 604a007548..888de2b479 100644 --- a/.devcontainer/Dockerfile.app +++ b/.devcontainer/Dockerfile.app @@ -31,7 +31,7 @@ ENV PATH="/home/vscode/.local/bin:/home/vscode/.local/share/mise/shims:$PATH" # Development stacks (managed by sandcat init --stacks): RUN mise use -g java@lts -RUN mise use -g scala@latest # END STACKS# END STACKS mise use -g sbt@latest +RUN mise use -g scala@latest && mise use -g sbt@latest # END STACKS # If Java was installed above, bake JAVA_HOME and JAVA_TOOL_OPTIONS into diff --git a/build.sbt b/build.sbt index 9d10f2687e..e649695d80 100644 --- a/build.sbt +++ b/build.sbt @@ -967,6 +967,7 @@ 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-metrics-experimental" % otel4s % Test, "org.typelevel" %%% "otel4s-sdk-metrics-testkit" % otel4sSdk % Test ) diff --git a/docs/backends/wrappers/opentelemetry.md b/docs/backends/wrappers/opentelemetry.md index 4234560c4f..4628789e37 100644 --- a/docs/backends/wrappers/opentelemetry.md +++ b/docs/backends/wrappers/opentelemetry.md @@ -157,7 +157,49 @@ The following metrics are available by default: - [http.client.response.body.size](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientresponsebodysize) - [http.client.active_requests](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientactive_requests) -You can customize histogram buckets by providing a custom `Otel4sMetricsConfig`. +You can customize histogram buckets and URL template behavior by providing a custom `Otel4sMetricsConfig`. + +### URL template + +The `url.template` [experimental attribute](https://opentelemetry.io/docs/specs/semconv/attributes-registry/url/) is not added by default, as URL structures vary widely across APIs. To enable it, provide a `GenericRequest[_, _] => Option[String]` function via the `urlTemplate` config field. Because the function receives the full request, you can use request attributes to pass the template from the call site. + +A built-in implementation is available in `UrlTemplates.replaceIds`: it replaces UUIDs and numeric IDs in path segments and query values with `{id}`, and always returns `Some` (the URL unchanged when no IDs are found). + +```scala mdoc:compile-only +import cats.effect.* +import org.typelevel.otel4s.metrics.MeterProvider +import sttp.client4.* +import sttp.client4.opentelemetry.otel4s.* + +implicit val meterProvider: MeterProvider[IO] = ??? +val catsBackend: Backend[IO] = ??? + +// Use the built-in implementation (replaces UUIDs and numeric IDs with {id}): +Otel4sMetricsBackend( + catsBackend, + Otel4sMetricsConfig( + requestDurationHistogramBuckets = Otel4sMetricsConfig.DefaultDurationBuckets, + requestBodySizeHistogramBuckets = None, + responseBodySizeHistogramBuckets = None, + urlTemplate = UrlTemplates.replaceIds + ) +) + +// Or provide a custom function based on request attributes: +import sttp.attributes.AttributeKey +val UrlTemplateKey = new AttributeKey[String]("UrlTemplateKey") +Otel4sMetricsBackend( + catsBackend, + Otel4sMetricsConfig( + requestDurationHistogramBuckets = Otel4sMetricsConfig.DefaultDurationBuckets, + requestBodySizeHistogramBuckets = None, + responseBodySizeHistogramBuckets = None, + urlTemplate = req => req.attribute(UrlTemplateKey) + ) +) +// Then, at the call site: +// basicRequest.get(uri"...").attribute(UrlTemplateKey, "/users/{id}") +``` ## Tracing (cats-effect, otel4s) 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 1ceca8643d..b5adb5b65a 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,6 +18,7 @@ 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} @@ -103,7 +104,8 @@ object Otel4sMetricsBackend { requestBodySize, responseBodySize, activeRequests, - dispatcher + dispatcher, + config.urlTemplate ) private final case class State(start: FiniteDuration, activeRequestsAttributes: Attributes) @@ -113,7 +115,8 @@ object Otel4sMetricsBackend { requestBodySize: Histogram[F, Long], responseBodySize: Histogram[F, Long], activeRequests: UpDownCounter[F, Long], - dispatcher: Dispatcher[F] + dispatcher: Dispatcher[F], + urlTemplate: GenericRequest[_, _] => Option[String] ) extends RequestListener[F, State] { def before(request: GenericRequest[_, _]): F[State] = for { @@ -173,6 +176,7 @@ object Otel4sMetricsBackend { b ++= ServerAttributes.ServerAddress.maybe(request.uri.host) b ++= ServerAttributes.ServerPort.maybe(request.uri.port.map(_.toLong)) b ++= UrlAttributes.UrlScheme.maybe(request.uri.scheme) + b ++= UrlExperimentalAttributes.UrlTemplate.maybe(urlTemplate(request)) b.result() } @@ -196,6 +200,7 @@ object Otel4sMetricsBackend { b ++= ServerAttributes.ServerPort.maybe(request.uri.port.map(_.toLong)) b ++= NetworkAttributes.NetworkProtocolVersion.maybe(request.httpVersion.map(networkProtocol)) b ++= UrlAttributes.UrlScheme.maybe(request.uri.scheme) + b ++= UrlExperimentalAttributes.UrlTemplate.maybe(urlTemplate(request)) // response b ++= HttpAttributes.HttpResponseStatusCode.maybe(responseStatusCode.map(_.code.toLong)) @@ -211,6 +216,7 @@ object Otel4sMetricsBackend { case HttpVersion.HTTP_2 => "2" case HttpVersion.HTTP_3 => "3" } + } } diff --git a/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsConfig.scala b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsConfig.scala index 81d6a81fe1..45428cfba9 100644 --- a/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsConfig.scala +++ b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsConfig.scala @@ -1,11 +1,13 @@ package sttp.client4.opentelemetry.otel4s import org.typelevel.otel4s.metrics.BucketBoundaries +import sttp.client4.GenericRequest final case class Otel4sMetricsConfig( requestDurationHistogramBuckets: BucketBoundaries, requestBodySizeHistogramBuckets: Option[BucketBoundaries], - responseBodySizeHistogramBuckets: Option[BucketBoundaries] + responseBodySizeHistogramBuckets: Option[BucketBoundaries], + urlTemplate: GenericRequest[_, _] => Option[String] = (_) => None ) object Otel4sMetricsConfig { @@ -16,6 +18,7 @@ object Otel4sMetricsConfig { val default: Otel4sMetricsConfig = Otel4sMetricsConfig( requestDurationHistogramBuckets = DefaultDurationBuckets, requestBodySizeHistogramBuckets = None, - responseBodySizeHistogramBuckets = None + responseBodySizeHistogramBuckets = None, + urlTemplate = _ => None ) } diff --git a/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlTemplates.scala b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlTemplates.scala new file mode 100644 index 0000000000..043919a259 --- /dev/null +++ b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlTemplates.scala @@ -0,0 +1,30 @@ +package sttp.client4.opentelemetry.otel4s + +import sttp.client4.GenericRequest +import sttp.model.Uri + +object UrlTemplates { + private val IdPlaceholder = "{id}" + private val IdRegex = """[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|\d+""".r + + /** URL template function that replaces numeric IDs and UUIDs in path segments and query values with `{id}`. Always + * returns `Some` — the template equals the original URL when no IDs are found. + */ + val replaceIds: GenericRequest[_, _] => Option[String] = request => { + val uri = request.uri + val templatedSegments = uri.pathSegments.segments.map(s => if (IdRegex.matches(s.v)) IdPlaceholder else s.v) + + val templatedQueryParts = uri.querySegments.map { + case Uri.QuerySegment.KeyValue(k, v, _, _) => + s"$k=${if (IdRegex.matches(v)) IdPlaceholder else v}" + case Uri.QuerySegment.Value(v, _) => + if (IdRegex.matches(v)) IdPlaceholder else v + case Uri.QuerySegment.Plain(v, _) => + IdRegex.replaceAllIn(v, IdPlaceholder) + } + + val pathPart = "/" + templatedSegments.mkString("/") + val queryPart = if (templatedQueryParts.isEmpty) "" else "?" + templatedQueryParts.mkString("&") + Some(pathPart + queryPart) + } +} 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 95f9f7daeb..a944194a35 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 @@ -10,7 +10,6 @@ import org.typelevel.otel4s.metrics.MeterProvider import org.typelevel.otel4s.sdk.metrics.data.MetricData import org.typelevel.otel4s.sdk.testkit.metrics.MetricsTestkit import org.typelevel.otel4s.semconv.experimental.metrics.HttpExperimentalMetrics -import org.typelevel.otel4s.semconv.metrics.HttpMetrics import org.typelevel.otel4s.semconv.{MetricSpec, Requirement} import sttp.model.{Header, StatusCode} import sttp.client4._ @@ -27,7 +26,7 @@ class Otel4sMetricsBackendTest extends AsyncFreeSpec with Matchers { "Otel4sMetricsBackend" - { "should pass the client semantic test" in { val specs = List( - HttpMetrics.ClientRequestDuration, + HttpExperimentalMetrics.ClientRequestDuration, HttpExperimentalMetrics.ClientRequestBodySize, HttpExperimentalMetrics.ClientResponseBodySize, HttpExperimentalMetrics.ClientActiveRequests diff --git a/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/UrlTemplatesTest.scala b/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/UrlTemplatesTest.scala new file mode 100644 index 0000000000..e910c8fa8c --- /dev/null +++ b/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/UrlTemplatesTest.scala @@ -0,0 +1,48 @@ +package sttp.client4.opentelemetry.otel4s + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.client4._ + +class UrlTemplatesTest extends AnyFlatSpec with Matchers { + + private def replaceIds(uri: String) = UrlTemplates.replaceIds(basicRequest.get(uri"$uri")) + + it should "replace a UUID in the path" in { + replaceIds("http://example.com/orders/550e8400-e29b-41d4-a716-446655440000") shouldBe Some( + "/orders/{id}" + ) + } + + it should "replace multiple IDs in the path" in { + replaceIds("http://example.com/a/123/b/456") shouldBe Some("/a/{id}/b/{id}") + } + + it should "replace a numeric ID in a query value" in { + replaceIds("http://example.com/users?id=123") shouldBe Some("/users?id={id}") + } + + it should "replace a UUID in a query value" in { + replaceIds( + "http://example.com/users?id=550e8400-e29b-41d4-a716-446655440000" + ) shouldBe Some("/users?id={id}") + } + + it should "return the same URL when there are no IDs in the query" in { + replaceIds("http://example.com/users?active=true") shouldBe Some("/users?active=true") + } + + it should "replace IDs in both path and query" in { + replaceIds("http://example.com/users/42?version=7") shouldBe Some( + "/users/{id}?version={id}" + ) + } + + it should "return the same URL when there are no IDs in the path" in { + replaceIds("http://example.com/users") shouldBe Some("/users") + } + + it should "return / when the path is empty" in { + replaceIds("http://example.com") shouldBe Some("/") + } +}