Skip to content

Commit 81f147d

Browse files
committed
Add otel4s opentelemetry backend
1 parent 748df31 commit 81f147d

File tree

9 files changed

+816
-1
lines changed

9 files changed

+816
-1
lines changed

build.sbt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ val scala3 = "3.3.5"
1414

1515
val scala2 = List(scala2_12, scala2_13)
1616
val scala2And3 = scala2 ++ List(scala3)
17+
val scala2_13And3 = List(scala2_13, scala3)
1718

1819
val examplesScalaVersion = scala3
1920
val documentationScalaVersion = scala3
@@ -166,6 +167,7 @@ val osLibVersion = "0.11.3"
166167
val tethysVersion = "0.29.3"
167168
val openTelemetryVersion = "1.47.0"
168169
val openTelemetrySemconvVersion = "1.26.0-alpha"
170+
val otel4s = "0.12.0-RC2"
169171
val slf4jVersion = "1.7.36"
170172

171173
val compileAndTest = "compile->compile;test->test"
@@ -231,6 +233,8 @@ lazy val rawAllAggregates =
231233
prometheusBackend.projectRefs ++
232234
openTelemetryBackend.projectRefs ++
233235
openTelemetryTracingZioBackend.projectRefs ++
236+
otel4sMetricsBackend.projectRefs ++
237+
otel4sTracingBackend.projectRefs ++
234238
finagleBackend.projectRefs ++
235239
armeriaBackend.projectRefs ++
236240
armeriaScalazBackend.projectRefs ++
@@ -928,6 +932,36 @@ lazy val openTelemetryTracingZioBackend = (projectMatrix in file("observability/
928932
.dependsOn(zio % compileAndTest)
929933
.dependsOn(core)
930934

935+
lazy val otel4sMetricsBackend = (projectMatrix in file("observability/otel4s-metrics-backend"))
936+
.settings(
937+
name := "opentelemetry-otel4s-metrics-backend",
938+
libraryDependencies ++= Seq(
939+
"org.typelevel" %%% "otel4s-core-metrics" % otel4s,
940+
"org.typelevel" %%% "otel4s-semconv" % otel4s,
941+
"org.typelevel" %%% "otel4s-semconv-metrics-experimental" % otel4s % Test,
942+
"org.typelevel" %%% "otel4s-sdk-metrics-testkit" % otel4s % Test
943+
)
944+
)
945+
.jvmPlatform(scalaVersions = scala2_13And3, settings = commonJvmSettings)
946+
.jsPlatform(scalaVersions = scala2_13And3, settings = commonJsSettings)
947+
.dependsOn(cats % Test)
948+
.dependsOn(core % compileAndTest)
949+
950+
lazy val otel4sTracingBackend = (projectMatrix in file("observability/otel4s-tracing-backend"))
951+
.settings(
952+
name := "opentelemetry-otel4s-tracing-backend",
953+
libraryDependencies ++= Seq(
954+
"org.typelevel" %%% "otel4s-core-trace" % otel4s,
955+
"org.typelevel" %%% "otel4s-semconv" % otel4s,
956+
"org.typelevel" %%% "otel4s-sdk-trace-testkit" % otel4s % Test,
957+
"org.typelevel" %%% "cats-effect-testkit" % catsEffect_3_version % Test
958+
)
959+
)
960+
.jvmPlatform(scalaVersions = scala2_13And3, settings = commonJvmSettings)
961+
.jsPlatform(scalaVersions = scala2_13And3, settings = commonJsSettings)
962+
.dependsOn(cats % Test)
963+
.dependsOn(core % compileAndTest)
964+
931965
lazy val scribeBackend = (projectMatrix in file("logging/scribe"))
932966
.settings(commonJvmSettings)
933967
.settings(
@@ -1069,6 +1103,8 @@ lazy val docs: ProjectMatrix = (projectMatrix in file("generated-docs")) // impo
10691103
prometheusBackend,
10701104
openTelemetryBackend,
10711105
openTelemetryTracingZioBackend,
1106+
otel4sMetricsBackend,
1107+
otel4sTracingBackend,
10721108
slf4jBackend
10731109
)
10741110
.jvmPlatform(scalaVersions = List(documentationScalaVersion))

docs/backends/wrappers/opentelemetry.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,66 @@ 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+
.use { backend => ??? }
151+
```
152+
153+
The backend follows the OpenTelemetry [specification](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/)
154+
of HTTP metrics.
155+
The following metrics are available by default:
156+
- [http.client.request.duration](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration)
157+
- [http.client.request.body.size](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestbodysize)
158+
- [http.client.response.body.size](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientresponsebodysize)
159+
- [http.client.active_requests](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientactive_requests)
160+
161+
You can customize histogram buckets by providing a custom `Otel4sMetricsConfig`.
162+
163+
## Tracing (cats-effect, otel4s)
164+
165+
Add the following dependency to your project:
166+
```scala
167+
"com.softwaremill.sttp.client4" %% "opentelemetry-otel4s-tracing-backend" % "@VERSION@"
168+
```
169+
170+
This backend depends on [otel4s](https://github.com/typelevel/otel4s).
171+
172+
Use `Otel4sTracingBackend` to enable tracing of a client:
173+
```scala mdoc:compile-only
174+
import cats.effect.*
175+
import org.typelevel.otel4s.trace.TracerProvider
176+
import sttp.client4.*
177+
import sttp.client4.opentelemetry.otel4s.*
178+
179+
implicit val tracerProvider: TracerProvider[IO] = ???
180+
val catsBackend: Backend[IO] = ???
181+
182+
Otel4sTracingBackend(catsBackend, Otel4sTracingConfig.default)
183+
```
184+
185+
The backend follows the OpenTelemetry [specification](https://opentelemetry.io/docs/specs/semconv/http/http-spans/)
186+
of HTTP spans.
187+
188+
You can customize span name and attached attributes by providing a custom `Otel4sTracingConfig`.
189+
190+
## Tracing (cats-effect, trace4cats)
131191

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