Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@dj707chen
Copy link
Contributor

@dj707chen dj707chen commented Jun 17, 2024

Add CustomMetricsOps and update client and server Metrics middleware to allow adding custom labels to metrics.

Background
In a situation where a service interacts with multiple providers, a few of them do not have a lot of traffic, so when one of these light providers is down, the alert of the service is not triggered due to the small percentage counted in the failing traffic; So we want to create alerts specific to providers, and I’m searching for ways to identify the metrics entries related to a provider.

I can think of two options currently available:

  • Use provider specific prefix when create MetricsOps instance, but the metrics of this client will be excluded from a generic Http4S metrics dashboard;
  • Add provider specific prefix to classifier when create org.http4s.client.middleware.Metrics.

The second option is better than the first option, but not ideal.

What it does
This PR

  • Add CustomMetricsOps (extends existing MetricsOps) to allow adding custom labels to metrics records;
  • Add withCustomLabels method to both client and server Metrics middleware to pass custom label values when they call the CustomMetricsOps with custom label values.

When CustomMetricsOps with some custom labels is created, the Metrics middleware calling it must supply the same number of label values, so the type parameter SL (sized list) is added to enforce this constraint.

We could use shapeless's Sized type to express this constraint, but shapeless is not on the dependency list and it's unlikely it will be added. So we temporarily created SizedSeq to mimic shapeless's Sized type.

http4s-prometheus-metrics will be updated to adapt this feature, a draft pull request on http4s-prometheus-metrics Allow custom labels allows creating instance of CustomMetricsOps with custom label. epimetheus-http4s could also be updated.

Code example
First call http4s-prometheus-metrics's Prometheus to create an instance of CustomMetricsOps, notice the prefix payments:org_http4s_client, and one single custom label payment_provider.
Then create a Metrics client middleware, passing value Paylly to custom label.

for {
    # Create a CustomMetricsOps using the new method to be added to http4-promethues-metrics
    # with one custom label "payment_provider"
    customMetricsOps: CustomMetricsOps[F, SizedSeq1[String]] <- Stream.resource(
        Prometheus
          .default(cr)
          .withPrefix("payments:org_http4s_client")
          .buildCustomMetricsOps(CustomLabels(SizedSeq1("payment_provider"), SizedSeq1("default value1")))
      )
    # Create client middleware calling the customMetricsOps, passing custom label value "Paylly"
    client <- BlazeClientBuilder[F]
        .resource
        .map { client =>
            Metrics.withCustomLabels(
                customMetricsOps,
                SizedSeq1("Paylly")   # SizedSeq2("Paylly", "Other") would cause compilation error here
                (req: Request[F]) => req.uri.host.map(_.value))(client)
        }
}

Output examples
I created SNAPSHOT version of http4s and ran test in local, the above code generated below metrics entries; Notice that the entries with prefix payments:org_http4s_client_ have custom label payment_provider with value Paylly.

# Normal metrics entries:
org_http4s_client_response_duration_seconds_count{classifier="localhost",method="get",phase="body",} 3.0
org_http4s_client_response_duration_seconds_sum{classifier="localhost",method="get",phase="body",} 0.511480755
org_http4s_client_response_duration_seconds_bucket{classifier="localhost",method="get",phase="headers",le="0.005",} 0.0

