Skip to content

Commit ee3b80d

Browse files
remimompriveRemi Mompriveadamwclaude
authored
Add url.template attribute to otel4s-metrics-backend (#2840)
Implementing the experimental attribute `url.template` for the metric `http.client.request.duration` See https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration Before submitting pull request: - [x] Check if the project compiles by running `sbt compile` - [x] Verify docs compilation by running `sbt compileDocs` - [x] Check if tests pass by running `sbt test` - [x] Format code by running `sbt scalafmt` --------- Co-authored-by: Remi Momprive <rmomprive@excilys.com> Co-authored-by: Adam Warski <adam@warski.org> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d3d2e38 commit ee3b80d

8 files changed

Lines changed: 137 additions & 8 deletions

File tree

.devcontainer/Dockerfile.app

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ ENV PATH="/home/vscode/.local/bin:/home/vscode/.local/share/mise/shims:$PATH"
3131

3232
# Development stacks (managed by sandcat init --stacks):
3333
RUN mise use -g java@lts
34-
RUN mise use -g scala@latest # END STACKS# END STACKS mise use -g sbt@latest
34+
RUN mise use -g scala@latest && mise use -g sbt@latest
3535
# END STACKS
3636

3737
# If Java was installed above, bake JAVA_HOME and JAVA_TOOL_OPTIONS into

build.sbt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,7 @@ lazy val otel4sMetricsBackend = (projectMatrix in file("observability/otel4s-met
967967
libraryDependencies ++= Seq(
968968
"org.typelevel" %%% "otel4s-core-metrics" % otel4s,
969969
"org.typelevel" %%% "otel4s-semconv" % otel4s,
970+
"org.typelevel" %%% "otel4s-semconv-experimental" % otel4s,
970971
"org.typelevel" %%% "otel4s-semconv-metrics-experimental" % otel4s % Test,
971972
"org.typelevel" %%% "otel4s-sdk-metrics-testkit" % otel4sSdk % Test
972973
)

docs/backends/wrappers/opentelemetry.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,49 @@ The following metrics are available by default:
157157
- [http.client.response.body.size](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientresponsebodysize)
158158
- [http.client.active_requests](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientactive_requests)
159159

160-
You can customize histogram buckets by providing a custom `Otel4sMetricsConfig`.
160+
You can customize histogram buckets and URL template behavior by providing a custom `Otel4sMetricsConfig`.
161+
162+
### URL template
163+
164+
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.
165+
166+
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).
167+
168+
```scala mdoc:compile-only
169+
import cats.effect.*
170+
import org.typelevel.otel4s.metrics.MeterProvider
171+
import sttp.client4.*
172+
import sttp.client4.opentelemetry.otel4s.*
173+
174+
implicit val meterProvider: MeterProvider[IO] = ???
175+
val catsBackend: Backend[IO] = ???
176+
177+
// Use the built-in implementation (replaces UUIDs and numeric IDs with {id}):
178+
Otel4sMetricsBackend(
179+
catsBackend,
180+
Otel4sMetricsConfig(
181+
requestDurationHistogramBuckets = Otel4sMetricsConfig.DefaultDurationBuckets,
182+
requestBodySizeHistogramBuckets = None,
183+
responseBodySizeHistogramBuckets = None,
184+
urlTemplate = UrlTemplates.replaceIds
185+
)
186+
)
187+
188+
// Or provide a custom function based on request attributes:
189+
import sttp.attributes.AttributeKey
190+
val UrlTemplateKey = new AttributeKey[String]("UrlTemplateKey")
191+
Otel4sMetricsBackend(
192+
catsBackend,
193+
Otel4sMetricsConfig(
194+
requestDurationHistogramBuckets = Otel4sMetricsConfig.DefaultDurationBuckets,
195+
requestBodySizeHistogramBuckets = None,
196+
responseBodySizeHistogramBuckets = None,
197+
urlTemplate = req => req.attribute(UrlTemplateKey)
198+
)
199+
)
200+
// Then, at the call site:
201+
// basicRequest.get(uri"...").attribute(UrlTemplateKey, "/users/{id}")
202+
```
161203

162204
## Tracing (cats-effect, otel4s)
163205

observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackend.scala

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import org.typelevel.otel4s.semconv.attributes.{
1818
ServerAttributes,
1919
UrlAttributes
2020
}
21+
import org.typelevel.otel4s.semconv.experimental.attributes.UrlExperimentalAttributes
2122
import sttp.client4.listener.{ListenerBackend, RequestListener}
2223
import sttp.client4._
2324
import sttp.model.{HttpVersion, ResponseMetadata, StatusCode}
@@ -103,7 +104,8 @@ object Otel4sMetricsBackend {
103104
requestBodySize,
104105
responseBodySize,
105106
activeRequests,
106-
dispatcher
107+
dispatcher,
108+
config.urlTemplate
107109
)
108110

109111
private final case class State(start: FiniteDuration, activeRequestsAttributes: Attributes)
@@ -113,7 +115,8 @@ object Otel4sMetricsBackend {
113115
requestBodySize: Histogram[F, Long],
114116
responseBodySize: Histogram[F, Long],
115117
activeRequests: UpDownCounter[F, Long],
116-
dispatcher: Dispatcher[F]
118+
dispatcher: Dispatcher[F],
119+
urlTemplate: GenericRequest[_, _] => Option[String]
117120
) extends RequestListener[F, State] {
118121
def before(request: GenericRequest[_, _]): F[State] =
119122
for {
@@ -173,6 +176,7 @@ object Otel4sMetricsBackend {
173176
b ++= ServerAttributes.ServerAddress.maybe(request.uri.host)
174177
b ++= ServerAttributes.ServerPort.maybe(request.uri.port.map(_.toLong))
175178
b ++= UrlAttributes.UrlScheme.maybe(request.uri.scheme)
179+
b ++= UrlExperimentalAttributes.UrlTemplate.maybe(urlTemplate(request))
176180

177181
b.result()
178182
}
@@ -196,6 +200,7 @@ object Otel4sMetricsBackend {
196200
b ++= ServerAttributes.ServerPort.maybe(request.uri.port.map(_.toLong))
197201
b ++= NetworkAttributes.NetworkProtocolVersion.maybe(request.httpVersion.map(networkProtocol))
198202
b ++= UrlAttributes.UrlScheme.maybe(request.uri.scheme)
203+
b ++= UrlExperimentalAttributes.UrlTemplate.maybe(urlTemplate(request))
199204

200205
// response
201206
b ++= HttpAttributes.HttpResponseStatusCode.maybe(responseStatusCode.map(_.code.toLong))
@@ -211,6 +216,7 @@ object Otel4sMetricsBackend {
211216
case HttpVersion.HTTP_2 => "2"
212217
case HttpVersion.HTTP_3 => "3"
213218
}
219+
214220
}
215221

216222
}

observability/otel4s-metrics-backend/src/main/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsConfig.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package sttp.client4.opentelemetry.otel4s
22

33
import org.typelevel.otel4s.metrics.BucketBoundaries
4+
import sttp.client4.GenericRequest
45

56
final case class Otel4sMetricsConfig(
67
requestDurationHistogramBuckets: BucketBoundaries,
78
requestBodySizeHistogramBuckets: Option[BucketBoundaries],
8-
responseBodySizeHistogramBuckets: Option[BucketBoundaries]
9+
responseBodySizeHistogramBuckets: Option[BucketBoundaries],
10+
urlTemplate: GenericRequest[_, _] => Option[String] = (_) => None
911
)
1012

1113
object Otel4sMetricsConfig {
@@ -16,6 +18,7 @@ object Otel4sMetricsConfig {
1618
val default: Otel4sMetricsConfig = Otel4sMetricsConfig(
1719
requestDurationHistogramBuckets = DefaultDurationBuckets,
1820
requestBodySizeHistogramBuckets = None,
19-
responseBodySizeHistogramBuckets = None
21+
responseBodySizeHistogramBuckets = None,
22+
urlTemplate = _ => None
2023
)
2124
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package sttp.client4.opentelemetry.otel4s
2+
3+
import sttp.client4.GenericRequest
4+
import sttp.model.Uri
5+
6+
object UrlTemplates {
7+
private val IdPlaceholder = "{id}"
8+
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
9+
10+
/** URL template function that replaces numeric IDs and UUIDs in path segments and query values with `{id}`. Always
11+
* returns `Some` — the template equals the original URL when no IDs are found.
12+
*/
13+
val replaceIds: GenericRequest[_, _] => Option[String] = request => {
14+
val uri = request.uri
15+
val templatedSegments = uri.pathSegments.segments.map(s => if (IdRegex.matches(s.v)) IdPlaceholder else s.v)
16+
17+
val templatedQueryParts = uri.querySegments.map {
18+
case Uri.QuerySegment.KeyValue(k, v, _, _) =>
19+
s"$k=${if (IdRegex.matches(v)) IdPlaceholder else v}"
20+
case Uri.QuerySegment.Value(v, _) =>
21+
if (IdRegex.matches(v)) IdPlaceholder else v
22+
case Uri.QuerySegment.Plain(v, _) =>
23+
IdRegex.replaceAllIn(v, IdPlaceholder)
24+
}
25+
26+
val pathPart = "/" + templatedSegments.mkString("/")
27+
val queryPart = if (templatedQueryParts.isEmpty) "" else "?" + templatedQueryParts.mkString("&")
28+
Some(pathPart + queryPart)
29+
}
30+
}

observability/otel4s-metrics-backend/src/test/scala/sttp/client4/opentelemetry/otel4s/Otel4sMetricsBackendTest.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import org.typelevel.otel4s.metrics.MeterProvider
1010
import org.typelevel.otel4s.sdk.metrics.data.MetricData
1111
import org.typelevel.otel4s.sdk.testkit.metrics.MetricsTestkit
1212
import org.typelevel.otel4s.semconv.experimental.metrics.HttpExperimentalMetrics
13-
import org.typelevel.otel4s.semconv.metrics.HttpMetrics
1413
import org.typelevel.otel4s.semconv.{MetricSpec, Requirement}
1514
import sttp.model.{Header, StatusCode}
1615
import sttp.client4._
@@ -27,7 +26,7 @@ class Otel4sMetricsBackendTest extends AsyncFreeSpec with Matchers {
2726
"Otel4sMetricsBackend" - {
2827
"should pass the client semantic test" in {
2928
val specs = List(
30-
HttpMetrics.ClientRequestDuration,
29+
HttpExperimentalMetrics.ClientRequestDuration,
3130
HttpExperimentalMetrics.ClientRequestBodySize,
3231
HttpExperimentalMetrics.ClientResponseBodySize,
3332
HttpExperimentalMetrics.ClientActiveRequests
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package sttp.client4.opentelemetry.otel4s
2+
3+
import org.scalatest.flatspec.AnyFlatSpec
4+
import org.scalatest.matchers.should.Matchers
5+
import sttp.client4._
6+
7+
class UrlTemplatesTest extends AnyFlatSpec with Matchers {
8+
9+
private def replaceIds(uri: String) = UrlTemplates.replaceIds(basicRequest.get(uri"$uri"))
10+
11+
it should "replace a UUID in the path" in {
12+
replaceIds("http://example.com/orders/550e8400-e29b-41d4-a716-446655440000") shouldBe Some(
13+
"/orders/{id}"
14+
)
15+
}
16+
17+
it should "replace multiple IDs in the path" in {
18+
replaceIds("http://example.com/a/123/b/456") shouldBe Some("/a/{id}/b/{id}")
19+
}
20+
21+
it should "replace a numeric ID in a query value" in {
22+
replaceIds("http://example.com/users?id=123") shouldBe Some("/users?id={id}")
23+
}
24+
25+
it should "replace a UUID in a query value" in {
26+
replaceIds(
27+
"http://example.com/users?id=550e8400-e29b-41d4-a716-446655440000"
28+
) shouldBe Some("/users?id={id}")
29+
}
30+
31+
it should "return the same URL when there are no IDs in the query" in {
32+
replaceIds("http://example.com/users?active=true") shouldBe Some("/users?active=true")
33+
}
34+
35+
it should "replace IDs in both path and query" in {
36+
replaceIds("http://example.com/users/42?version=7") shouldBe Some(
37+
"/users/{id}?version={id}"
38+
)
39+
}
40+
41+
it should "return the same URL when there are no IDs in the path" in {
42+
replaceIds("http://example.com/users") shouldBe Some("/users")
43+
}
44+
45+
it should "return / when the path is empty" in {
46+
replaceIds("http://example.com") shouldBe Some("/")
47+
}
48+
}

0 commit comments

Comments
 (0)