diff --git a/src/main/java/ext/Ext.kt b/src/main/java/ext/Ext.kt index bde47a10..b7e77c65 100644 --- a/src/main/java/ext/Ext.kt +++ b/src/main/java/ext/Ext.kt @@ -5,9 +5,6 @@ import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* -import io.micrometer.core.instrument.* -import io.micrometer.core.instrument.Timer -import io.micrometer.core.instrument.Metrics as Micrometer import jakarta.persistence.Query import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -25,7 +22,6 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.* -import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KCallable import kotlin.reflect.KClass import kotlin.reflect.KMutableProperty1 @@ -167,6 +163,7 @@ val Any?.truthy get() = when (this) { // Collections fun ls(vararg args: T) = args.toList() +inline fun arr(vararg args: T) = arrayOf(*args) operator fun Map.plus(map: Map) = (if (this is MutableMap) this else toMutableMap()).apply { putAll(map) } operator fun MutableMap.plusAssign(map: Map) { putAll(map) } @@ -218,45 +215,3 @@ val Pair<*, S>.r get() = component2() // Database val Query.exec get() = resultList.map { (it as Array<*>).toList() } - -// Metrics -object Metrics { - @PublishedApi - internal inline fun expandLabels(labels: T): Array { - return T::class.memberProperties.flatMap { prop -> - listOf(prop.name, prop.get(labels)?.toString() ?: throw IllegalArgumentException("Missing value for label ${prop.name}")) - }.toTypedArray() - } - - @PublishedApi - internal data class MetricCacheKey(val metricName: String, val labels: Any) - - @PublishedApi - internal val counterCache = ConcurrentHashMap() - - inline fun counter(metricName: String): (T) -> Counter { - return { labels -> - counterCache.computeIfAbsent(MetricCacheKey(metricName, labels)) { - Counter - .builder(metricName) - .tags(*expandLabels(labels)) - .register(Micrometer.globalRegistry) - } - } - } - - @PublishedApi - internal val timerCache = ConcurrentHashMap() - - inline fun timer(metricName: String): (T) -> Timer { - return { labels -> - timerCache.computeIfAbsent(MetricCacheKey(metricName, labels)) { - Timer - .builder(metricName) - .publishPercentiles(0.5, 0.75, 0.90, 0.95, 0.99) - .tags(*expandLabels(labels)) - .register(Micrometer.globalRegistry) - } - } - } -} diff --git a/src/main/java/icu/samnyan/aqua/net/db/Prometheus.kt b/src/main/java/icu/samnyan/aqua/net/db/Prometheus.kt new file mode 100644 index 00000000..260189e6 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/net/db/Prometheus.kt @@ -0,0 +1,47 @@ +package icu.samnyan.aqua.net.db + +import ext.arr +import icu.samnyan.aqua.net.utils.ApiException +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.Timer +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.TimeSource +import kotlin.time.toJavaDuration +import io.micrometer.core.instrument.Metrics as MMetrics + +operator fun Counter.unaryPlus() = increment() + +class APICounter(val api: String, val metrics: APIMetrics) { + operator fun unaryPlus() = +metrics["api_count", arr("api", api)] + + operator fun rem(err: Exception) = also { + val e = if (err is ApiException) err.code.toString() else err.javaClass.simpleName + +metrics["api_error_count", arr("api", api, "error", e)] + } + + operator fun invoke(fn: () -> T): T { + val start = TimeSource.Monotonic.markNow() + try { return fn().also { +this } } + catch (e: Exception) { throw e.also { this % e } } + finally { metrics.timer("api_latency", arr("api", api)).record(start.elapsedNow().toJavaDuration()) } + } +} + +class APIMetrics(val domain: String) { + val cache = ConcurrentHashMap, Any>() + val reg = MMetrics.globalRegistry + + operator fun get(name: String, vararg pairs: Pair) = + get(name, pairs.flatMap { listOf(it.first, it.second.toString()) }.toTypedArray()) + + operator fun get(name: String, tag: Array) = cache.computeIfAbsent(tag) { + Counter.builder("aquadx_${domain}_$name").tags(*tag).register(reg) + } as Counter + + fun timer(name: String, tag: Array) = cache.computeIfAbsent(tag) { + Timer.builder("aquadx_${domain}_$name").tags(*tag).publishPercentiles(0.5, 0.75, 0.90, 0.95, 0.99).register(reg) + } as Timer + + operator fun get(api: String) = APICounter(api, this) + operator fun set(api: String, value: APICounter) {} +} diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanServletController.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanServletController.kt index f4fc9211..d2a637ff 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanServletController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanServletController.kt @@ -1,15 +1,13 @@ package icu.samnyan.aqua.sega.chusan import ext.* +import icu.samnyan.aqua.net.db.APIMetrics import icu.samnyan.aqua.sega.chunithm.handler.impl.GetGameIdlistHandler import icu.samnyan.aqua.sega.chusan.handler.* import icu.samnyan.aqua.sega.general.BaseHandler -import io.micrometer.core.instrument.Timer import org.slf4j.LoggerFactory import org.springframework.web.bind.annotation.* import kotlin.reflect.full.declaredMemberProperties -import kotlin.time.TimeSource -import kotlin.time.toJavaDuration /** @@ -73,11 +71,7 @@ class ChusanServletController( val getUserNetBattleRankingInfo: GetUserNetBattleRankingInfoHandler, val getGameMapAreaCondition: GetGameMapAreaConditionHandler ) { - data class ApiLabel(val api: String) - data class ApiErrorLabel(val api: String, val error: String) - val apiCountMetric = Metrics.counter("aquadx_chusan_api_count") - val apiErrorCountMetric = Metrics.counter("aquadx_chusan_api_error_count") - val apiLatencyMetric = Metrics.timer("aquadx_chusan_api_latency") + val metrics = APIMetrics("chusan") val logger = LoggerFactory.getLogger(ChusanServletController::class.java) @@ -119,34 +113,25 @@ class ChusanServletController( @API("/{endpoint}") fun handle(@PV endpoint: Str, @RB request: MutableMap, @PV version: Str): Any { var api = endpoint - val startTime = TimeSource.Monotonic.markNow() - var timer: Timer? = null - try { - request["version"] = version + request["version"] = version - // Export version - if (api.endsWith("C3Exp")) { - api = api.removeSuffix("C3Exp") - request["c3exp"] = true - } + // Export version + if (api.endsWith("C3Exp")) { + api = api.removeSuffix("C3Exp") + request["c3exp"] = true + } - apiCountMetric(ApiLabel(api)).increment() - logger.info("Chu3 $api : $request") + logger.info("Chu3 $api : $request") - if (api in noopEndpoint) { - return """{"returnCode":"1"}""" - } + if (api in noopEndpoint) { + return """{"returnCode":"1"}""" + } - timer = apiLatencyMetric(ApiLabel(api)) - return handlers[api]?.handle(request) ?: { + return metrics[api] { + handlers[api]?.handle(request) ?: { logger.warn("Chu3 $api not found") """{"returnCode":"1","apiName":"$api"}""" } - } catch (e: Exception) { - apiErrorCountMetric(ApiErrorLabel(api, e.javaClass.name)).increment() - throw e; - } finally { - timer?.record(startTime.elapsedNow().toJavaDuration()) } } } diff --git a/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt b/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt index 8e6f695e..d883a6fb 100644 --- a/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt +++ b/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt @@ -1,7 +1,6 @@ package icu.samnyan.aqua.sega.general import com.fasterxml.jackson.core.JsonProcessingException -import icu.samnyan.aqua.sega.allnet.KeychipSession /** * @author samnyan (privateamusement@protonmail.com) diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt index dc86ee00..0f1638de 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt @@ -1,22 +1,17 @@ package icu.samnyan.aqua.sega.maimai2 import ext.* +import icu.samnyan.aqua.net.db.APIMetrics import icu.samnyan.aqua.net.utils.ApiException -import icu.samnyan.aqua.sega.allnet.KeychipSession import icu.samnyan.aqua.sega.general.BaseHandler import icu.samnyan.aqua.sega.maimai2.handler.* import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import io.ktor.client.request.* -import io.micrometer.core.instrument.Timer -import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlin.reflect.full.declaredMemberProperties -import kotlin.time.TimeSource -import kotlin.time.toJavaDuration /** * @author samnyan (privateamusement@protonmail.com) @@ -46,11 +41,7 @@ class Maimai2ServletController( private val GAME_SETTING_TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:00") } - data class ApiLabel(val api: String) - data class ApiErrorLabel(val api: String, val error: String) - val apiCountMetric = Metrics.counter("aquadx_maimai2_api_count") - val apiErrorCountMetric = Metrics.counter("aquadx_maimai2_api_error_count") - val apiLatencyMetric = Metrics.timer("aquadx_maimai2_api_latency") + val metrics = APIMetrics("maimai2") val getUserExtend = UserReqHandler { _, userId -> mapOf( "userId" to userId, @@ -351,41 +342,32 @@ class Maimai2ServletController( @API("/{api}") fun handle(@PathVariable api: String, @RequestBody request: Map): Any { - val startTime = TimeSource.Monotonic.markNow() - var timer: Timer? = null - try { - apiCountMetric(ApiLabel(api)).increment() - logger.info("Mai2 < $api : ${request.toJson()}") // TODO: Optimize logging + logger.info("Mai2 < $api : ${request.toJson()}") // TODO: Optimize logging - if (api in noopEndpoint) { - logger.info("Mai2 > $api no-op") - return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}""" - } + if (api in noopEndpoint) { + logger.info("Mai2 > $api no-op") + return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}""" + } - if (api in staticEndpoint) { - logger.info("Mai2 > $api static") - return staticEndpoint[api]!! - } + if (api in staticEndpoint) { + logger.info("Mai2 > $api static") + return staticEndpoint[api]!! + } - timer = apiLatencyMetric(ApiLabel(api)) - return handlers[api]?.handle(request)?.let { if (it is String) it else it.toJson() }?.also { + if (!handlers.containsKey(api)) { + logger.warn("Mai2 > $api not found") + return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}""" + } + + return try { metrics[api] { + handlers[api]!!.handle(request).let { if (it is String) it else it.toJson() }.also { if (api !in setOf("GetUserItemApi", "GetGameEventApi")) logger.info("Mai2 > $api : $it") - } ?: { - logger.warn("Mai2 > $api not found") - """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}""" } - } catch (e: Exception) { - if (e is ApiException) { - apiErrorCountMetric(ApiErrorLabel(api, e.code.toString())).increment() - // It's a bad practice to return 200 ok on error, but this is what maimai does so we have to follow - return ResponseEntity.ok().body("""{"returnCode":0,"apiName":"com.sega.maimai2servlet.api.$api","message":"${e.message?.replace("\"", "\\\"")} - ${e.code}"}""") - } else { - apiErrorCountMetric(ApiErrorLabel(api, e.javaClass.name)).increment() - throw e; - } - } finally { - timer?.record(startTime.elapsedNow().toJavaDuration()) + } } + catch (e: ApiException) { + // It's a bad practice to return 200 ok on error, but this is what maimai does so we have to follow + return ResponseEntity.ok().body("""{"returnCode":0,"apiName":"com.sega.maimai2servlet.api.$api","message":"${e.message?.replace("\"", "\\\"")} - ${e.code}"}""") } } } diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt index e2c351f5..64de61f3 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/UploadUserPlaylogHandler.kt @@ -1,12 +1,12 @@ package icu.samnyan.aqua.sega.maimai2.handler -import ext.Metrics import ext.millis +import icu.samnyan.aqua.net.db.APIMetrics +import icu.samnyan.aqua.net.db.unaryPlus import icu.samnyan.aqua.sega.allnet.TokenChecker +import icu.samnyan.aqua.sega.general.BaseHandler import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo import icu.samnyan.aqua.sega.maimai2.model.Mai2UserPlaylogRepo -import icu.samnyan.aqua.sega.general.BaseHandler -import icu.samnyan.aqua.sega.maimai2.Maimai2ServletController.ApiLabel import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPlaylog import icu.samnyan.aqua.sega.maimai2.model.userdata.Mai2UserPlaylog import icu.samnyan.aqua.sega.util.jackson.BasicMapper @@ -31,8 +31,7 @@ class UploadUserPlaylogHandler( val VALID_GAME_IDS = setOf("SDEZ", "SDGA", "SDGB") } - data class GameIdVersionLabel(val gameId: String, val version: String) - val gameVersionCountMetric = Metrics.counter("aquadx_maimai2_game_version_count") + val metrics = APIMetrics("maimai2") override fun handle(request: Map): String { val req = mapper.convert(request, UploadUserPlaylog::class.java) @@ -41,7 +40,7 @@ class UploadUserPlaylogHandler( if (version != null) { val session = TokenChecker.getCurrentSession() val gameId = if (session?.gameId in VALID_GAME_IDS) session!!.gameId else "" - gameVersionCountMetric(GameIdVersionLabel(gameId, version)).increment() + +metrics["game_version_count", "game_id" to gameId, "version" to version] } // Save if the user is registered diff --git a/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt b/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt index 9b3d61ce..76265c03 100644 --- a/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt +++ b/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt @@ -1,6 +1,7 @@ package icu.samnyan.aqua.sega.wacca import ext.* +import icu.samnyan.aqua.net.db.APIMetrics import icu.samnyan.aqua.net.db.AquaGameOptions import icu.samnyan.aqua.net.games.wacca.Wacca import icu.samnyan.aqua.net.utils.ApiException @@ -13,7 +14,6 @@ import icu.samnyan.aqua.sega.wacca.WaccaOptionType.* import icu.samnyan.aqua.sega.wacca.model.BaseRequest import icu.samnyan.aqua.sega.wacca.model.db.* import io.ktor.client.utils.* -import io.micrometer.core.instrument.Timer import jakarta.servlet.http.HttpServletRequest import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.ResponseEntity @@ -21,8 +21,6 @@ import org.springframework.web.bind.annotation.RestController import java.util.* import kotlin.math.max import kotlin.math.min -import kotlin.time.TimeSource -import kotlin.time.toJavaDuration val empty = emptyList() @@ -35,11 +33,7 @@ class WaccaServer { @Autowired lateinit var rp: WaccaRepos @Autowired lateinit var wacca: Wacca - data class ApiLabel(val api: String) - data class ApiErrorLabel(val api: String, val error: String) - val apiCountMetric = Metrics.counter("aquadx_wacca_api_count") - val apiErrorCountMetric = Metrics.counter("aquadx_wacca_api_error_count") - val apiLatencyMetric = Metrics.timer("aquadx_wacca_api_latency") + val metrics = APIMetrics("wacca") val handlerMap = mutableMapOf) -> Any>() val cacheMap = mutableMapOf() @@ -88,38 +82,28 @@ class WaccaServer { /** Handle all requests */ @API("/api/**") fun handle(req: HttpServletRequest, @RB body: String): Any { - val startTime = TimeSource.Monotonic.markNow() - var timer: Timer? = null - var api = "" - return try { - val path = req.requestURI.removePrefix("/g/wacca").removePrefix("/WaccaServlet") - .removePrefix("/api").removePrefix("/").lowercase() + val path = req.requestURI.removePrefix("/g/wacca").removePrefix("/WaccaServlet") + .removePrefix("/api").removePrefix("/").lowercase() - if (path !in cacheMap && path !in handlerMap) return resp("[]", 1, "Not Found") - api = path - apiCountMetric(ApiLabel(api)).increment() - if (path in cacheMap) return resp(cacheMap[path]!!) + if (path in cacheMap) return resp(cacheMap[path]!!) + else if (path !in handlerMap) return resp("[]", 1, "Not Found") - log.info("Wacca < $path : $body") + log.info("Wacca < $path : $body") - timer = apiLatencyMetric(ApiLabel(api)) + return try { metrics[path] { val br = JACKSON.parse(body) handlerMap[path]!!(br, br.params).let { when (it) { is String -> resp(it) is List<*> -> resp(it.toJson()) else -> error("Invalid response type ${it.javaClass}") } }.also { log.info("Wacca > $path : ${it.body}") } - } + } } catch (e: ApiException) { - apiErrorCountMetric(ApiErrorLabel(api, e.code.toString())).increment() resp("[]", e.code, e.message ?: "") } catch (e: Exception) { - apiErrorCountMetric(ApiErrorLabel(api, e.javaClass.name)).increment() log.error("Wacca > Error", e) resp("[]", 500, e.message ?: "") - } finally { - timer?.record(startTime.elapsedNow().toJavaDuration()) } } }