# Custom metrics entries with custom label payment_provider:
payments:org_http4s_client_response_duration_seconds_bucket{classifier="test.com",method="post",phase="headers",payment_provider="Paylly",le="0.005",} 0.0
payments:org_http4s_client_response_duration_seconds_bucket{classifier="test.com",method="post",phase="headers",payment_provider="Paylly",le="+Inf",} 1.0
payments:org_http4s_client_response_duration_seconds_count{classifier="test.com",method="post",phase="headers",payment_provider="Paylly",} 1.0
payments:org_http4s_client_response_duration_seconds_sum{classifier="test.com",method="post",phase="headers",payment_provider="Paylly",} 0.526104997
payments:org_http4s_client_response_duration_seconds_count{classifier="test.com",method="post",phase="body",payment_provider="Paylly",} 1.0
payments:org_http4s_client_response_duration_seconds_sum{classifier="test.com",method="post",phase="body",payment_provider="Paylly",} 0.571559018
payments:org_http4s_client_request_count_total{classifier="test.com",method="post",status="2xx",payment_provider="Paylly",} 1.0
payments:org_http4s_client_request_count_created{classifier="test.com",method="post",status="2xx",payment_provider="Paylly",} 1.717544234533E9
payments:org_http4s_client_response_duration_seconds_created{classifier="test.com",method="post",phase="headers",payment_provider="Paylly",} 1.717544234488E9
payments:org_http4s_client_response_duration_seconds_created{classifier="test.com",method="post",phase="body",payment_provider="Paylly",} 1.717544234533E9

@dj707chen dj707chen marked this pull request as ready for review June 17, 2024 21:36
@rossabaker rossabaker added this to the 0.23.28 milestone Jun 17, 2024
@rossabaker
Copy link
Member

I spoke with @dj707chen about this. I think the basic tradeoff here is:

  • This is reinventing things that epimetheus core already does, specifically having properly sized custom labels.
  • But we can't just do this in epimetheus-http4s without duplicating all the HTTP metric functionality that's already http4s's Metrics middlewares.

@dj707chen
Copy link
Contributor Author

dj707chen commented Jun 17, 2024

  • But we can't just do this in epimetheus-http4s without duplicating all the HTTP metric functionality that's already http4s's Metrics middlewares.

The http4s Metrics middlewares currently interact with MetricsOps only and there is no means to pass custom labels; the new CustomMetricsOps extends MetricsOps, adding methods with custom label values, below is increaseActiveRequests as example:

trait CustomMetricsOps[F[_], SL <: SizedSeq[String]] extends MetricsOps[F] {
  def definingCustomLabels: CustomLabels[SL]

  def increaseActiveRequests(classifier: Option[String], customLabelValues: SL): F[Unit]
  override def increaseActiveRequests(classifier: Option[String]): F[Unit] =
    increaseActiveRequests(classifier, definingCustomLabels.values)

@dj707chen
Copy link
Contributor Author

dj707chen commented Jun 17, 2024

  • This is reinventing things that epimetheus core already does, specifically having properly sized custom labels.

epimetheus core's interface is defined using shapless's Sized type. I guess we don't want to add shapeless as a dependency for http4s core, I had to create the repetitive SizedSeq.

@iRevive
Copy link
Contributor

iRevive commented Jul 23, 2024

When it comes to the labels, there are two scenarios:

