Skip to content

Commit

Permalink
migrate green score calculations in backend
Browse files Browse the repository at this point in the history
  • Loading branch information
Zwiterrion committed Sep 7, 2023
1 parent 0a19f21 commit 1b2681b
Show file tree
Hide file tree
Showing 6 changed files with 441 additions and 261 deletions.
194 changes: 185 additions & 9 deletions otoroshi/app/greenscore/ecometrics.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package otoroshi.greenscore

import com.codahale.metrics.UniformReservoir
import otoroshi.env.Env
import otoroshi.greenscore.EcoMetrics.{MAX_GREEN_SCORE_NOTE, colorFromScore, letterFromScore, scoreToColor}
import otoroshi.greenscore.Score.{RouteScore, SectionScore}
import otoroshi.utils.cache.types.UnboundedTrieMap
import play.api.Logger
import play.api.libs.json.{JsObject, JsValue, Json}
import play.api.libs.json._

import java.util.{Timer => _}
import scala.collection.concurrent.TrieMap
import scala.util.Try

class GlobalScore {
private val routesScore: UnboundedTrieMap[String, RouteScore] = TrieMap.empty
Expand Down Expand Up @@ -41,19 +43,21 @@ class GlobalScore {
)
}

def json(routeId: String) = routesScore.get(routeId).map(_.json()).getOrElse(Json.obj())
def route(routeId: String): Option[RouteScore] = routesScore.get(routeId)

def json(routeId: String) = routesScore.get(routeId).map(_.json()).getOrElse(new RouteScore().json())
}

