feat(admin): 서비스 실시간 지표 수집(actuator 집계)

- 7개 서비스 actuator 지표를 수집해 /admin/metrics/summary로 집계 제공(포탈 카드 지표 소스)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rtgs
2026-07-16 13:41:18 +09:00
parent 552d9d716c
commit 1180013506
2 changed files with 121 additions and 0 deletions

View File

@@ -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())
}

View File

@@ -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()
}
}