Skip to content

Commit

Permalink
Migrate to nanoTime for time* methods, improve API DX
Browse files Browse the repository at this point in the history
timeFunc and timeFuture used millisecond precision, which lead to issues like
Summary quantiles being skewed towards 0 if measured latencies are close to 1 ms.

To fix that:
- time* methods use System.nanoTime() now
- deprecate specific time*Nano methods, millisecond precision shouldn't be a choice to avoid mistakes
- improve method naming, document all time-related public functionality
- deprecate unused TemporalOps implicit conversion
- overall developer experience improvements

This change breaks source compatibility for some usages, but binary compatibility is preserved.
  • Loading branch information
migesok committed Sep 3, 2024
1 parent b86b650 commit 7dd29e2
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 76 deletions.
2 changes: 2 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Compile / doc / scalacOptions ++= Seq("-groups", "-implicits", "-no-link-warning
publishTo := Some(Resolver.evolutionReleases)

libraryDependencies ++= Seq(
// executor-tools dependency is not used anymore, left as is so MiMa bincompat report doesn't complain
// TODO: remove in 2.x
"com.evolutiongaming" %% "executor-tools" % "1.0.4",
"io.prometheus" % "simpleclient_common" % "0.8.1",
"org.scalameta" %% "munit" % "1.0.0" % Test
Expand Down
14 changes: 14 additions & 0 deletions src/main/scala/com/evolutiongaming/prometheus/ClockPlatform.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
package com.evolutiongaming.prometheus

/** Exposes platform time measuring capabilities needed for metrics
*/
trait ClockPlatform {

/** @see
* [[System#currentTimeMillis()]]
*/
def nowMillis: Long

/** @see
* [[System#nanoTime()]]
*/
def nowNano: Long
}

object ClockPlatform {

/** Global singleton instance for default [[ClockPlatform]] for JVM
*/
val default: ClockPlatform = new Default

/** Default [[ClockPlatform]] impl for JVM - use [[ClockPlatform.default]] global singleton instance!
*/
class Default extends ClockPlatform {
override def nowMillis: Long = System.currentTimeMillis()
override def nowNano: Long = System.nanoTime()
Expand Down
11 changes: 9 additions & 2 deletions src/main/scala/com/evolutiongaming/prometheus/HasObserve.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package com.evolutiongaming.prometheus

/** A type-class abstracting over prometheus client histogram-like classes providing "observe a Double value" method.
*
* Check [[PrometheusHelper]] for available implicit instances.
*/
trait HasObserve[F] {
def observe(observer: F, duration: Double): Unit

/** Observe a new sample value on a histogram-like metric type
*/
def observe(observer: F, value: Double): Unit
}

object HasObserve {
def apply[F](implicit hasObserve: HasObserve[F]) = hasObserve
def apply[F](implicit hasObserve: HasObserve[F]): HasObserve[F] = hasObserve
}
201 changes: 160 additions & 41 deletions src/main/scala/com/evolutiongaming/prometheus/ObserveDuration.scala
Original file line number Diff line number Diff line change
@@ -1,65 +1,184 @@
package com.evolutiongaming.prometheus

import io.prometheus.client.Collector
import io.prometheus.client.{Collector, SimpleTimer}

import scala.annotation.{nowarn, unused}
import scala.concurrent.{ExecutionContext, Future}

trait ObserveDuration[F] {

import scala.math.Numeric.Implicits.*
import scala.util.Try

/** Time duration measurement syntax for [[HasObserve]]-kind metrics from the prometheus client, i.e., Summary, Histogram.
*
* Here time is always reported in seconds, which means your prometheus metric name should end in `_seconds`.
*
* The class is not supposed to be used directly but as an implicit syntax provided by [[PrometheusHelper]].
*/
sealed trait ObserveDuration[F] {

/** Measures evaluation time of a block in seconds with nano-time precision
*/
def timeFunc[T](f: => T): T
def timeFuncNanos[T](f: => T): T

/** Measures evaluation time of a block in seconds with nano-time precision
*
* @deprecated
* since 1.1.0 timeFunc has been changed to use nano-time precision, this method is obsolete and will be removed
*/
@deprecated(
message = "use timeFunc instead - it has nano-time precision now",
since = "1.1.0"
)
def timeFuncNanos[T](f: => T): T = timeFunc(f)

/** Measures evaluation time of an asynchronous block in seconds with nano-time precision
*/
def timeFuture[T](f: => Future[T]): Future[T]
def timeFutureNanos[T](f: => Future[T]): Future[T]

/** Measures evaluation time of an asynchronous block in seconds with nano-time precision
*
* @deprecated
* since 1.1.0 timeFuture has been changed to use nano-time precision, this method is obsolete and will be removed
*/
@deprecated(
message = "use timeFuture instead - it has nano-time precision now",
since = "1.1.0"
)
def timeFutureNanos[T](f: => Future[T]): Future[T] = timeFuture(f)

/** Measures in seconds the time spent since the provided start time obtained using [[ClockPlatform.nowMillis]]
*
* @param start
* start time from a millisecond-precision clock
* @deprecated
* since 1.1.0, use timeTillNowMillis(: Long) with a primitive arg type and explicit precision name suffix
*/
@deprecated(
message = "use timeTillNowMillis(: Long) with a primitive arg type and explicit precision name suffix",
since = "1.1.0"
)
def timeTillNow[T](start: T)(implicit numeric: Numeric[T]): Unit

/** Measures in seconds the time spent since the provided start time obtained using [[ClockPlatform.nowNano]]
*
* @param start
* start time from a nanosecond-precision clock
* @deprecated
* since 1.1.0, use timeTillNowNanos(: Long) with a primitive arg type
*/
@deprecated(
message = "use timeTillNowNanos(: Long) with a primitive arg type",
since = "1.1.0"
)
def timeTillNowNanos[T](start: T)(implicit numeric: Numeric[T]): Unit
}

object ObserveDuration {
def apply[F](implicit observeDuration: ObserveDuration[F]): ObserveDuration[F] = observeDuration
/** Measures in seconds the time spent since the provided start time obtained using [[ClockPlatform.nowMillis]]
*/
def timeTillNowMillis(startMs: Long): Unit = {
// default impl for a new method of a trait - added for keeping binary compatibility
// TODO: bincompat leftover, remove in 2.x

def fromHasObserver[F](observer: F)(implicit hasObserve: HasObserve[F], clock: ClockPlatform, ec: ExecutionContext): ObserveDuration[F] =
new ObserveDuration[F] {
timeTillNow[Long](startMs): @nowarn("cat=deprecation")
}

override def timeFunc[T](f: => T): T =
measureFunction(f, clock.nowMillis, timeTillNow[Long])
/** Measures in seconds the time spent since the provided start time obtained using [[ClockPlatform.nowNano]]
*/
def timeTillNowNanos(startNs: Long): Unit = {
// default impl for a new method of a trait - added for keeping binary compatibility
// TODO: bincompat leftover, remove in 2.x

override def timeFuncNanos[T](f: => T): T =
measureFunction(f, clock.nowNano, timeTillNowNanos[Long])
timeTillNowNanos[Long](startNs): @nowarn("cat=deprecation")
}
}

private def measureFunction[A](f: => A, start: Long, measurer: Long => Unit): A =
try f
finally measurer(start)
object ObserveDuration {
def apply[F](implicit observeDuration: ObserveDuration[F]): ObserveDuration[F] = observeDuration

override def timeFuture[T](f: => Future[T]): Future[T] =
measureFuture(f, clock.nowMillis, timeTillNow[Long])
// TODO: bincompat leftover, remove in 2.x
@deprecated(
message = "use create",
since = "1.1.0"
)
def fromHasObserver[F](
observer: F
)(implicit
hasObserve: HasObserve[F],
clock: ClockPlatform,
@unused ec: ExecutionContext
): ObserveDuration[F] = new ObserveDurationImpl[F](observer)

/** Creates [[ObserveDuration]] implementation instance
*
* @param observer
* metric instance which has [[HasObserve]]
* @param hasObserve
* [[HasObserve]] instance for the metric
* @param clock
* [[ClockPlatform]] to use for time measurement
* @tparam F
* metric type
*/
def create[F](
observer: F
)(implicit
hasObserve: HasObserve[F],
clock: ClockPlatform
): ObserveDuration[F] = new ObserveDurationImpl[F](observer)

private final class ObserveDurationImpl[F](
observer: F
)(implicit
hasObserve: HasObserve[F],
clock: ClockPlatform
) extends ObserveDuration[F] {

override def timeFunc[T](f: => T): T = {
val startNs = clock.nowNano
try {
f
} finally {
timeTillNowNanos(startNs)
}
}

override def timeFutureNanos[T](f: => Future[T]): Future[T] =
measureFuture(f, clock.nowNano, timeTillNowNanos[Long])
override def timeFuture[T](f: => Future[T]): Future[T] = {
val startNs = clock.nowNano
Future
.fromTry(Try {
f
})
.flatten
.andThen { case _ =>
timeTillNowNanos(startNs)
}(ExecutionContext.parasitic)
}

private def measureFuture[A](
f: => Future[A],
start: Long,
measurer: Long => Unit
)(implicit ec: ExecutionContext): Future[A] = f andThen { case _ => measurer(start) }
override def timeTillNow[T](
start: T
)(implicit numeric: Numeric[T]): Unit = {
val value = duration(start, clock.nowMillis) / Collector.MILLISECONDS_PER_SECOND
hasObserve.observe(observer, value)
}

override def timeTillNow[T](
start: T
)(implicit numeric: Numeric[T]): Unit = {
val value = duration(start, clock.nowMillis) / Collector.MILLISECONDS_PER_SECOND
hasObserve.observe(observer, value)
}
override def timeTillNowNanos[T](start: T)(implicit
numeric: Numeric[T]
): Unit = {
val value = duration(start, clock.nowNano) / Collector.NANOSECONDS_PER_SECOND
hasObserve.observe(observer, value)
}

override def timeTillNowNanos[T](start: T)(implicit
numeric: Numeric[T]
): Unit = {
val value = duration(start, clock.nowNano) / Collector.NANOSECONDS_PER_SECOND
hasObserve.observe(observer, value)
}
override def timeTillNowMillis(startMs: Long): Unit = {
val endMs = clock.nowMillis
val elapsedSeconds = (endMs - startMs).toDouble / Collector.MILLISECONDS_PER_SECOND
hasObserve.observe(observer, elapsedSeconds)
}

private def duration[A](start: A, now: Long)(implicit a: Numeric[A]): Double = {
now.toDouble - a.toDouble(start)
override def timeTillNowNanos(startNs: Long): Unit = {
val endNs = clock.nowNano
hasObserve.observe(observer, SimpleTimer.elapsedSecondsFromNanos(startNs, endNs))
}
}

private def duration[A: Numeric](start: A, now: Long): Double = {
now.toDouble - start.toDouble
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package com.evolutiongaming.prometheus

import com.evolutiongaming.concurrent.CurrentThreadExecutionContext
import io.prometheus.client.{Gauge, Histogram, SimpleCollector, Summary}

import scala.concurrent.{ExecutionContext, Future}

import scala.concurrent.Future
import scala.math.Numeric.Implicits.*

/** Main entry point for prometheus-tools goodies, mainly extension method for prometheus client classes.
*
* Usage:
* {{{
* import com.evolutiongaming.prometheus.PrometheusHelper.*
* }}}
*
* @see
* [[ObserveDuration]]
*/
object PrometheusHelper {
private implicit val ec: ExecutionContext = CurrentThreadExecutionContext
private implicit val clock: ClockPlatform = ClockPlatform.default

implicit val histogramObs: HasObserve[Histogram] = (histogram: Histogram, duration: Double) => histogram.observe(duration)
Expand All @@ -19,15 +28,42 @@ object PrometheusHelper {

implicit class GaugeOps(val gauge: Gauge) extends AnyVal {

def collect[T](f: => T)(implicit numeric: Numeric[T]): Gauge = {
def collect[T: Numeric](f: => T): Gauge = {
val child = new Gauge.Child() {
override def get() = numeric.toDouble(f)
override def get(): Double = f.toDouble
}
gauge.setChild(child)
}
}

implicit class TemporalOps[A: ObserveDuration](val a: A) {
/*
TODO: bincompat leftover, remove in 2.x
Without this magic method MiMa complained:
static method TemporalOps(java.lang.Object,com.evolutiongaming.prometheus.ObserveDuration)com.evolutiongaming.prometheus.PrometheusHelper#TemporalOps
in class com.evolutiongaming.prometheus.PrometheusHelper does not have a correspondent in current version
The visibility also has to be public, otherwise scalac does not reliably generate static method for both 2.13 and 3
*/
@deprecated(
message = "referencing TemporalOps directly is deprecated, use implicit syntax",
since = "1.1.0"
)
def TemporalOps[A](
v1: A,
v2: com.evolutiongaming.prometheus.ObserveDuration[A]
): com.evolutiongaming.prometheus.PrometheusHelper.TemporalOps[A] = new TemporalOps[A](v1)(v2)

/*
TODO: bincompat leftover, remove in 2.x
TemporalOps wasn't needed to provide ObserveDuration syntax, should be removed - see PrometheusHelperSpec
*/
@deprecated(
message = "referencing TemporalOps directly is deprecated, use implicit syntax",
since = "1.1.0"
)
private[prometheus] class TemporalOps[A: ObserveDuration](val a: A) {

def timeFunc[T](f: => T): T = ObserveDuration[A].timeFunc(f)

Expand All @@ -46,7 +82,7 @@ object PrometheusHelper {
ObserveDuration[A].timeTillNowNanos(start)
}

implicit class BuilderOps[C <: SimpleCollector[_], B <: SimpleCollector.Builder[
implicit class BuilderOps[C <: SimpleCollector[?], B <: SimpleCollector.Builder[
B,
C
]](val self: B)
Expand All @@ -60,7 +96,8 @@ object PrometheusHelper {
}
}

implicit def observeDuration[F](observer: F)(implicit hasObserve: HasObserve[F]): ObserveDuration[F] = ObserveDuration.fromHasObserver(observer)
implicit def observeDuration[F](observer: F)(implicit hasObserve: HasObserve[F]): ObserveDuration[F] =
ObserveDuration.create(observer)

implicit class RichSummaryBuilder(val summaryBuilder: Summary.Builder) extends AnyVal {

Expand Down
Loading

0 comments on commit 7dd29e2

Please sign in to comment.