class RouteScore {
private val overheadReservoir: UniformReservoir = new UniformReservoir()
private val overheadWithoutCircuitBreakerReservoir: UniformReservoir = new UniformReservoir()
private val circuitBreakerDurationReservoir: UniformReservoir = new UniformReservoir()
private val durationReservoir: UniformReservoir = new UniformReservoir()
private val pluginsReservoir: UniformReservoir = new UniformReservoir()
var pluginsReservoir: Int = 0

private val dataInReservoir: UniformReservoir = new UniformReservoir()
private val headersOutReservoir: UniformReservoir = new UniformReservoir()
private val dataOutReservoir: UniformReservoir = new UniformReservoir()
val headersOutReservoir: UniformReservoir = new UniformReservoir()
val dataOutReservoir: UniformReservoir = new UniformReservoir()
private val headersReservoir: UniformReservoir = new UniformReservoir()

private var backendId: String = ""
Expand All @@ -74,7 +78,7 @@ class RouteScore {
overheadWithoutCircuitBreakerReservoir.update(overheadWithoutCircuitBreaker)
circuitBreakerDurationReservoir.update(circuitBreakerDuration)
durationReservoir.update(duration)
pluginsReservoir.update(plugins)
pluginsReservoir = plugins

dataInReservoir.update(dataIn)
dataOutReservoir.update(dataOut)
Expand All @@ -89,21 +93,193 @@ class RouteScore {
"overheadWithoutCircuitBreakerReservoir" -> overheadWithoutCircuitBreakerReservoir.getSnapshot.getMean,
"circuitBreakerDurationReservoir" -> circuitBreakerDurationReservoir.getSnapshot.getMean,
"durationReservoir" -> durationReservoir.getSnapshot.getMean,
"pluginsReservoir" -> pluginsReservoir.getSnapshot.getMean,
"pluginsReservoir" -> pluginsReservoir,
"backendId" -> backendId,
"dataInReservoir" -> dataInReservoir.getSnapshot.getMean,
"dataOutReservoir" -> dataOutReservoir.getSnapshot.getMean,
"headersReservoir" -> headersReservoir.getSnapshot.getMean,
"headersOutReservoir" -> headersOutReservoir.getSnapshot.getMean
)
}

object SectionScoreHelper {
val format = new Format[SectionScore] {
override def writes(o: SectionScore): JsValue = ???

override def reads(value: JsValue): JsResult[SectionScore] = Try {
JsSuccess(
SectionScore(
id = (value \ "id").as[String],
score = (value \ "score").as[Double],
normalizedScore = (value \ "normalized_score").as[Double],
letter = (value \ "letter").as[String],
color = (value \ "color").as[String],
)
)
} recover { case e =>
JsError(e.getMessage)
} get
}


def mean(values: Seq[SectionScore]) = SectionScore(
id = values.head.id,
score = values.foldLeft(0.0)(_ + _.score) / values.length,
normalizedScore = values.foldLeft(0.0)(_ + _.normalizedScore) / values.length,
letter = letterFromScore(values.foldLeft(0.0)(_ + _.score)),
color = colorFromScore(values.foldLeft(0.0)(_ + _.score))
)
}

// def compute(): Double = dataOutReservoir.getSnapshot.getMean + headersOutReservoir.getSnapshot.getMean
sealed trait Score {
def color: String
def letter: String
}
object Score {
case class Excellent(color: String = "#2ecc71", letter: String = "A") extends Score
case class Acceptable(color: String = "#27ae60", letter: String = "B") extends Score
case class Sufficient(color: String = "#f1c40f", letter: String = "C") extends Score
case class Poor(color: String = "#d35400", letter: String = "D") extends Score
case class ExtremelyPoor(color: String = "#c0392b", letter: String = "E") extends Score

case class SectionScore(id: String = "",
score: Double = 0.0,
normalizedScore: Double = 0.0,
letter: String = "",
color: String = "") {
def json = Json.obj(
"id" -> id,
"score" -> score,
"normalized_score" -> normalizedScore,
"letter" -> letter,
"color" -> color
)

def merge(other: SectionScore): SectionScore = SectionScore(
id = other.id,
score = score + other.score,
normalizedScore = normalizedScore + other.normalizedScore,
letter = other.letter,
color = other.color
)
}
case class RouteScore(
route: RouteGreenScore,
informations: SectionScore,
sectionsScore: Seq[SectionScore],
pluginsInstance: Double,
producedData: Double,
producedHeaders: Double,
architecture: SectionScore,
design: SectionScore,
log: SectionScore,
usage: SectionScore
) {
def json(): JsObject = Json.obj(
"route" -> Json.obj(
"routeId" -> route.routeId,
"rules_config" -> GreenScoreConfig.format.writes(route.rulesConfig)
),
"informations" -> informations.json,
"sections" -> sectionsScore.map(_.json),
"plugins_instance" -> pluginsInstance,
"produced_data" -> producedData,
"produced_headers" -> producedHeaders,
"architecture" -> architecture.json,
"design" -> design.json,
"log" -> log.json,
"usage" -> usage.json
)
}
}

object EcoMetrics {
private val MAX_GREEN_SCORE_NOTE = 6000

private def scoreToColor(rank: Double): Score = {
if (rank >= MAX_GREEN_SCORE_NOTE) {
Score.Excellent()
} else if (rank >= 3000) {
Score.Acceptable()
} else if (rank >= 2000) {
Score.Sufficient()
} else if (rank >= 1000) {
Score.Poor()
} else // rank < 1000
Score.ExtremelyPoor()
}

def letterFromScore(rank: Double): String = {
scoreToColor(rank).letter
}

def colorFromScore(rank: Double): String = {
scoreToColor(rank).color
}
}

class EcoMetrics(env: Env) {

private val registry = new GlobalScore()

private def calculateRules(rules: GreenScoreConfig) = {
rules.sections
.foldLeft(Seq.empty[SectionScore]) {
case (scores, section) =>
val sectionScore = section.rules.foldLeft(0.0) { case (acc, rule) =>
if (rule.enabled) {
acc + MAX_GREEN_SCORE_NOTE * (rule.sectionWeight / 100) * (rule.weight / 100)
} else {
acc
}
}
scores :+ SectionScore(
section.id.value,
sectionScore,
sectionScore / (section.rules.head.sectionWeight / 100 * MAX_GREEN_SCORE_NOTE),
letterFromScore(sectionScore),
colorFromScore(sectionScore)
)
}
}

def normalizeReservoir(value: Double, limit: Int) = {
if (value > limit)
1
else
value / limit
}

def calculateScore(route: RouteGreenScore) = {
val sectionsScore: Seq[SectionScore] = calculateRules(route.rulesConfig)

val routeScore = registry.route(route.routeId)
.getOrElse(new RouteScore())

val plugins = 1 - this.normalizeReservoir(routeScore.pluginsReservoir, route.rulesConfig.thresholds.plugins.poor)
val producedData = 1 - this.normalizeReservoir(routeScore.dataOutReservoir.getSnapshot.getMean, route.rulesConfig.thresholds.dataOut.poor)
val producedHeaders = 1 - this.normalizeReservoir(routeScore.headersOutReservoir.getSnapshot.getMean, route.rulesConfig.thresholds.headersOut.poor)

RouteScore(
route = route,
sectionsScore = sectionsScore,
pluginsInstance = plugins,
producedData = producedData,
producedHeaders = producedHeaders,
architecture = sectionsScore.find(section => section.id == "architecture").get,
design = sectionsScore.find(section => section.id == "design").get,
usage = sectionsScore.find(section => section.id == "usage").get,
log = sectionsScore.find(section => section.id == "log").get,
informations = SectionScore(
id = route.routeId,
score = sectionsScore.foldLeft(0.0)( _ + _.score),
normalizedScore = sectionsScore.foldLeft(0.0)( _ + _.normalizedScore),
letter = letterFromScore(sectionsScore.foldLeft(0.0)( _ + _.score)),
color = colorFromScore(sectionsScore.foldLeft(0.0)( _ + _.score)),
)
)
}

def json(routeId: String): JsValue = registry.json(routeId)

def updateRoute(
Expand Down
72 changes: 47 additions & 25 deletions otoroshi/app/greenscore/extension.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import akka.actor.{Actor, ActorRef, Props}
import otoroshi.api.{GenericResourceAccessApiWithState, Resource, ResourceVersion}
import otoroshi.env.Env
import otoroshi.events.{GatewayEvent, OtoroshiEvent}
import otoroshi.greenscore.EcoMetrics.{colorFromScore, letterFromScore}
import otoroshi.greenscore.Score.SectionScore
import otoroshi.models.{EntityLocation, EntityLocationSupport}
import otoroshi.next.extensions.{AdminExtension, AdminExtensionAdminApiRoute, AdminExtensionEntity, AdminExtensionId}
import otoroshi.storage.{BasicStore, RedisLike, RedisLikeStore}
Expand Down Expand Up @@ -52,7 +54,7 @@ class OtoroshiEventListener(ext: GreenScoreExtension, env: Env) extends Actor {
}
}

case class RouteScreenScore(routeId: String, rulesConfig: GreenScoreConfig)
case class RouteGreenScore(routeId: String, rulesConfig: GreenScoreConfig)

case class GreenScoreEntity(
location: EntityLocation,
Expand All @@ -61,7 +63,7 @@ case class GreenScoreEntity(
description: String,
tags: Seq[String],
metadata: Map[String, String],
routes: Seq[RouteScreenScore]
routes: Seq[RouteGreenScore]
) extends EntityLocationSupport {
override def internalId: String = id
override def json: JsValue = GreenScoreEntity.format.writes(this)
Expand Down Expand Up @@ -103,7 +105,7 @@ object GreenScoreEntity {
route
.asOpt[JsObject]
.map(v => {
RouteScreenScore(
RouteGreenScore(
v.select("routeId").as[String],
v.select("rulesConfig").asOpt[JsObject].map(GreenScoreConfig.format.reads).get.get
)
Expand Down Expand Up @@ -185,34 +187,54 @@ class GreenScoreExtension(val env: Env) extends AdminExtension {
override def adminApiRoutes(): Seq[AdminExtensionAdminApiRoute] = Seq(
AdminExtensionAdminApiRoute(
"GET",
"/api/extensions/green-score/eco",
"/api/extensions/green-score",
false,
(ctx, request, apk, _) => {
Results.Ok(Json.obj("score" -> 0)).vfuture
}
),
AdminExtensionAdminApiRoute(
"GET",
"/api/extensions/green-score/:greenscore",
false,
(ctx, request, apk, _) => {
implicit val ec = env.otoroshiExecutionContext
implicit val ev = env
val greenScoreId = ctx.named("greenscore").map(JsString.apply).getOrElse(JsNull).asString
implicit val ec = env.otoroshiExecutionContext
implicit val ev = env

for {
scores <- datastores.greenscoresDatastore.findAll()
} yield {
val jsonScores = scores
.find(_.id == greenScoreId)
.map(group => group.routes.foldLeft(Json.arr())((acc, route) => acc :+ ecoMetrics.json(route.routeId)))
.getOrElse(Json.arr())

Results.Ok(
Json.obj(
"group" -> greenScoreId,
"scores" -> jsonScores
)
)
.map(group => {
val groupScores = group.routes.map(route => ecoMetrics.calculateScore(route))

group.json.as[JsObject]
.deepMerge(Json.obj(
"routes" -> groupScores.map(_.json()),
"plugins_instance" -> groupScores.foldLeft(0.0)(_ + _.pluginsInstance) / groupScores.length,
"produced_data" -> groupScores.foldLeft(0.0)(_ + _.producedData) / groupScores.length,
"produced_headers" -> groupScores.foldLeft(0.0)(_ + _.producedHeaders) / groupScores.length,
"architecture" -> groupScores.foldLeft(SectionScore()) { case (a,b) => a.merge(b.architecture) }.json,
"design" -> groupScores.foldLeft(SectionScore()) { case (a,b) => a.merge(b.design) }.json,
"usage" -> groupScores.foldLeft(SectionScore()) { case (a,b) => a.merge(b.usage) }.json,
"log" -> groupScores.foldLeft(SectionScore()) { case (a,b) => a.merge(b.log) }.json,
"score" -> groupScores.foldLeft(0.0)(_ + _.informations.score) / groupScores.length,
"normalized_score" -> groupScores.foldLeft(0.0)(_ + _.informations.normalizedScore) / groupScores.length,
"letter" -> letterFromScore(groupScores.foldLeft(0.0)(_ + _.informations.score)),
"color" -> colorFromScore(groupScores.foldLeft(0.0)(_ + _.informations.score))
))
})

Results.Ok(Json.obj(
"groups" -> JsArray(jsonScores),
"plugins_instance" -> jsonScores.map(item => (item \ "plugins_instance").as[Double]).foldLeft(0.0)(_ + _) / jsonScores.length,
"produced_data" -> jsonScores.map(item => (item \ "produced_data").as[Double]).foldLeft(0.0)(_ + _) / jsonScores.length,
"produced_headers" -> jsonScores.map(item => (item \ "produced_headers").as[Double]).foldLeft(0.0)(_ + _) / jsonScores.length,
"architecture" -> SectionScoreHelper.mean(jsonScores.map(item => (item \ "architecture")
.as[SectionScore](SectionScoreHelper.format.reads))).json,
"design" -> SectionScoreHelper.mean(jsonScores.map(item => (item \ "design")
.as[SectionScore](SectionScoreHelper.format.reads))).json,
"usage" -> SectionScoreHelper.mean(jsonScores.map(item => (item \ "usage")
.as[SectionScore](SectionScoreHelper.format.reads))).json,
"log" -> SectionScoreHelper.mean(jsonScores.map(item => (item \ "log")
.as[SectionScore](SectionScoreHelper.format.reads))).json,
"score" -> jsonScores.map(item => (item \ "score").as[Double]).foldLeft(0.0)(_ + _) / jsonScores.length,
"normalized_score" -> jsonScores.map(item => (item \ "normalized_score").as[Double]).foldLeft(0.0)(_ + _) / jsonScores.length,
"letter" -> letterFromScore(jsonScores.map(item => (item \ "score").as[Double]).foldLeft(0.0)(_ + _)),
"color" -> colorFromScore(jsonScores.map(item => (item \ "score").as[Double]).foldLeft(0.0)(_ + _))
))
}
}
),
Expand Down
Loading

0 comments on commit 1b2681b

Please sign in to comment.