diff --git a/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/MetricsController.kt b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/MetricsController.kt new file mode 100644 index 0000000..9d79322 --- /dev/null +++ b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/MetricsController.kt @@ -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 = collector.snapshot(System.currentTimeMillis()) +} diff --git a/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceMetricsCollector.kt b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceMetricsCollector.kt new file mode 100644 index 0000000..91a7036 --- /dev/null +++ b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceMetricsCollector.kt @@ -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? = null + @Volatile private var cachedAt: Long = 0 + + /** 3초 캐시 스냅샷. */ + fun snapshot(now: Long): Map { + 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 { + 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 { + val m = linkedMapOf() + 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? = null): Double? = + statistic(base, name, "COUNT", tag) + + /** VALUE 통계(게이지: process.uptime, jvm.memory.used 등). */ + private fun value(base: String, name: String, tag: Pair? = 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? = 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 ?: return null + val measurements = body["measurements"] as? List> ?: return null + measurements.firstOrNull { it["statistic"] == stat }?.get("value")?.let { (it as? Number)?.toDouble() } + }.getOrNull() + } +}