1616
1717package org .http4s .metrics .prometheus
1818
19+ import cats .Applicative
1920import cats .data .NonEmptyList
2021import cats .effect .Resource
2122import cats .effect .Sync
2223import cats .syntax .apply ._
24+ import cats .syntax .flatMap ._
25+ import cats .syntax .functor ._
2326import io .prometheus .client ._
2427import org .http4s .Method
2528import 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
297364final case class MetricsCollection (
0 commit comments