feat(admin): 서비스 실시간 지표 수집(actuator 집계)
- 7개 서비스 actuator 지표를 수집해 /admin/metrics/summary로 집계 제공(포탈 카드 지표 소스) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package kr.or.bok.rtgs.louisvuitton
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 포탈 대시보드용 서비스 지표 집계 API. admin 하위 경로이므로 ADMIN JWT 인터셉터로 보호됨(AdminSecurity).
|
||||
*/
|
||||
@RestController
|
||||
class MetricsController(
|
||||
private val collector: ServiceMetricsCollector,
|
||||
) {
|
||||
/** 7개 서비스의 라이브 업무/부하 지표 스냅샷(3초 캐시). */
|
||||
@GetMapping("/admin/metrics/summary")
|
||||
fun metricsSummary(): Map<String, Any?> = collector.snapshot(System.currentTimeMillis())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package kr.or.bok.rtgs.louisvuitton
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
|
||||
/**
|
||||
* 포탈용 서비스 지표 수집기.
|
||||
*
|
||||
* 각 RTGS 서비스의 actuator `/actuator/metrics/{name}` JSON을 폴링해 업무/부하 지표를 모은다.
|
||||
* - DB/Kafka 미접근(순수 HTTP), 다운 서비스는 reachable=false 로만 표기(전체는 성공 반환).
|
||||
* - 대시보드 5초 폴링 대비 결과를 3초 메모이즈(과호출 방지).
|
||||
*/
|
||||
@Component
|
||||
class ServiceMetricsCollector {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/** 서비스명 → actuator 포트(로컬 단일 센터). */
|
||||
private val ports = linkedMapOf(
|
||||
"sequencer" to 8090, "chanel" to 8091, "dior" to 8092, "hermes" to 8093,
|
||||
"prada" to 8094, "gucci" to 8095, "louisvuitton" to 8099,
|
||||
)
|
||||
|
||||
private val http: RestClient = RestClient.builder()
|
||||
.requestFactory(SimpleClientHttpRequestFactory().apply {
|
||||
setConnectTimeout(500)
|
||||
setReadTimeout(800)
|
||||
})
|
||||
.build()
|
||||
|
||||
@Volatile private var cache: Map<String, Any?>? = null
|
||||
@Volatile private var cachedAt: Long = 0
|
||||
|
||||
/** 3초 캐시 스냅샷. */
|
||||
fun snapshot(now: Long): Map<String, Any?> {
|
||||
val c = cache
|
||||
if (c != null && now - cachedAt < 3000) return c
|
||||
val fresh = collect(now)
|
||||
cache = fresh; cachedAt = now
|
||||
return fresh
|
||||
}
|
||||
|
||||
private fun collect(now: Long): Map<String, Any?> {
|
||||
val services = ports.map { (svc, port) ->
|
||||
val base = "http://127.0.0.1:$port"
|
||||
val reachable = ping(base)
|
||||
val metrics = if (reachable) metricsFor(svc, base) else emptyMap()
|
||||
mapOf("service" to svc, "port" to port, "reachable" to reachable, "metrics" to metrics)
|
||||
}
|
||||
return mapOf("collectedAt" to now, "services" to services)
|
||||
}
|
||||
|
||||
/** 서비스별 관심 지표 수집. 업무지표 없는 서비스는 생존/부하만. */
|
||||
private fun metricsFor(svc: String, base: String): Map<String, Any?> {
|
||||
val m = linkedMapOf<String, Any?>()
|
||||
m["uptimeSec"] = value(base, "process.uptime")?.let { it.toLong() }
|
||||
m["httpRequests"] = count(base, "http.server.requests")?.toLong()
|
||||
m["memUsedMb"] = value(base, "jvm.memory.used")?.let { (it / 1_048_576).toLong() }
|
||||
when (svc) {
|
||||
"hermes" -> {
|
||||
m["settlementsAccc"] = count(base, "rtgs.settlements", "status" to "ACCC")?.toLong() ?: 0L
|
||||
m["settlementsRjct"] = count(base, "rtgs.settlements", "status" to "RJCT")?.toLong() ?: 0L
|
||||
val settleCnt = count(base, "rtgs.settle")
|
||||
val settleTotal = statistic(base, "rtgs.settle", "TOTAL_TIME")
|
||||
m["settleMeanMs"] = if (settleCnt != null && settleCnt > 0 && settleTotal != null)
|
||||
Math.round(settleTotal / settleCnt * 1000.0) / 1.0 else 0.0
|
||||
m["settleMaxMs"] = statistic(base, "rtgs.settle", "MAX")?.let { Math.round(it * 1000.0) / 1.0 } ?: 0.0
|
||||
m["journalGap"] = count(base, "rtgs.journal.gap")?.toLong() ?: 0L
|
||||
}
|
||||
"prada" -> {
|
||||
m["finalityAccc"] = count(base, "rtgs.finality", "status" to "ACCC")?.toLong() ?: 0L
|
||||
m["finalityRjct"] = count(base, "rtgs.finality", "status" to "RJCT")?.toLong() ?: 0L
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------- actuator /metrics 파싱 헬퍼 ----------
|
||||
|
||||
private fun ping(base: String): Boolean =
|
||||
runCatching { http.get().uri("$base/actuator/health").retrieve().toBodilessEntity() }.isSuccess
|
||||
|
||||
/** COUNT 통계(카운터/타이머 건수). tag 옵션(status:ACCC 등). */
|
||||
private fun count(base: String, name: String, tag: Pair<String, String>? = null): Double? =
|
||||
statistic(base, name, "COUNT", tag)
|
||||
|
||||
/** VALUE 통계(게이지: process.uptime, jvm.memory.used 등). */
|
||||
private fun value(base: String, name: String, tag: Pair<String, String>? = null): Double? =
|
||||
statistic(base, name, "VALUE", tag)
|
||||
|
||||
/** 지정 statistic(COUNT/VALUE/TOTAL_TIME/MAX)의 measurement 값. 실패 시 null. */
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun statistic(base: String, name: String, stat: String, tag: Pair<String, String>? = null): Double? {
|
||||
val uri = buildString {
|
||||
append(base).append("/actuator/metrics/").append(name)
|
||||
if (tag != null) append("?tag=").append(tag.first).append(":").append(tag.second)
|
||||
}
|
||||
return runCatching {
|
||||
val body = http.get().uri(uri).retrieve().body(Map::class.java) as? Map<String, Any?> ?: return null
|
||||
val measurements = body["measurements"] as? List<Map<String, Any?>> ?: return null
|
||||
measurements.firstOrNull { it["statistic"] == stat }?.get("value")?.let { (it as? Number)?.toDouble() }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user