Skip to content

Commit 0807f78

Browse files
committed
Add otel4s opentelemetry backend
1 parent 57711cc commit 0807f78

File tree

9 files changed

+784
-1
lines changed

9 files changed

+784
-1
lines changed

build.sbt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ val osLibVersion = "0.11.3"
166166
val tethysVersion = "0.29.3"
167167
val openTelemetryVersion = "1.46.0"
168168
val openTelemetrySemconvVersion = "1.26.0-alpha"
169+
val otel4s = "0.12.0-RC2"
169170
val slf4jVersion = "1.7.36"
170171

171172
val compileAndTest = "compile->compile;test->test"
@@ -231,6 +232,8 @@ lazy val rawAllAggregates =
231232
prometheusBackend.projectRefs ++
232233
openTelemetryBackend.projectRefs ++
233234
openTelemetryTracingZioBackend.projectRefs ++
235+
otel4sMetricsBackend.projectRefs ++
236+
otel4sTracingBackend.projectRefs ++
234237
finagleBackend.projectRefs ++
235238
armeriaBackend.projectRefs ++
236239
armeriaScalazBackend.projectRefs ++
@@ -928,6 +931,36 @@ lazy val openTelemetryTracingZioBackend = (projectMatrix in file("observability/
928931
.dependsOn(zio % compileAndTest)
929932
.dependsOn(core)
930933

934+
lazy val otel4sMetricsBackend = (projectMatrix in file("observability/otel4s-metrics-backend"))
935+
.settings(
936+
name := "opentelemetry-otel4s-metrics-backend",
937+
libraryDependencies ++= Seq(
938+
"org.typelevel" %%% "otel4s-core-metrics" % otel4s,
939+
"org.typelevel" %%% "otel4s-semconv" % otel4s,
940+
"org.typelevel" %%% "otel4s-semconv-metrics-experimental" % otel4s % Test,
941+
"org.typelevel" %%% "otel4s-sdk-metrics-testkit" % otel4s % Test
942+
)
943+
)
944+
.jvmPlatform(scalaVersions = scala2And3, settings = commonJvmSettings)
945+
.jsPlatform(scalaVersions = scala2And3, settings = commonJsSettings)
946+
.dependsOn(cats % Test)
947+
.dependsOn(core % compileAndTest)
948+
949+
lazy val otel4sTracingBackend = (projectMatrix in file("observability/otel4s-tracing-backend"))
950+
.settings(
951+
name := "opentelemetry-otel4s-tracing-backend",
952+
libraryDependencies ++= Seq(
953+
"org.typelevel" %%% "otel4s-core-trace" % otel4s,
954+
"org.typelevel" %%% "otel4s-semconv" % otel4s,
955+
"org.typelevel" %%% "otel4s-sdk-trace-testkit" % otel4s % Test,
956+
"org.typelevel" %%% "cats-effect-testkit" % catsEffect_3_version % Test
957+
)
958+
)
959+
.jvmPlatform(scalaVersions = scala2And3, settings = commonJvmSettings)
960+
.jsPlatform(scalaVersions = scala2And3, settings = commonJsSettings)
961+
.dependsOn(cats % Test)
962+
.dependsOn(core % compileAndTest)
963+
931964
lazy val scribeBackend = (projectMatrix in file("logging/scribe"))
932965
.settings(commonJvmSettings)
933966
.settings(
@@ -1069,6 +1102,8 @@ lazy val docs: ProjectMatrix = (projectMatrix in file("generated-docs")) // impo
10691102
prometheusBackend,
10701103
openTelemetryBackend,
10711104
openTelemetryTracingZioBackend,
1105+
otel4sMetricsBackend,
1106+
otel4sTracingBackend,
10721107
slf4jBackend
10731108
)
10741109
.jvmPlatform(scalaVersions = List(documentationScalaVersion))

docs/backends/wrappers/opentelemetry.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,65 @@ OpenTelemetryTracingZioBackend(zioBackend, tracing)
127127
By default, the span is named after the HTTP method (e.g `POST`) as [recommended by OpenTelemetry](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-client) for HTTP clients, and the http method, url and response status codes are set as span attributes.
128128
You can override these defaults by supplying a custom `OpenTelemetryZioTracer`.
129129

130-
## Tracing (cats-effect)
130+
## Metrics (cats-effect, otel4s)
131+
132+
Add the following dependency to your project:
133+
```scala
134+
"com.softwaremill.sttp.client4" %% "opentelemetry-otel4s-metrics-backend" % "@VERSION@"
135+
```
136+
137+
This backend depends on [otel4s](https://github.com/typelevel/otel4s).
138+
139+
Use `Otel4sMetricsBackend` to enable tracing of a client:
140+
```scala mdoc:compile-only
141+
import cats.effect.*
142+
import org.typelevel.otel4s.metrics.MeterProvider
143+
import sttp.client4.*
144+
import sttp.client4.opentelemetry.otel4s.*
145+
146+
implicit val meterProvider: MeterProvider[IO] = ???
147+
val catsBackend: Backend[IO] = ???
148+
149+
Otel4sMetricsBackend(catsBackend, Otel4sMetricsConfig.default)
150+
```
151+
152+
The backend follows the OpenTelemetry [specification](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/)
153+
of HTTP metrics.
154+
The following metrics are available by default:
155+
- [http.client.request.duration](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration)
156+
- [http.client.request.body.size](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestbodysize)
157+
- [http.client.response.body.size](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientresponsebodysize)
158+
- [http.client.active_requests](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientactive_requests)
159+
160+
You can customize histogram buckets by providing a custom `Otel4sMetricsConfig`.
161+
162+
## Tracing (cats-effect, otel4s)
163+
164+
Add the following dependency to your project:
165+
```scala
166+
"com.softwaremill.sttp.client4" %% "opentelemetry-otel4s-tracing-backend" % "@VERSION@"
167+
```
168+
169+
This backend depends on [otel4s](https://github.com/typelevel/otel4s).
170+
171+
Use `Otel4sTracingBackend` to enable tracing of a client:
172+
```scala mdoc:compile-only
173+
import cats.effect.*
174+
import org.typelevel.otel4s.trace.TracerProvider
175+
import sttp.client4.*
176+
import sttp.client4.opentelemetry.otel4s.*
177+
178+
implicit val tracerProvider: TracerProvider[IO] = ???
179+
val catsBackend: Backend[IO] = ???
180+
181+
Otel4sTracingBackend(catsBackend, Otel4sTracingConfig.default)
182+
```
183+
184+
The backend follows the OpenTelemetry [specification](https://opentelemetry.io/docs/specs/semconv/http/http-spans/)
185+
of HTTP spans.
186+
187+
You can customize span name and attached attributes by providing a custom `Otel4sTracingConfig`.
188+
189+
## Tracing (cats-effect, trace4cats)
131190

132191
The [trace4cats](https://github.com/trace4cats/trace4cats) project includes sttp-client integration.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package sttp.client4.opentelemetry.otel4s
2+
3+
import java.util.concurrent.TimeUnit
4+
5+
import cats.Monad
6+
import cats.effect.Clock
7+
import cats.syntax.flatMap._
8+
import cats.syntax.functor._
9+
import cats.syntax.foldable._
10+
import org.typelevel.otel4s.Attributes
11+
import org.typelevel.otel4s.metrics.{BucketBoundaries, Histogram, MeterProvider, UpDownCounter}
12+
import org.typelevel.otel4s.semconv.attributes.{
13+
ErrorAttributes,
14+
HttpAttributes,
15+
NetworkAttributes,
16+
ServerAttributes,
17+
UrlAttributes
18+
}
19+
import sttp.client4.listener.{ListenerBackend, RequestListener}
20+
import sttp.client4._
21+
import sttp.model.{HttpVersion, StatusCode}
22+
import sttp.client4.wrappers.FollowRedirectsBackend
23+
24+
import scala.concurrent.duration.FiniteDuration
25+
import scala.util.chaining._
26+
27+
object Otel4sMetricsBackend {
28+
29+
def apply[F[_]: Monad: Clock: MeterProvider](
30+
delegate: Backend[F],
31+
config: Otel4sMetricsConfig
32+
): F[Backend[F]] =
33+
for {
34+
listener <- metricsListener(config)
35+
} yield FollowRedirectsBackend(ListenerBackend(delegate, listener))
36+
37+
def apply[F[_]: Monad: Clock: MeterProvider](
38+
delegate: WebSocketBackend[F],
39+
config: Otel4sMetricsConfig
40+
): F[WebSocketBackend[F]] =
41+
for {
42+
listener <- metricsListener(config)
43+
} yield FollowRedirectsBackend(ListenerBackend(delegate, listener))
44+
45+
def apply[F[_]: Monad: Clock: MeterProvider, S](
46+
delegate: StreamBackend[F, S],
47+
config: Otel4sMetricsConfig
48+
): F[StreamBackend[F, S]] =
49+
for {
50+
listener <- metricsListener(config)
51+
} yield FollowRedirectsBackend(ListenerBackend(delegate, listener))
52+
53+
def apply[F[_]: Monad: Clock: MeterProvider, S](
54+
delegate: WebSocketStreamBackend[F, S],
55+
config: Otel4sMetricsConfig
56+
): F[WebSocketStreamBackend[F, S]] =
57+
for {
58+
listener <- metricsListener(config)
59+
} yield FollowRedirectsBackend(ListenerBackend(delegate, listener))
60+
61+
private def metricsListener[F[_]: Monad: Clock: MeterProvider, P](
62+
config: Otel4sMetricsConfig
63+
): F[MetricsRequestListener[F]] =
64+
for {
65+
meter <- MeterProvider[F].meter("sttp-client4").withVersion("1.0.0").get
66+
67+
requestDuration <- meter
68+
.histogram[Double]("http.client.request.duration")
69+
.withExplicitBucketBoundaries(config.requestDurationHistogramBuckets)
70+
.withDescription("Duration of HTTP client requests.")
71+
.withUnit("s")
72+
.create
73+
74+
requestBodySize <- meter
75+
.histogram[Long]("http.client.request.body.size")
76+
.pipe(b => config.requestBodySizeHistogramBuckets.fold(b)(b.withExplicitBucketBoundaries))
77+
.withDescription("Size of HTTP client request bodies.")
78+
.withUnit("By")
79+
.create
80+
81+
responseBodySize <- meter
82+
.histogram[Long]("http.client.response.body.size")
83+
.pipe(b => config.responseBodySizeHistogramBuckets.fold(b)(b.withExplicitBucketBoundaries))
84+
.withDescription("Size of HTTP client response bodies.")
85+
.withUnit("By")
86+
.create
87+
88+
activeRequests <- meter
89+
.upDownCounter[Long]("http.client.active_requests")
90+
.withDescription("Number of active HTTP requests.")
91+
.withUnit("{request}")
92+
.create
93+
} yield new MetricsRequestListener[F](
94+
requestDuration,
95+
requestBodySize,
96+
responseBodySize,
97+
activeRequests
98+
)
99+
100+
private final case class State(start: FiniteDuration, activeRequestsAttributes: Attributes)
101+
102+
private final class MetricsRequestListener[F[_]: Monad: Clock](
103+
requestDuration: Histogram[F, Double],
104+
requestBodySize: Histogram[F, Long],
105+
responseBodySize: Histogram[F, Long],
106+
activeRequests: UpDownCounter[F, Long]
107+
) extends RequestListener[F, State] {
108+
def beforeRequest(request: GenericRequest[_, _]): F[State] =
109+
for {
110+
start <- Clock[F].realTime
111+
attributes <- Monad[F].pure(activeRequestAttributes(request))
112+
_ <- activeRequests.inc(attributes)
113+
} yield State(start, attributes)
114+
115+
def requestException(request: GenericRequest[_, _], state: State, e: Exception): F[Unit] =
116+
ResponseException.find(e) match {
117+
case Some(re) =>
118+
requestSuccessful(request, Response((), re.response.code, request.onlyMetadata), state)
119+
120+
case _ =>
121+
for {
122+
now <- Clock[F].realTime
123+
attributes <- Monad[F].pure(fullAttributes(request, None, Some(e.getClass.getName)))
124+
_ <- requestDuration.record((now - state.start).toUnit(TimeUnit.SECONDS), attributes)
125+
_ <- request.contentLength.traverse_(size => requestBodySize.record(size, attributes))
126+
_ <- activeRequests.dec(state.activeRequestsAttributes)
127+
} yield ()
128+
}
129+
130+
def requestSuccessful(request: GenericRequest[_, _], response: Response[_], state: State): F[Unit] =
131+
for {
132+
now <- Clock[F].realTime
133+
attributes <- Monad[F].pure(fullAttributes(request, response))
134+
_ <- requestDuration.record((now - state.start).toUnit(TimeUnit.SECONDS), attributes)
135+
_ <- request.contentLength.traverse_(length => requestBodySize.record(length, attributes))
136+
_ <- response.contentLength.traverse_(length => responseBodySize.record(length, attributes))
137+
_ <- activeRequests.dec(state.activeRequestsAttributes)
138+
} yield ()
139+
140+
private def activeRequestAttributes(request: GenericRequest[_, _]): Attributes = {
141+
val b = Attributes.newBuilder
142+
143+
b += HttpAttributes.HttpRequestMethod(request.method.method)
144+
b ++= ServerAttributes.ServerAddress.maybe(request.uri.host)
145+
b ++= ServerAttributes.ServerPort.maybe(request.uri.port.map(_.toLong))
146+
b ++= UrlAttributes.UrlScheme.maybe(request.uri.scheme)
147+
148+
b.result()
149+
}
150+
151+
private def fullAttributes(request: GenericRequest[_, _], response: Response[_]): Attributes =
152+
fullAttributes(
153+
request,
154+
Some(response.code),
155+
Option.unless(response.isSuccess)(response.code.toString())
156+
)
157+
158+
private def fullAttributes(
159+
request: GenericRequest[_, _],
160+
responseStatusCode: Option[StatusCode],
161+
errorType: Option[String]
162+
): Attributes = {
163+
val b = Attributes.newBuilder
164+
165+
b += HttpAttributes.HttpRequestMethod(request.method.method)
166+
b ++= ServerAttributes.ServerAddress.maybe(request.uri.host)
167+
b ++= ServerAttributes.ServerPort.maybe(request.uri.port.map(_.toLong))
168+
b ++= NetworkAttributes.NetworkProtocolVersion.maybe(request.httpVersion.map(networkProtocol))
169+
b ++= UrlAttributes.UrlScheme.maybe(request.uri.scheme)
170+
171+
// response
172+
b ++= HttpAttributes.HttpResponseStatusCode.maybe(responseStatusCode.map(_.code.toLong))
173+
b ++= ErrorAttributes.ErrorType.maybe(errorType)
174+
175+
b.result()
176+
}
177+
178+
private def networkProtocol(httpVersion: HttpVersion): String =
179+
httpVersion match {
180+
case HttpVersion.HTTP_1 => "1.0"
181+
case HttpVersion.HTTP_1_1 => "1.1"
182+
case HttpVersion.HTTP_2 => "2"
183+
case HttpVersion.HTTP_3 => "3"
184+
}
185+
}
186+
187+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package sttp.client4.opentelemetry.otel4s
2+
3+
import org.typelevel.otel4s.metrics.BucketBoundaries
4+
5+
final case class Otel4sMetricsConfig(
6+
requestDurationHistogramBuckets: BucketBoundaries,
7+
requestBodySizeHistogramBuckets: Option[BucketBoundaries],
8+
responseBodySizeHistogramBuckets: Option[BucketBoundaries]
9+
)
10+
11+
object Otel4sMetricsConfig {
12+
val DefaultDurationBuckets: BucketBoundaries = BucketBoundaries(
13+
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10
14+
)
15+
16+
val default: Otel4sMetricsConfig = Otel4sMetricsConfig(
17+
requestDurationHistogramBuckets = DefaultDurationBuckets,
18+
requestBodySizeHistogramBuckets = None,
19+
responseBodySizeHistogramBuckets = None
20+
)
21+
}

0 commit comments

Comments
 (0)