Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile.app
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
44 changes: 43 additions & 1 deletion docs/backends/wrappers/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -103,7 +104,8 @@ object Otel4sMetricsBackend {
requestBodySize,
responseBodySize,
activeRequests,
dispatcher
dispatcher,
config.urlTemplate
)

private final case class State(start: FiniteDuration, activeRequestsAttributes: Attributes)
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
}
Expand All @@ -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))
Expand All @@ -211,6 +216,7 @@ object Otel4sMetricsBackend {
case HttpVersion.HTTP_2 => "2"
case HttpVersion.HTTP_3 => "3"
}

}

}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,6 +18,7 @@ object Otel4sMetricsConfig {
val default: Otel4sMetricsConfig = Otel4sMetricsConfig(
requestDurationHistogramBuckets = DefaultDurationBuckets,
requestBodySizeHistogramBuckets = None,
responseBodySizeHistogramBuckets = None
responseBodySizeHistogramBuckets = None,
urlTemplate = _ => None
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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("/")
}
}
Loading