  1. Constant labels: payment_provider=Paylly or payment_provider=Wise
  2. Dynamic labels: decide the set of labels based on the request, e.g. if (request.host.contains("paylly")) "payment_provider=Paylly" else "payment_provider=Wise"

In some sense, constant labels are dynamic labels where _ => "payment_provider=Paylly".

Also, it seems that MetricsOps design was heavily influenced by integration with Prometheus. However, OpenTelemetry is gaining more traction each year, becoming a standard in the industry.

However, each metric backend treats labels differently. For example, in Prometheus, labels are key-value string entries.
In OpenTelemetry, labels (attributes) are typed.

While working with Prometheus, you must ensure that the configured instrument labels Counter.builder().labelNames("a", "b", "c") match the provided one: counter.labelValues("a value", "b value", "c value").inc(). That's why the SizedSeqN is mandatory to ensure correctness at the compile time.

And that's not the case for OpenTelemetry because you can use different sets of attributes (labels) with the same instrument, unlike Prometheus:


The current concept of the classifier is outdated because it's limited to exactly one label.
Currently, the classifier can be configured only through the Metrics:

object Metrics {
  def apply[F[_]](
      ops: MetricsOps[F],
      classifierF: Request[F] => Option[String] = { (_: Request[F]) => None },
  )(client: Client[F])(...): Client[F] = 
}

Which makes it pretty limiting.

I believe the backend should provide a way to build a classifier. For example:

trait MetricsOps[F[_]] {
  type Classifier
  def classifier(request: RequestPrelude): F[Option[Classifier]]
  def increaseActiveRequests(request: RequestPrelude, classifier: Option[Classifier]): F[Unit]
  ...
}

class OtelMetrics[F[_]] extends MetricsOps[F] {
  type Classifier = Attributes
}

class PrometheusMetrics[F[_]] extends MetricsOps[F] {
  type Classifier = Labels
}

Or even different classifiers per metric (which might be an overkill):

trait MetricsOps[F[_]] {
  type Classifier
  def classifier(metric: Metric, request: RequestPrelude): F[Option[Classifier]]
  def increaseActiveRequests(request: RequestPrelude, classifier: Option[Classifier]): F[Unit]
  ...
}

sealed trait Metric
object Metrics {
  case object ActiveRequests extends Metric
  case object TotalTime extends Metric
  case object RequestSize extends Metric
  case object ResponseSize extends Metric
}

@dj707chen
Copy link
Contributor Author

@iRevive Thanks for the insightful comment, I think the changes proposed in your comment could be good candidate for next major version.

Copy link
Contributor

@hamnis hamnis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a reasonable change given it has to be binary compatible.

Leaving it up to other maintainers to merge.

@dj707chen dj707chen requested a review from danicheg August 5, 2024 15:03
Copy link
Member

@rossabaker rossabaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I share the concern that this is biasing the type toward Prometheus, but I have thought about this off and on and not come up with a better way.

As we work on instrumenting backends directly with otel4s (e.g., #7476), I think that the MetricsOps will become less important anyway.

I'm good with this, with a few small nitpicks below.

}
}

final case class EmptyCustomLabels() extends CustomLabels[SizedSeq0[String]] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like this could be a case object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried to convert, but had difficulty, see my comment below on SizedSeq0.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to abstract and the constructor of EmptyCustomLabels private.

}

// format: off
final case class SizedSeq0[+A]() extends SizedSeqBase[A] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be a case object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried to convert it to a case object, but encountered difficulty, I may tried a wrong way?

case object SizedSeq0 {
  def toSeq[A]: Seq[A] = Seq.empty[A]
}

and EmptyCustomLabels will look like this:

case object EmptyCustomLabels {
  val labels = SizedSeq0
  val values = SizedSeq0
}

Then CustomMetricsOps.fromMetricsOps will looks like:

  def fromMetricsOps[F[_]](ops: MetricsOps[F]): CustomMetricsOps[F, SizedSeq0.type] = {

and it got compilation error:

Type SizedSeq0.type does not conform to upper bound SizedSeq[String] of type parameter SL

Root cause is that when we define SizedSeq0 as object, it can't extend SizedSeq since object can't define a type parameter which SizedSeq requires.

So I'm leaving it as case class for now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I didn't think of that. Could do something like this:

final case class SizedSeq0[+A] private () extends SizedSeq[A] {
  val toSeq: Seq[A] = Seq.empty[A]
}

final object SizedSeq0 {
  private[this] val instance = SizedSeq0[Nothing]()

  def apply[A](): SizedSeq0[A] = instance
}

It would be the only SizedSeq that can't be created with new, but it would prevent us from creating multiple instances when a singleton does just fine. I'm not sure if it's worth it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made it also abstract to fix the following error:

[error] /home/runner/work/http4s/http4s/core/shared/src/main/scala/org/http4s/util/SizedSeq.scala:24:1: error: [Http4sGeneralLinters.nonValidatingCopyConstructor] Case classes with private constructors should be abstract to prevent exposing a non-validating copy constructor
[error] final case class SizedSeq0[+A] private () extends SizedSeq[A] {
[error] ^
[error] (coreJS / scalafixAll) scalafix.sbt.ScalafixFailed: LinterError

@dj707chen dj707chen requested a review from rossabaker August 10, 2024 03:20
Copy link
Member

@rossabaker rossabaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

@rossabaker rossabaker merged commit 1ec9d1d into http4s:series/0.23 Aug 16, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants