Skip to content

Commit 92ef84a

Browse files
authored
Merge pull request #131 from ybasket/record-exemplars
Record Prometheus exemplars
2 parents c41c42a + d78e521 commit 92ef84a

File tree

3 files changed

+183
-45
lines changed

3 files changed

+183
-45
lines changed

docs/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,8 @@ val prefixedClient: Resource[IO, Client[IO]] =
4949
metrics <- Prometheus.metricsOps[IO](registry, "prefix")
5050
} yield Metrics[IO](metrics, classifier)(httpClient)
5151
```
52+
53+
## Exemplars
54+
55+
You can add Prometheus exemplars to most of the metrics (except gauges) recorded by `http4s-prometheus-metrics`
56+
by using `Prometheus.metricsOpsWithExemplars` and passing an effect that captures the related exemplar labels.

prometheus-metrics/src/main/scala/org/http4s/metrics/prometheus/Prometheus.scala

Lines changed: 112 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616

1717
package org.http4s.metrics.prometheus
1818

19+
import cats.Applicative
1920
import cats.data.NonEmptyList
2021
import cats.effect.Resource
2122
import cats.effect.Sync
2223
import cats.syntax.apply._
24+
import cats.syntax.flatMap._
25+
import cats.syntax.functor._
2326
import io.prometheus.client._
2427
import org.http4s.Method
2528
import org.http4s.Status
@@ -95,10 +98,32 @@ object Prometheus {
9598
): Resource[F, MetricsOps[F]] =
9699
for {
97100
metrics <- createMetricsCollection(registry, prefix, responseDurationSecondsHistogramBuckets)
98-
} yield createMetricsOps(metrics)
101+
} yield createMetricsOps(metrics, Applicative[F].pure(None))
102+
103+
/** Creates a [[MetricsOps]] that supports Prometheus metrics and records exemplars.
104+
*
105+
* Warning: The sampler effect is responsible for producing exemplar labels that are valid for the underlying
106+
* implementation as errors happening during metric recording will not be handled! For Prometheus version < 1.0,
107+
* this means the combined length of keys and values may not exceed 128 characters and the parts must adhere
108+
* to the label regex Prometheus defines.
109+
*
110+
* @param registry a metrics collector registry
111+
* @param sampleExemplar an effect that returns the corresponding exemplar labels
112+
* @param prefix a prefix that will be added to all metrics
113+
*/
114+
def metricsOpsWithExemplars[F[_]: Sync](
115+
registry: CollectorRegistry,
116+
sampleExemplar: F[Option[Map[String, String]]],
117+
prefix: String = "org_http4s_server",
118+
responseDurationSecondsHistogramBuckets: NonEmptyList[Double] = DefaultHistogramBuckets,
119+
): Resource[F, MetricsOps[F]] =
120+
for {
121+
metrics <- createMetricsCollection(registry, prefix, responseDurationSecondsHistogramBuckets)
122+
} yield createMetricsOps(metrics, sampleExemplar.map(_.map(toFlatArray)))
99123

100124
private def createMetricsOps[F[_]](
101-
metrics: MetricsCollection
125+
metrics: MetricsCollection,
126+
exemplarLabels: F[Option[Array[String]]],
102127
)(implicit F: Sync[F]): MetricsOps[F] =
103128
new MetricsOps[F] {
104129
override def increaseActiveRequests(classifier: Option[String]): F[Unit] =
@@ -120,10 +145,15 @@ object Prometheus {
120145
elapsed: Long,
121146
classifier: Option[String],
122147
): F[Unit] =
123-
F.delay {
124-
metrics.responseDuration
125-
.labels(label(classifier), reportMethod(method), Phase.report(Phase.Headers))
126-
.observe(SimpleTimer.elapsedSecondsFromNanos(0, elapsed))
148+
exemplarLabels.flatMap { exemplarOpt =>
149+
F.delay {
150+
metrics.responseDuration
151+
.labels(label(classifier), reportMethod(method), Phase.report(Phase.Headers))
152+
.observeWithExemplar(
153+
SimpleTimer.elapsedSecondsFromNanos(0, elapsed),
154+
exemplarOpt.orNull: _*
155+
)
156+
}
127157
}
128158

129159
override def recordTotalTime(
@@ -132,13 +162,18 @@ object Prometheus {
132162
elapsed: Long,
133163
classifier: Option[String],
134164
): F[Unit] =
135-
F.delay {
136-
metrics.responseDuration
137-
.labels(label(classifier), reportMethod(method), Phase.report(Phase.Body))
138-
.observe(SimpleTimer.elapsedSecondsFromNanos(0, elapsed))
139-
metrics.requests
140-
.labels(label(classifier), reportMethod(method), reportStatus(status))
141-
.inc()
165+
exemplarLabels.flatMap { exemplarOpt =>
166+
F.delay {
167+
metrics.responseDuration
168+
.labels(label(classifier), reportMethod(method), Phase.report(Phase.Body))
169+
.observeWithExemplar(
170+
SimpleTimer.elapsedSecondsFromNanos(0, elapsed),
171+
exemplarOpt.orNull: _*
172+
)
173+
metrics.requests
174+
.labels(label(classifier), reportMethod(method), reportStatus(status))
175+
.incWithExemplar(exemplarOpt.orNull: _*)
176+
}
142177
}
143178

144179
override def recordAbnormalTermination(
@@ -154,55 +189,75 @@ object Prometheus {
154189
}
155190

156191
private def recordCanceled(elapsed: Long, classifier: Option[String]): F[Unit] =
157-
F.delay {
158-
metrics.abnormalTerminations
159-
.labels(
160-
label(classifier),
161-
AbnormalTermination.report(AbnormalTermination.Canceled),
162-
label(Option.empty),
163-
)
164-
.observe(SimpleTimer.elapsedSecondsFromNanos(0, elapsed))
192+
exemplarLabels.flatMap { exemplarOpt =>
193+
F.delay {
194+
metrics.abnormalTerminations
195+
.labels(
196+
label(classifier),
197+
AbnormalTermination.report(AbnormalTermination.Canceled),
198+
label(Option.empty),
199+
)
200+
.observeWithExemplar(
201+
SimpleTimer.elapsedSecondsFromNanos(0, elapsed),
202+
exemplarOpt.orNull: _*
203+
)
204+
}
165205
}
166206

167207
private def recordAbnormal(
168208
elapsed: Long,
169209
classifier: Option[String],
170210
cause: Throwable,
171211
): F[Unit] =
172-
F.delay {
173-
metrics.abnormalTerminations
174-
.labels(
175-
label(classifier),
176-
AbnormalTermination.report(AbnormalTermination.Abnormal),
177-
label(Option(cause.getClass.getName)),
178-
)
179-
.observe(SimpleTimer.elapsedSecondsFromNanos(0, elapsed))
212+
exemplarLabels.flatMap { exemplarOpt =>
213+
F.delay {
214+
metrics.abnormalTerminations
215+
.labels(
216+
label(classifier),
217+
AbnormalTermination.report(AbnormalTermination.Abnormal),
218+
label(Option(cause.getClass.getName)),
219+
)
220+
.observeWithExemplar(
221+
SimpleTimer.elapsedSecondsFromNanos(0, elapsed),
222+
exemplarOpt.orNull: _*
223+
)
224+
}
180225
}
181226

182227
private def recordError(
183228
elapsed: Long,
184229
classifier: Option[String],
185230
cause: Throwable,
186231
): F[Unit] =
187-
F.delay {
188-
metrics.abnormalTerminations
189-
.labels(
190-
label(classifier),
191-
AbnormalTermination.report(AbnormalTermination.Error),
192-
label(Option(cause.getClass.getName)),
193-
)
194-
.observe(SimpleTimer.elapsedSecondsFromNanos(0, elapsed))
232+
exemplarLabels.flatMap { exemplarOpt =>
233+
F.delay {
234+
metrics.abnormalTerminations
235+
.labels(
236+
label(classifier),
237+
AbnormalTermination.report(AbnormalTermination.Error),
238+
label(Option(cause.getClass.getName)),
239+
)
240+
.observeWithExemplar(
241+
SimpleTimer.elapsedSecondsFromNanos(0, elapsed),
242+
exemplarOpt.orNull: _*
243+
)
244+
}
195245
}
196246

197247
private def recordTimeout(elapsed: Long, classifier: Option[String]): F[Unit] =
198-
F.delay {
199-
metrics.abnormalTerminations
200-
.labels(
201-
label(classifier),
202-
AbnormalTermination.report(AbnormalTermination.Timeout),
203-
label(Option.empty),
204-
)
205-
.observe(SimpleTimer.elapsedSecondsFromNanos(0, elapsed))
248+
exemplarLabels.flatMap { exemplarOpt =>
249+
F.delay {
250+
metrics.abnormalTerminations
251+
.labels(
252+
label(classifier),
253+
AbnormalTermination.report(AbnormalTermination.Timeout),
254+
label(Option.empty),
255+
)
256+
.observeWithExemplar(
257+
SimpleTimer.elapsedSecondsFromNanos(0, elapsed),
258+
exemplarOpt.orNull: _*
259+
)
260+
}
206261
}
207262

208263
private def label(value: Option[String]): String = value.getOrElse("")
@@ -292,6 +347,18 @@ object Prometheus {
292347
// https://github.com/prometheus/client_java/blob/parent-0.6.0/simpleclient/src/main/java/io/prometheus/client/Histogram.java#L73
293348
private val DefaultHistogramBuckets: NonEmptyList[Double] =
294349
NonEmptyList(.005, List(.01, .025, .05, .075, .1, .25, .5, .75, 1, 2.5, 5, 7.5, 10))
350+
351+
// Prometheus expects exemplars as alternating key-value strings: k1, v1, k2, v2, ...
352+
private def toFlatArray(m: Map[String, String]): Array[String] = {
353+
val arr = new Array[String](m.size * 2)
354+
var i = 0
355+
m.foreach { case (key, value) =>
356+
arr(i) = key
357+
arr(i + 1) = value
358+
i += 2
359+
}
360+
arr
361+
}
295362
}
296363

297364
final case class MetricsCollection(
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2018 http4s.org
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.http4s.metrics.prometheus
18+
19+
import cats.effect.*
20+
import io.prometheus.client.CollectorRegistry
21+
import io.prometheus.client.exemplars.Exemplar
22+
import munit.CatsEffectSuite
23+
import org.http4s.HttpApp
24+
import org.http4s.client.Client
25+
import org.http4s.client.middleware.Metrics
26+
import org.http4s.metrics.prometheus.util.*
27+
28+
class PrometheusExemplarsSuite extends CatsEffectSuite {
29+
val client: Client[IO] = Client.fromHttpApp[IO](HttpApp[IO](stub))
30+
31+
meteredClient(exemplar = Map("trace_id" -> "123")).test(
32+
"A http client with a prometheus metrics middleware should sample an exemplar"
33+
) { case (registry, client) =>
34+
client.expect[String]("/ok").map { resp =>
35+
val filter = new java.util.HashSet[String]()
36+
filter.add("exemplars_request_count_total")
37+
val exemplar: Exemplar = registry
38+
.filteredMetricFamilySamples(filter)
39+
.nextElement()
40+
.samples
41+
.get(0)
42+
.exemplar
43+
44+
assertEquals(exemplar.getLabelName(0), "trace_id")
45+
assertEquals(exemplar.getLabelValue(0), "123")
46+
assertEquals(resp, "200 OK")
47+
}
48+
}
49+
50+
private def buildMeteredClient(
51+
exemplar: Map[String, String]
52+
): Resource[IO, (CollectorRegistry, Client[IO])] = {
53+
implicit val clock: Clock[IO] = FakeClock[IO]
54+
55+
for {
56+
registry <- Prometheus.collectorRegistry[IO]
57+
metrics <- Prometheus
58+
.metricsOpsWithExemplars[IO](registry, IO.pure(Some(exemplar)), "exemplars")
59+
} yield (registry, Metrics[IO](metrics)(client))
60+
}
61+
62+
def meteredClient(
63+
exemplar: Map[String, String]
64+
): SyncIO[FunFixture[(CollectorRegistry, Client[IO])]] =
65+
ResourceFixture(buildMeteredClient(exemplar))
66+
}

0 commit comments

Comments
 (0)