From 418f284a89a70f5e1dc9253b4b2b95e0a8d4c155 Mon Sep 17 00:00:00 2001 From: Remi Momprive Date: Wed, 25 Mar 2026 22:01:46 +0100 Subject: [PATCH 1/4] Add url.template attribute to otel4s-metrics-backend --- build.sbt | 1 + docs/backends/wrappers/opentelemetry.md | 40 +++++++++++++++- .../otel4s/Otel4sMetricsBackend.scala | 12 +++-- .../otel4s/Otel4sMetricsConfig.scala | 7 ++- .../opentelemetry/otel4s/UrlTemplates.scala | 28 +++++++++++ .../otel4s/Otel4sMetricsBackendTest.scala | 3 +- .../otel4s/UrlTemplatesTest.scala | 46 +++++++++++++++++++ 7 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlTemplates.scala create mode 100644 observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/UrlTemplatesTest.scala diff --git a/build.sbt b/build.sbt index fb970e3f68..5193c174f8 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..6e1576f606 100644 --- a/docs/backends/wrappers/opentelemetry.md +++ b/docs/backends/wrappers/opentelemetry.md @@ -157,7 +157,45 @@ 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 `Uri => Option[String]` function via the `urlTemplate` config field. + +A built-in implementation is available in `UrlTemplates.urlTemplate`: 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.urlTemplate + ) +) + +// Or provide a custom function: +Otel4sMetricsBackend( + catsBackend, + Otel4sMetricsConfig( + requestDurationHistogramBuckets = Otel4sMetricsConfig.DefaultDurationBuckets, + requestBodySizeHistogramBuckets = None, + responseBodySizeHistogramBuckets = None, + urlTemplate = (uri => Some(uri.pathSegments.segments.map(_ => "{?}").mkString("/"))) + ) +) +``` ## 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..6e6f27dd1a 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,9 +18,10 @@ 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} +import sttp.model.{HttpVersion, ResponseMetadata, StatusCode, Uri} import sttp.client4.wrappers.FollowRedirectsBackend import scala.concurrent.duration.FiniteDuration @@ -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: Uri => 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.uri)) 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.uri)) // 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..8c7f8bc050 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.model.Uri final case class Otel4sMetricsConfig( requestDurationHistogramBuckets: BucketBoundaries, requestBodySizeHistogramBuckets: Option[BucketBoundaries], - responseBodySizeHistogramBuckets: Option[BucketBoundaries] + responseBodySizeHistogramBuckets: Option[BucketBoundaries], + urlTemplate: Uri => 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..9dff887791 --- /dev/null +++ b/observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/UrlTemplates.scala @@ -0,0 +1,28 @@ +package sttp.client4.opentelemetry.otel4s + +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 urlTemplate: Uri => Option[String] = 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..96de3a8702 --- /dev/null +++ b/observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/UrlTemplatesTest.scala @@ -0,0 +1,46 @@ +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 { + + it should "replace a UUID in the path" in { + UrlTemplates.urlTemplate(uri"http://example.com/orders/550e8400-e29b-41d4-a716-446655440000") shouldBe Some( + "/orders/{id}" + ) + } + + it should "replace multiple IDs in the path" in { + UrlTemplates.urlTemplate(uri"http://example.com/a/123/b/456") shouldBe Some("/a/{id}/b/{id}") + } + + it should "replace a numeric ID in a query value" in { + UrlTemplates.urlTemplate(uri"http://example.com/users?id=123") shouldBe Some("/users?id={id}") + } + + it should "replace a UUID in a query value" in { + UrlTemplates.urlTemplate( + uri"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 { + UrlTemplates.urlTemplate(uri"http://example.com/users?active=true") shouldBe Some("/users?active=true") + } + + it should "replace IDs in both path and query" in { + UrlTemplates.urlTemplate(uri"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 { + UrlTemplates.urlTemplate(uri"http://example.com/users") shouldBe Some("/users") + } + + it should "return / when the path is empty" in { + UrlTemplates.urlTemplate(uri"http://example.com") shouldBe Some("/") + } +} From 16ad986d90e0acff189cf88967381772e5cc3990 Mon Sep 17 00:00:00 2001 From: adamw Date: Fri, 27 Mar 2026 12:54:56 +0100 Subject: [PATCH 2/4] Fix the sandcat dockerfile --- .devcontainer/Dockerfile.app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From acf35b6bf08e7ecc9a816affda7761a1e668556d Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 27 Mar 2026 11:56:27 +0000 Subject: [PATCH 3/4] Accept full request in urlTemplate, rename UrlTemplates.urlTemplate to replaceIds Change the urlTemplate config from `Uri => Option[String]` to `GenericRequest[_, _] => Option[String]` so that request attributes can be used to pass the URL template from the call site. Rename the built-in heuristic from `UrlTemplates.urlTemplate` to `UrlTemplates.replaceIds` for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/backends/wrappers/opentelemetry.md | 14 ++++++++----- .../otel4s/Otel4sMetricsBackend.scala | 8 ++++---- .../otel4s/Otel4sMetricsConfig.scala | 6 +++--- .../opentelemetry/otel4s/UrlTemplates.scala | 4 +++- .../otel4s/UrlTemplatesTest.scala | 20 ++++++++++--------- 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/docs/backends/wrappers/opentelemetry.md b/docs/backends/wrappers/opentelemetry.md index 6e1576f606..518891a852 100644 --- a/docs/backends/wrappers/opentelemetry.md +++ b/docs/backends/wrappers/opentelemetry.md @@ -161,9 +161,9 @@ You can customize histogram buckets and URL template behavior by providing a cus ### 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 `Uri => Option[String]` function via the `urlTemplate` config field. +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.urlTemplate`: 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). +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.* @@ -181,20 +181,24 @@ Otel4sMetricsBackend( requestDurationHistogramBuckets = Otel4sMetricsConfig.DefaultDurationBuckets, requestBodySizeHistogramBuckets = None, responseBodySizeHistogramBuckets = None, - urlTemplate = UrlTemplates.urlTemplate + urlTemplate = UrlTemplates.replaceIds ) ) -// Or provide a custom function: +// Or provide a custom function based on request attributes: +import sttp.attributes.AttributeKey +val UrlTemplateKey = AttributeKey[String] Otel4sMetricsBackend( catsBackend, Otel4sMetricsConfig( requestDurationHistogramBuckets = Otel4sMetricsConfig.DefaultDurationBuckets, requestBodySizeHistogramBuckets = None, responseBodySizeHistogramBuckets = None, - urlTemplate = (uri => Some(uri.pathSegments.segments.map(_ => "{?}").mkString("/"))) + 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 6e6f27dd1a..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 @@ -21,7 +21,7 @@ import org.typelevel.otel4s.semconv.attributes.{ import org.typelevel.otel4s.semconv.experimental.attributes.UrlExperimentalAttributes import sttp.client4.listener.{ListenerBackend, RequestListener} import sttp.client4._ -import sttp.model.{HttpVersion, ResponseMetadata, StatusCode, Uri} +import sttp.model.{HttpVersion, ResponseMetadata, StatusCode} import sttp.client4.wrappers.FollowRedirectsBackend import scala.concurrent.duration.FiniteDuration @@ -116,7 +116,7 @@ object Otel4sMetricsBackend { responseBodySize: Histogram[F, Long], activeRequests: UpDownCounter[F, Long], dispatcher: Dispatcher[F], - urlTemplate: Uri => Option[String] + urlTemplate: GenericRequest[_, _] => Option[String] ) extends RequestListener[F, State] { def before(request: GenericRequest[_, _]): F[State] = for { @@ -176,7 +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.uri)) + b ++= UrlExperimentalAttributes.UrlTemplate.maybe(urlTemplate(request)) b.result() } @@ -200,7 +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.uri)) + b ++= UrlExperimentalAttributes.UrlTemplate.maybe(urlTemplate(request)) // response b ++= HttpAttributes.HttpResponseStatusCode.maybe(responseStatusCode.map(_.code.toLong)) 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 8c7f8bc050..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,13 +1,13 @@ package sttp.client4.opentelemetry.otel4s import org.typelevel.otel4s.metrics.BucketBoundaries -import sttp.model.Uri +import sttp.client4.GenericRequest final case class Otel4sMetricsConfig( requestDurationHistogramBuckets: BucketBoundaries, requestBodySizeHistogramBuckets: Option[BucketBoundaries], responseBodySizeHistogramBuckets: Option[BucketBoundaries], - urlTemplate: Uri => Option[String] = (_) => None + urlTemplate: GenericRequest[_, _] => Option[String] = (_) => None ) object Otel4sMetricsConfig { @@ -19,6 +19,6 @@ object Otel4sMetricsConfig { requestDurationHistogramBuckets = DefaultDurationBuckets, requestBodySizeHistogramBuckets = None, responseBodySizeHistogramBuckets = None, - urlTemplate = (_) => 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 index 9dff887791..043919a259 100644 --- 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 @@ -1,5 +1,6 @@ package sttp.client4.opentelemetry.otel4s +import sttp.client4.GenericRequest import sttp.model.Uri object UrlTemplates { @@ -9,7 +10,8 @@ object UrlTemplates { /** 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 urlTemplate: Uri => Option[String] = uri => { + 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 { 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 index 96de3a8702..e910c8fa8c 100644 --- 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 @@ -6,41 +6,43 @@ 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 { - UrlTemplates.urlTemplate(uri"http://example.com/orders/550e8400-e29b-41d4-a716-446655440000") shouldBe Some( + replaceIds("http://example.com/orders/550e8400-e29b-41d4-a716-446655440000") shouldBe Some( "/orders/{id}" ) } it should "replace multiple IDs in the path" in { - UrlTemplates.urlTemplate(uri"http://example.com/a/123/b/456") shouldBe Some("/a/{id}/b/{id}") + 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 { - UrlTemplates.urlTemplate(uri"http://example.com/users?id=123") shouldBe Some("/users?id={id}") + replaceIds("http://example.com/users?id=123") shouldBe Some("/users?id={id}") } it should "replace a UUID in a query value" in { - UrlTemplates.urlTemplate( - uri"http://example.com/users?id=550e8400-e29b-41d4-a716-446655440000" + 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 { - UrlTemplates.urlTemplate(uri"http://example.com/users?active=true") shouldBe Some("/users?active=true") + replaceIds("http://example.com/users?active=true") shouldBe Some("/users?active=true") } it should "replace IDs in both path and query" in { - UrlTemplates.urlTemplate(uri"http://example.com/users/42?version=7") shouldBe Some( + 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 { - UrlTemplates.urlTemplate(uri"http://example.com/users") shouldBe Some("/users") + replaceIds("http://example.com/users") shouldBe Some("/users") } it should "return / when the path is empty" in { - UrlTemplates.urlTemplate(uri"http://example.com") shouldBe Some("/") + replaceIds("http://example.com") shouldBe Some("/") } } From d9472eef89e5e38e52a4e08a52e6f40e0b0437bc Mon Sep 17 00:00:00 2001 From: adamw Date: Fri, 27 Mar 2026 14:03:29 +0100 Subject: [PATCH 4/4] Fix example --- docs/backends/wrappers/opentelemetry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backends/wrappers/opentelemetry.md b/docs/backends/wrappers/opentelemetry.md index 518891a852..4628789e37 100644 --- a/docs/backends/wrappers/opentelemetry.md +++ b/docs/backends/wrappers/opentelemetry.md @@ -187,7 +187,7 @@ Otel4sMetricsBackend( // Or provide a custom function based on request attributes: import sttp.attributes.AttributeKey -val UrlTemplateKey = AttributeKey[String] +val UrlTemplateKey = new AttributeKey[String]("UrlTemplateKey") Otel4sMetricsBackend( catsBackend, Otel4sMetricsConfig(