Skip to content

Commit 79037fa

Browse files
jctimadamw
andauthored
Adds support for otel4s metrics (#4788)
# Adding otel4s OpenTelemetry metrics integration for Tapir This PR adds support for OpenTelemetry **metrics** integration with Tapir using the [otel4s](https://typelevel.org/otel4s/) library. It adds a new module `tapir-otel4s-metrics` which is inspired by the existing `tapir-opentelemetry-metrics` module and highly inspired by the `tapir-otel4s-tracing` module, especially the PR #4428 by @yannick-cw. ## Implementation Details * `Otel4sMetrics` - Main _metrics interceptor_ class that handles default metrics creation and management * `MetricLabelsTyped` - the case class for storing typed metrics labels (it's important because some attributes can hold int values, not only strings, e.g. `HttpAttributes.HttpResponseStatusCode`) * Proper error handling and status code reporting Small refactoring of `tapir-otel4s-tracing`: * Replaces dependency to `"io.opentelemetry.semconv" % "opentelemetry-semconv"` by `"org.typelevel" %% "otel4s-semconv"` for `tapir-otel4s-tracing`, so that there are only dependencis to `otel4s` libraries in both modules now This closes #4798. --------- Co-authored-by: Adam Warski <[email protected]>
1 parent b4da94f commit 79037fa

File tree

6 files changed

+591
-10
lines changed

6 files changed

+591
-10
lines changed

build.sbt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ lazy val rawAllAggregates = core.projectRefs ++
188188
datadogMetrics.projectRefs ++
189189
zioMetrics.projectRefs ++
190190
opentelemetryTracing.projectRefs ++
191+
otel4sMetrics.projectRefs ++
191192
otel4sTracing.projectRefs ++
192193
json4s.projectRefs ++
193194
playJson.projectRefs ++
@@ -1121,8 +1122,24 @@ lazy val otel4sTracing: ProjectMatrix = (projectMatrix in file("tracing/otel4s-t
11211122
.settings(
11221123
name := "tapir-otel4s-tracing",
11231124
libraryDependencies ++= Seq(
1124-
"io.opentelemetry.semconv" % "opentelemetry-semconv" % Versions.openTelemetrySemconvVersion,
1125+
"org.typelevel" %% "otel4s-semconv" % Versions.otel4s,
1126+
"org.typelevel" %% "otel4s-oteljava" % Versions.otel4s,
1127+
"io.opentelemetry.semconv" % "opentelemetry-semconv" % Versions.openTelemetrySemconvVersion % Test,
1128+
"org.typelevel" %% "otel4s-oteljava-testkit" % Versions.otel4s % Test,
1129+
scalaTest.value % Test
1130+
)
1131+
)
1132+
.jvmPlatform(scalaVersions = scala2_13And3Versions, settings = commonJvmSettings)
1133+
.dependsOn(serverCore % CompileAndTest, catsEffect % Test)
1134+
1135+
lazy val otel4sMetrics: ProjectMatrix = (projectMatrix in file("metrics/otel4s-metrics"))
1136+
.settings(commonSettings)
1137+
.settings(
1138+
name := "tapir-otel4s-metrics",
1139+
libraryDependencies ++= Seq(
1140+
"org.typelevel" %% "otel4s-semconv" % Versions.otel4s,
11251141
"org.typelevel" %% "otel4s-oteljava" % Versions.otel4s,
1142+
"io.opentelemetry.semconv" % "opentelemetry-semconv" % Versions.openTelemetrySemconvVersion % Test,
11261143
"org.typelevel" %% "otel4s-oteljava-testkit" % Versions.otel4s % Test,
11271144
scalaTest.value % Test
11281145
)
@@ -2236,6 +2253,7 @@ lazy val examples: ProjectMatrix = (projectMatrix in file("examples"))
22362253
opentelemetryMetrics,
22372254
opentelemetryTracing,
22382255
otel4sTracing,
2256+
otel4sMetrics,
22392257
pekkoHttpServer,
22402258
picklerJson,
22412259
prometheusMetrics,
@@ -2305,6 +2323,7 @@ lazy val documentation: ProjectMatrix = (projectMatrix in file("generated-doc"))
23052323
opentelemetryMetrics,
23062324
opentelemetryTracing,
23072325
otel4sTracing,
2326+
otel4sMetrics,
23082327
pekkoHttpServer,
23092328
picklerJson,
23102329
playClient,

doc/server/observability.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,56 @@ val metrics = OpenTelemetryMetrics.default[Future](meter)
154154
val metricsInterceptor = metrics.metricsInterceptor() // add to your server options
155155
```
156156

157+
## otel4s OpenTelemetry metrics
158+
159+
Add the following dependency:
160+
161+
```scala
162+
"com.softwaremill.sttp.tapir" %% "tapir-otel4s-metrics" % "@VERSION@"
163+
```
164+
165+
The `Otel4sMetrics` provides integration with the [otel4s](https://typelevel.org/otel4s/) library for OpenTelemetry metrics.
166+
This allows you to create metrics for your tapir endpoints using a purely functional API.
167+
168+
`Otel4sMetrics` encapsulates metric instances and needs a `Meter[F]` from `otel4s` to create default metrics.
169+
170+
It should be set as `metricsInterceptor` of your ServerOptions:
171+
172+
Example using Http4s:
173+
```scala mdoc:compile-only
174+
import cats.effect.IO
175+
import org.typelevel.otel4s.oteljava.OtelJava
176+
import sttp.tapir.server.http4s.Http4sServerInterpreter
177+
import sttp.tapir.server.http4s.Http4sServerOptions
178+
import sttp.tapir.server.ServerEndpoint
179+
import sttp.tapir.server.metrics.otel4s.Otel4sMetrics
180+
181+
OtelJava
182+
.autoConfigured[IO]()
183+
.use { otel4s =>
184+
otel4s.meterProvider.get("meter-name").flatMap { meter =>
185+
val endpoints: List[ServerEndpoint[Any, IO]] = ???
186+
val routes =
187+
Http4sServerInterpreter[IO](
188+
Http4sServerOptions
189+
.customiseInterceptors[IO]
190+
.metricsInterceptor(Otel4sMetrics.default(meter).metricsInterceptor())
191+
.options
192+
).toRoutes(endpoints)
193+
// start your server
194+
???
195+
}
196+
}
197+
```
198+
199+
By default, the following metrics are exposed:
200+
201+
* `http.server.active_requests` (up-down-counter)
202+
* `http.server.requests.total` (counter)
203+
* `http.server.request.duration` (histogram)
204+
205+
206+
157207
## Datadog Metrics
158208

159209
Add the following dependency:
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package sttp.tapir.server.metrics.otel4s
2+
3+
import org.typelevel.otel4s.{Attribute, Attributes}
4+
import org.typelevel.otel4s.metrics.{Counter, Histogram, Meter, UpDownCounter}
5+
import org.typelevel.otel4s.semconv.attributes.{ErrorAttributes, HttpAttributes, UrlAttributes}
6+
import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor
7+
import sttp.tapir.server.metrics.{EndpointMetric, Metric, MetricLabelsTyped}
8+
import sttp.tapir.AnyEndpoint
9+
import sttp.tapir.model.ServerRequest
10+
import sttp.tapir.server.model.ServerResponse
11+
12+
import java.time.{Duration, Instant}
13+
14+
case class Otel4sMetrics[F[_]](metrics: List[Metric[F, _]]) {
15+
16+
import Otel4sMetrics._
17+
18+
/** Registers a `http.server.active_requests` up-down-counter (assuming default labels). */
19+
def addRequestsActive(meter: Meter[F], labels: MetricLabels = DefaultMetricLabels): Otel4sMetrics[F] =
20+
copy(metrics = metrics :+ requestActive(meter, labels))
21+
22+
/** Registers a `http.server.requests.total` counter (assuming default labels). */
23+
def addRequestsTotal(meter: Meter[F], labels: MetricLabels = DefaultMetricLabels): Otel4sMetrics[F] =
24+
copy(metrics = metrics :+ requestTotal(meter, labels))
25+
26+
/** Registers a `http.server.request.duration` histogram (assuming default labels). */
27+
def addRequestsDuration(meter: Meter[F], labels: MetricLabels = DefaultMetricLabels): Otel4sMetrics[F] =
28+
copy(metrics = metrics :+ requestDuration(meter, labels))
29+
30+
/** Registers a custom metric. */
31+
def addCustom(m: Metric[F, _]): Otel4sMetrics[F] = copy(metrics = metrics :+ m)
32+
33+
/** The interceptor which can be added to a server's options, to enable metrics collection. */
34+
def metricsInterceptor(ignoreEndpoints: Seq[AnyEndpoint] = Seq.empty): MetricsRequestInterceptor[F] =
35+
new MetricsRequestInterceptor[F](metrics, ignoreEndpoints)
36+
}
37+
38+
object Otel4sMetrics {
39+
private type MetricLabels = MetricLabelsTyped[Attribute[_]]
40+
41+
/** Using the default labels, registers the following metrics:
42+
*
43+
* - `http.server.active_requests` (up-down-counter)
44+
* - `http.server.requests.total` (counter)
45+
* - `http.server.request.duration` (histogram)
46+
*/
47+
def default[F[_]](meter: Meter[F], labels: MetricLabels = DefaultMetricLabels): Otel4sMetrics[F] =
48+
Otel4sMetrics(
49+
List[Metric[F, _]](
50+
requestActive(meter, labels),
51+
requestTotal(meter, labels),
52+
requestDuration(meter, labels)
53+
)
54+
)
55+
56+
/** Default labels for OpenTelemetry-compliant metrics, as recommended here:
57+
* https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#http-server
58+
*
59+
* - `http.request.method` - HTTP request method (e.g., GET, POST).
60+
* - `url.scheme` - the scheme of the request URL (e.g., http, https).
61+
* - `http.route` - the request path or route template.
62+
* - `http.response.status_code` - HTTP response status code (200, 404, etc.).
63+
*/
64+
private val DefaultMetricLabels: MetricLabels = MetricLabelsTyped[Attribute[_]](
65+
forRequest = List(
66+
{ case (ep, req) => HttpAttributes.HttpRequestMethod(req.method.method) },
67+
{ case (_, req) => UrlAttributes.UrlScheme(req.uri.scheme.getOrElse("unknown")) },
68+
{ case (ep, _) => HttpAttributes.HttpRoute(ep.showPathTemplate(showQueryParam = None)) }
69+
),
70+
forResponse = List(
71+
{
72+
case Right(r) => Some(HttpAttributes.HttpResponseStatusCode(r.code.code.toLong))
73+
case Left(ex) => Some(HttpAttributes.HttpResponseStatusCode(500))
74+
},
75+
{
76+
case Right(_) => None
77+
case Left(ex) => Some(ErrorAttributes.ErrorType(ex.getClass.getName))
78+
}
79+
)
80+
)
81+
82+
private def requestActive[F[_]](meter: Meter[F], labels: MetricLabels): Metric[F, F[UpDownCounter[F, Long]]] =
83+
Metric(
84+
metric = meter
85+
.upDownCounter[Long]("http.server.active_requests")
86+
.withDescription("Active HTTP requests")
87+
.withUnit("1")
88+
.create,
89+
onRequest = (req, counterM, m) =>
90+
m.map(counterM) { counter =>
91+
EndpointMetric()
92+
.onEndpointRequest(ep => counter.inc(requestAttrs(labels, ep, req)))
93+
.onResponseBody((ep, _) => counter.dec(requestAttrs(labels, ep, req)))
94+
.onException((ep, _) => counter.dec(requestAttrs(labels, ep, req)))
95+
}
96+
)
97+
98+
private def requestTotal[F[_]](meter: Meter[F], labels: MetricLabels): Metric[F, F[Counter[F, Long]]] =
99+
Metric(
100+
metric = meter
101+
.counter[Long]("http.server.requests.total")
102+
.withDescription("Total HTTP requests")
103+
.withUnit("1")
104+
.create,
105+
onRequest = (req, counterM, m) =>
106+
m.map(counterM) { counter =>
107+
EndpointMetric()
108+
.onResponseBody { (ep, res) =>
109+
counter.inc(requestAttrs(labels, ep, req) ++ responseAttrs(labels, Right(res), None))
110+
}
111+
.onException { (ep, ex) =>
112+
counter.inc(requestAttrs(labels, ep, req) ++ responseAttrs(labels, Left(ex), None))
113+
}
114+
}
115+
)
116+
117+
private def requestDuration[F[_]](meter: Meter[F], labels: MetricLabels): Metric[F, F[Histogram[F, Double]]] =
118+
Metric(
119+
metric = meter
120+
.histogram[Double]("http.server.request.duration")
121+
.withDescription("Duration of HTTP requests")
122+
.withUnit("ms")
123+
.create,
124+
onRequest = (req, recorderM, m) =>
125+
m.map(recorderM) { recorder =>
126+
val requestStart = Instant.now()
127+
128+
def duration = Duration.between(requestStart, Instant.now()).toMillis.toDouble
129+
130+
EndpointMetric()
131+
.onResponseHeaders { (ep, res) =>
132+
recorder.record(
133+
duration,
134+
requestAttrs(labels, ep, req) ++ responseAttrs(labels, Right(res), Some(labels.forResponsePhase.headersValue))
135+
)
136+
}
137+
.onResponseBody { (ep, res) =>
138+
recorder.record(
139+
duration,
140+
requestAttrs(labels, ep, req) ++ responseAttrs(labels, Right(res), Some(labels.forResponsePhase.bodyValue))
141+
)
142+
}
143+
.onException { (ep, ex) =>
144+
recorder.record(
145+
duration,
146+
requestAttrs(labels, ep, req) ++ responseAttrs(labels, Left(ex), None)
147+
)
148+
}
149+
}
150+
)
151+
152+
private[otel4s] def requestAttrs(l: MetricLabels, ep: AnyEndpoint, req: ServerRequest): Attributes =
153+
Attributes.newBuilder
154+
.addAll(l.forRequest.map(label => label(ep, req)))
155+
.result()
156+
157+
private[otel4s] def responseAttrs(l: MetricLabels, res: Either[Throwable, ServerResponse[_]], phase: Option[String]): Attributes =
158+
Attributes.newBuilder
159+
.addAll(l.forResponse.flatMap(label => label(res)))
160+
.addAll(phase.map(v => Attribute.from(l.forResponsePhase.name, v)))
161+
.result()
162+
}

0 commit comments

Comments
 (0)