@@ -18,15 +18,18 @@ import org.typelevel.otel4s.semconv.attributes.{
1818 ServerAttributes ,
1919 UrlAttributes
2020}
21+ import org .typelevel .otel4s .semconv .experimental .attributes .UrlExperimentalAttributes
2122import sttp .client4 .listener .{ListenerBackend , RequestListener }
2223import sttp .client4 ._
23- import sttp .model .{HttpVersion , ResponseMetadata , StatusCode }
24+ import sttp .model .{HttpVersion , ResponseMetadata , StatusCode , Uri }
2425import sttp .client4 .wrappers .FollowRedirectsBackend
2526
2627import scala .concurrent .duration .FiniteDuration
2728import scala .util .chaining ._
2829
2930object Otel4sMetricsBackend {
31+ private val IdPlaceholder = " {id}"
32+ 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
3033
3134 def apply [F [_]: Async : MeterProvider ](
3235 delegate : Backend [F ],
@@ -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,33 @@ object Otel4sMetricsBackend {
211216 case HttpVersion .HTTP_2 => " 2"
212217 case HttpVersion .HTTP_3 => " 3"
213218 }
219+
220+ private def urlTemplate (request : GenericRequest [_, _]): Option [String ] = {
221+ val rawSegments = request.uri.pathSegments.segments
222+ val templatedSegments = rawSegments.map(s => if (IdRegex .matches(s.v)) IdPlaceholder else s.v)
223+ val pathChanged = rawSegments.zip(templatedSegments).exists { case (s, t) => s.v != t }
224+
225+ val rawQueryParts = request.uri.querySegments.map {
226+ case Uri .QuerySegment .KeyValue (k, v, _, _) => s " $k= $v"
227+ case Uri .QuerySegment .Value (v, _) => v
228+ case Uri .QuerySegment .Plain (v, _) => v
229+ }
230+ val templatedQueryParts = request.uri.querySegments.map {
231+ case Uri .QuerySegment .KeyValue (k, v, _, _) =>
232+ s " $k= ${if (IdRegex .matches(v)) IdPlaceholder else v}"
233+ case Uri .QuerySegment .Value (v, _) =>
234+ if (IdRegex .matches(v)) IdPlaceholder else v
235+ case Uri .QuerySegment .Plain (v, _) =>
236+ IdRegex .replaceAllIn(v, IdPlaceholder )
237+ }
238+ val queryChanged = rawQueryParts != templatedQueryParts
239+
240+ if (pathChanged || queryChanged) {
241+ val pathPart = " /" + templatedSegments.mkString(" /" )
242+ val queryPart = if (templatedQueryParts.isEmpty) " " else " ?" + templatedQueryParts.mkString(" &" )
243+ Some (pathPart + queryPart)
244+ } else None
245+ }
214246 }
215247
216248}
0 commit comments