feat: API/WS 쿼리 메트릭 이력 조회 기능 구현
- QueryMetricsBufferService: ConcurrentLinkedQueue + 10초 batch flush - GisServiceV2: REST API 메트릭 수집 추가 - ChunkedTrackStreamingService: saveAsync → buffer.enqueue 전환 - QueryMetricsController: /history (페이지네이션+필터), /summary (P95 포함) - ApiMetrics.tsx: 요약카드 + 버튼그룹 필터 + 서버사이드 DataTable + 30s 폴링 - DataTable: server-side pagination props 확장 (하위 호환)
This commit is contained in:
부모
b16ceddf10
커밋
a0f24d5757
@ -6,6 +6,9 @@ import type {
|
|||||||
HaeguStat,
|
HaeguStat,
|
||||||
MetricsSummary,
|
MetricsSummary,
|
||||||
ProcessingDelay,
|
ProcessingDelay,
|
||||||
|
QueryMetricsPage,
|
||||||
|
QueryMetricsParams,
|
||||||
|
QueryMetricsSummary,
|
||||||
ThroughputMetrics,
|
ThroughputMetrics,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
|
||||||
@ -45,4 +48,22 @@ export const monitorApi = {
|
|||||||
getHaeguStats(): Promise<Record<string, unknown>[]> {
|
getHaeguStats(): Promise<Record<string, unknown>[]> {
|
||||||
return fetchJson('/admin/haegu/stats')
|
return fetchJson('/admin/haegu/stats')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getQueryMetricsHistory(params: QueryMetricsParams): Promise<QueryMetricsPage> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.queryType) qs.set('queryType', params.queryType)
|
||||||
|
if (params.dataPath) qs.set('dataPath', params.dataPath)
|
||||||
|
if (params.status) qs.set('status', params.status)
|
||||||
|
if (params.elapsedMsMin != null) qs.set('elapsedMsMin', String(params.elapsedMsMin))
|
||||||
|
if (params.elapsedMsMax != null) qs.set('elapsedMsMax', String(params.elapsedMsMax))
|
||||||
|
qs.set('page', String(params.page ?? 0))
|
||||||
|
qs.set('size', String(params.size ?? 20))
|
||||||
|
qs.set('sortBy', params.sortBy ?? 'created_at')
|
||||||
|
qs.set('sortDir', params.sortDir ?? 'desc')
|
||||||
|
return fetchJson(`/api/monitoring/query-metrics/history?${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getQueryMetricsSummary(hours = 24): Promise<QueryMetricsSummary> {
|
||||||
|
return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,6 +187,65 @@ export interface ThroughputMetrics {
|
|||||||
partitionSizes: PartitionSize[]
|
partitionSizes: PartitionSize[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Query Metrics (쿼리 이력) */
|
||||||
|
|
||||||
|
export interface QueryMetricRow {
|
||||||
|
query_id: string
|
||||||
|
query_type: string
|
||||||
|
created_at: string
|
||||||
|
data_path: string
|
||||||
|
status: string
|
||||||
|
zoom_level: number | null
|
||||||
|
requested_mmsi: number
|
||||||
|
unique_vessels: number
|
||||||
|
total_points: number
|
||||||
|
points_after_simplify: number
|
||||||
|
total_chunks: number
|
||||||
|
response_bytes: number
|
||||||
|
elapsed_ms: number
|
||||||
|
db_query_ms: number
|
||||||
|
simplify_ms: number
|
||||||
|
cache_hit_days: number
|
||||||
|
db_query_days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryMetricsPage {
|
||||||
|
content: QueryMetricRow[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
currentPage: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryMetricsSummary {
|
||||||
|
total_queries: number
|
||||||
|
avg_elapsed_ms: number
|
||||||
|
p95_elapsed_ms: number
|
||||||
|
max_elapsed_ms: number
|
||||||
|
ws_count: number
|
||||||
|
rest_count: number
|
||||||
|
cache_only_count: number
|
||||||
|
db_only_count: number
|
||||||
|
hybrid_count: number
|
||||||
|
completed_count: number
|
||||||
|
failed_count: number
|
||||||
|
avg_vessels: number
|
||||||
|
avg_points_before: number
|
||||||
|
avg_points_after: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryMetricsParams {
|
||||||
|
queryType?: string
|
||||||
|
dataPath?: string
|
||||||
|
status?: string
|
||||||
|
elapsedMsMin?: number
|
||||||
|
elapsedMsMax?: number
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
sortBy?: string
|
||||||
|
sortDir?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
/* Monitor — Data Quality */
|
/* Monitor — Data Quality */
|
||||||
|
|
||||||
export interface DataQuality {
|
export interface DataQuality {
|
||||||
|
|||||||
@ -16,6 +16,10 @@ interface DataTableProps<T> {
|
|||||||
onRowClick?: (row: T) => void
|
onRowClick?: (row: T) => void
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
// Server-side pagination (optional)
|
||||||
|
totalElements?: number
|
||||||
|
currentPage?: number
|
||||||
|
onPageChange?: (page: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataTable<T>({
|
export default function DataTable<T>({
|
||||||
@ -25,14 +29,19 @@ export default function DataTable<T>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
|
totalElements,
|
||||||
|
currentPage,
|
||||||
|
onPageChange,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortAsc, setSortAsc] = useState(true)
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const isServerSide = totalElements != null && currentPage != null && onPageChange != null
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (!sortKey) return data
|
if (isServerSide || !sortKey) return data
|
||||||
return [...data].sort((a, b) => {
|
return [...data].sort((a, b) => {
|
||||||
const av = (a as Record<string, unknown>)[sortKey]
|
const av = (a as Record<string, unknown>)[sortKey]
|
||||||
const bv = (b as Record<string, unknown>)[sortKey]
|
const bv = (b as Record<string, unknown>)[sortKey]
|
||||||
@ -40,10 +49,12 @@ export default function DataTable<T>({
|
|||||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0
|
const cmp = av < bv ? -1 : av > bv ? 1 : 0
|
||||||
return sortAsc ? cmp : -cmp
|
return sortAsc ? cmp : -cmp
|
||||||
})
|
})
|
||||||
}, [data, sortKey, sortAsc])
|
}, [data, sortKey, sortAsc, isServerSide])
|
||||||
|
|
||||||
const totalPages = Math.ceil(sorted.length / pageSize)
|
const effectivePage = isServerSide ? currentPage! : page
|
||||||
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize)
|
const total = isServerSide ? totalElements! : sorted.length
|
||||||
|
const totalPages = Math.ceil(total / pageSize)
|
||||||
|
const paged = isServerSide ? sorted : sorted.slice(effectivePage * pageSize, (effectivePage + 1) * pageSize)
|
||||||
|
|
||||||
const handleSort = (key: string) => {
|
const handleSort = (key: string) => {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
@ -54,6 +65,14 @@ export default function DataTable<T>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
if (isServerSide) {
|
||||||
|
onPageChange!(newPage)
|
||||||
|
} else {
|
||||||
|
setPage(newPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="sb-table-wrapper">
|
<div className="sb-table-wrapper">
|
||||||
@ -67,7 +86,7 @@ export default function DataTable<T>({
|
|||||||
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
|
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
{sortKey === col.key && (sortAsc ? ' \u25B2' : ' \u25BC')}
|
{sortKey === col.key && (sortAsc ? ' ▲' : ' ▼')}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@ -102,19 +121,19 @@ export default function DataTable<T>({
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-3 flex items-center justify-between text-sm text-muted">
|
<div className="mt-3 flex items-center justify-between text-sm text-muted">
|
||||||
<span>
|
<span>
|
||||||
{sorted.length}{t('common.items')} {t('common.of')} {page * pageSize + 1}-{Math.min((page + 1) * pageSize, sorted.length)}
|
{total}{t('common.items')} {t('common.of')} {effectivePage * pageSize + 1}-{Math.min((effectivePage + 1) * pageSize, total)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
onClick={() => handlePageChange(Math.max(0, effectivePage - 1))}
|
||||||
disabled={page === 0}
|
disabled={effectivePage === 0}
|
||||||
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{t('common.prev')}
|
{t('common.prev')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
onClick={() => handlePageChange(Math.min(totalPages - 1, effectivePage + 1))}
|
||||||
disabled={page >= totalPages - 1}
|
disabled={effectivePage >= totalPages - 1}
|
||||||
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{t('common.next')}
|
{t('common.next')}
|
||||||
|
|||||||
@ -170,8 +170,24 @@ const en = {
|
|||||||
'metrics.cacheHitSummary': 'Cache Hit Summary',
|
'metrics.cacheHitSummary': 'Cache Hit Summary',
|
||||||
'metrics.hits': 'Hits',
|
'metrics.hits': 'Hits',
|
||||||
'metrics.misses': 'Misses',
|
'metrics.misses': 'Misses',
|
||||||
'metrics.dbMetricsPlaceholder': 'API/WS History Metrics (Coming Soon)',
|
'metrics.queryHistory': 'Query History',
|
||||||
'metrics.dbMetricsDesc': 'REST/WebSocket request history, response sizes, latency DB storage + query',
|
'metrics.totalQueries': 'Total Queries',
|
||||||
|
'metrics.avgElapsed': 'Avg Response',
|
||||||
|
'metrics.p95Elapsed': 'P95 Response',
|
||||||
|
'metrics.cacheHitRate': 'Cache Hit Rate',
|
||||||
|
'metrics.queryType': 'Type',
|
||||||
|
'metrics.dataPath': 'Path',
|
||||||
|
'metrics.queryStatus': 'Status',
|
||||||
|
'metrics.queryTime': 'Time',
|
||||||
|
'metrics.vessels': 'Vessels',
|
||||||
|
'metrics.pointsBefore': 'Points(Before)',
|
||||||
|
'metrics.pointsAfter': 'Points(After)',
|
||||||
|
'metrics.simplification': 'Reduction',
|
||||||
|
'metrics.chunks': 'Chunks',
|
||||||
|
'metrics.elapsed': 'Elapsed',
|
||||||
|
'metrics.allTypes': 'All',
|
||||||
|
'metrics.allPaths': 'All',
|
||||||
|
'metrics.resetFilters': 'Reset Filters',
|
||||||
|
|
||||||
// Time Range
|
// Time Range
|
||||||
'range.1d': '1D',
|
'range.1d': '1D',
|
||||||
|
|||||||
@ -170,8 +170,24 @@ const ko = {
|
|||||||
'metrics.cacheHitSummary': '캐시 히트 요약',
|
'metrics.cacheHitSummary': '캐시 히트 요약',
|
||||||
'metrics.hits': '히트',
|
'metrics.hits': '히트',
|
||||||
'metrics.misses': '미스',
|
'metrics.misses': '미스',
|
||||||
'metrics.dbMetricsPlaceholder': 'API/WS 이력 메트릭 (향후 구현)',
|
'metrics.queryHistory': '쿼리 이력',
|
||||||
'metrics.dbMetricsDesc': 'REST/WebSocket 요청 이력, 응답 크기, 소요시간 DB 저장 + 조회',
|
'metrics.totalQueries': '총 쿼리',
|
||||||
|
'metrics.avgElapsed': '평균 응답',
|
||||||
|
'metrics.p95Elapsed': 'P95 응답',
|
||||||
|
'metrics.cacheHitRate': '캐시 적중률',
|
||||||
|
'metrics.queryType': '유형',
|
||||||
|
'metrics.dataPath': '경로',
|
||||||
|
'metrics.queryStatus': '상태',
|
||||||
|
'metrics.queryTime': '시각',
|
||||||
|
'metrics.vessels': '선박',
|
||||||
|
'metrics.pointsBefore': '포인트(전)',
|
||||||
|
'metrics.pointsAfter': '포인트(후)',
|
||||||
|
'metrics.simplification': '간소화',
|
||||||
|
'metrics.chunks': '청크',
|
||||||
|
'metrics.elapsed': '응답시간',
|
||||||
|
'metrics.allTypes': '전체',
|
||||||
|
'metrics.allPaths': '전체',
|
||||||
|
'metrics.resetFilters': '필터 초기화',
|
||||||
|
|
||||||
// Time Range
|
// Time Range
|
||||||
'range.1d': '1일',
|
'range.1d': '1일',
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
import { usePoller } from '../hooks/usePoller.ts'
|
import { usePoller } from '../hooks/usePoller.ts'
|
||||||
import { useCachedState } from '../hooks/useCachedState.ts'
|
import { useCachedState } from '../hooks/useCachedState.ts'
|
||||||
import { useI18n } from '../hooks/useI18n.ts'
|
import { useI18n } from '../hooks/useI18n.ts'
|
||||||
import { monitorApi } from '../api/monitorApi.ts'
|
import { monitorApi } from '../api/monitorApi.ts'
|
||||||
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails } from '../api/types.ts'
|
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails, QueryMetricsPage, QueryMetricsSummary, QueryMetricsParams, QueryMetricRow } from '../api/types.ts'
|
||||||
import MetricCard from '../components/charts/MetricCard.tsx'
|
import MetricCard from '../components/charts/MetricCard.tsx'
|
||||||
|
import DataTable, { type Column } from '../components/common/DataTable.tsx'
|
||||||
import { formatNumber } from '../utils/formatters.ts'
|
import { formatNumber } from '../utils/formatters.ts'
|
||||||
|
|
||||||
const POLL_INTERVAL = 10_000
|
const POLL_INTERVAL = 10_000
|
||||||
|
const QUERY_POLL_INTERVAL = 30_000
|
||||||
|
|
||||||
|
const ELAPSED_RANGES = [
|
||||||
|
{ label: '< 1s', min: undefined, max: 999 },
|
||||||
|
{ label: '1-5s', min: 1000, max: 5000 },
|
||||||
|
{ label: '5-30s', min: 5000, max: 30000 },
|
||||||
|
{ label: '> 30s', min: 30000, max: undefined },
|
||||||
|
] as const
|
||||||
|
|
||||||
export default function ApiMetrics() {
|
export default function ApiMetrics() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -15,6 +25,13 @@ export default function ApiMetrics() {
|
|||||||
const [cacheDetails, setCacheDetails] = useCachedState<CacheDetails | null>('api.cacheDetail', null)
|
const [cacheDetails, setCacheDetails] = useCachedState<CacheDetails | null>('api.cacheDetail', null)
|
||||||
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('api.delay', null)
|
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('api.delay', null)
|
||||||
|
|
||||||
|
// Query History state
|
||||||
|
const [filter, setFilter] = useState<QueryMetricsParams>({
|
||||||
|
page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc',
|
||||||
|
})
|
||||||
|
const [historyData, setHistoryData] = useState<QueryMetricsPage | null>(null)
|
||||||
|
const [summaryData, setSummaryData] = useState<QueryMetricsSummary | null>(null)
|
||||||
|
|
||||||
usePoller(() => {
|
usePoller(() => {
|
||||||
monitorApi.getMetricsSummary().then(setMetrics).catch(() => {})
|
monitorApi.getMetricsSummary().then(setMetrics).catch(() => {})
|
||||||
monitorApi.getCacheStats().then(setCache).catch(() => {})
|
monitorApi.getCacheStats().then(setCache).catch(() => {})
|
||||||
@ -22,10 +39,89 @@ export default function ApiMetrics() {
|
|||||||
monitorApi.getDelay().then(setDelay).catch(() => {})
|
monitorApi.getDelay().then(setDelay).catch(() => {})
|
||||||
}, POLL_INTERVAL)
|
}, POLL_INTERVAL)
|
||||||
|
|
||||||
|
const fetchQueryData = useCallback(() => {
|
||||||
|
monitorApi.getQueryMetricsHistory(filter).then(setHistoryData).catch(() => {})
|
||||||
|
monitorApi.getQueryMetricsSummary(24).then(setSummaryData).catch(() => {})
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
usePoller(fetchQueryData, QUERY_POLL_INTERVAL, [filter])
|
||||||
|
|
||||||
|
const updateFilter = (patch: Partial<QueryMetricsParams>) => {
|
||||||
|
setFilter(prev => ({ ...prev, page: 0, ...patch }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilter({ page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc' })
|
||||||
|
}
|
||||||
|
|
||||||
const memUsed = metrics?.memory.used ?? 0
|
const memUsed = metrics?.memory.used ?? 0
|
||||||
const memMax = metrics?.memory.max ?? 1
|
const memMax = metrics?.memory.max ?? 1
|
||||||
const memPct = Math.round((memUsed / memMax) * 100)
|
const memPct = Math.round((memUsed / memMax) * 100)
|
||||||
|
|
||||||
|
// Summary computed values
|
||||||
|
const totalQueries = summaryData?.total_queries ?? 0
|
||||||
|
const cacheHitRate = totalQueries > 0
|
||||||
|
? ((summaryData?.cache_only_count ?? 0) / totalQueries * 100).toFixed(1)
|
||||||
|
: '0.0'
|
||||||
|
|
||||||
|
const historyColumns: Column<QueryMetricRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'created_at', label: t('metrics.queryTime'), sortable: false,
|
||||||
|
render: (row) => {
|
||||||
|
const ts = row.created_at ?? ''
|
||||||
|
return ts.length >= 19 ? ts.substring(5, 19) : ts
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'query_type', label: t('metrics.queryType'), sortable: false,
|
||||||
|
render: (row) => {
|
||||||
|
const isWs = row.query_type === 'WEBSOCKET'
|
||||||
|
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${isWs ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'}`}>{isWs ? 'WS' : 'REST'}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'data_path', label: t('metrics.dataPath'), sortable: false,
|
||||||
|
render: (row) => {
|
||||||
|
const path = row.data_path ?? ''
|
||||||
|
const color = path === 'CACHE' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
|
||||||
|
: path === 'DB' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'
|
||||||
|
: 'bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300'
|
||||||
|
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${color}`}>{path}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status', label: t('metrics.queryStatus'), sortable: false,
|
||||||
|
render: (row) => {
|
||||||
|
const ok = row.status === 'COMPLETED'
|
||||||
|
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${ok ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'}`}>{row.status}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'unique_vessels', label: t('metrics.vessels'), align: 'right' as const, sortable: false,
|
||||||
|
render: (row) => formatNumber(row.unique_vessels) },
|
||||||
|
{ key: 'total_points', label: t('metrics.pointsBefore'), align: 'right' as const, sortable: false,
|
||||||
|
render: (row) => formatNumber(row.total_points) },
|
||||||
|
{ key: 'points_after_simplify', label: t('metrics.pointsAfter'), align: 'right' as const, sortable: false,
|
||||||
|
render: (row) => formatNumber(row.points_after_simplify) },
|
||||||
|
{
|
||||||
|
key: 'reduction', label: t('metrics.simplification'), align: 'right' as const, sortable: false,
|
||||||
|
render: (row) => {
|
||||||
|
const before = row.total_points || 0
|
||||||
|
const after = row.points_after_simplify || 0
|
||||||
|
if (before === 0) return '-'
|
||||||
|
return `${((1 - after / before) * 100).toFixed(0)}%`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'total_chunks', label: t('metrics.chunks'), align: 'right' as const, sortable: false },
|
||||||
|
{
|
||||||
|
key: 'elapsed_ms', label: t('metrics.elapsed'), align: 'right' as const, sortable: false,
|
||||||
|
render: (row) => {
|
||||||
|
const ms = row.elapsed_ms || 0
|
||||||
|
const color = ms < 1000 ? 'text-success' : ms < 5000 ? 'text-warning' : 'text-danger'
|
||||||
|
return <span className={`font-mono font-medium ${color}`}>{ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 fade-in">
|
<div className="space-y-6 fade-in">
|
||||||
<h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
|
<h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
|
||||||
@ -178,12 +274,114 @@ export default function ApiMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder for future DB-based metrics */}
|
{/* Query History Section */}
|
||||||
<div className="sb-card border-dashed">
|
<div className="sb-card">
|
||||||
<div className="py-6 text-center text-sm text-muted">
|
<div className="sb-card-header">{t('metrics.queryHistory')}</div>
|
||||||
<p>{t('metrics.dbMetricsPlaceholder')}</p>
|
|
||||||
<p className="mt-1 text-xs opacity-60">{t('metrics.dbMetricsDesc')}</p>
|
{/* Summary Cards */}
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<MetricCard
|
||||||
|
title={t('metrics.totalQueries')}
|
||||||
|
value={summaryData ? formatNumber(totalQueries) : '-'}
|
||||||
|
subtitle={summaryData ? `WS:${summaryData.ws_count} / REST:${summaryData.rest_count}` : undefined}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('metrics.avgElapsed')}
|
||||||
|
value={summaryData ? `${((summaryData.avg_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('metrics.p95Elapsed')}
|
||||||
|
value={summaryData ? `${((summaryData.p95_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('metrics.cacheHitRate')}
|
||||||
|
value={summaryData ? `${cacheHitRate}%` : '-'}
|
||||||
|
subtitle={summaryData ? `C:${summaryData.cache_only_count}/DB:${summaryData.db_only_count}/H:${summaryData.hybrid_count}` : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
{/* Query Type toggle */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-muted mr-1">{t('metrics.queryType')}:</span>
|
||||||
|
{[undefined, 'WEBSOCKET', 'REST_V2'].map((val) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={val ?? 'all'}
|
||||||
|
onClick={() => updateFilter({ queryType: val })}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium transition ${
|
||||||
|
filter.queryType === val
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val == null ? t('metrics.allTypes') : val === 'WEBSOCKET' ? 'WS' : 'REST'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Path toggle */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-muted mr-1">{t('metrics.dataPath')}:</span>
|
||||||
|
{[undefined, 'CACHE', 'DB', 'HYBRID'].map((val) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={val ?? 'all'}
|
||||||
|
onClick={() => updateFilter({ dataPath: val })}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium transition ${
|
||||||
|
filter.dataPath === val
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val ?? t('metrics.allPaths')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Elapsed Time select */}
|
||||||
|
<select
|
||||||
|
title={t('metrics.elapsed')}
|
||||||
|
value={filter.elapsedMsMin != null ? `${filter.elapsedMsMin}-${filter.elapsedMsMax ?? ''}` : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
updateFilter({ elapsedMsMin: undefined, elapsedMsMax: undefined })
|
||||||
|
} else {
|
||||||
|
const range = ELAPSED_RANGES.find(r =>
|
||||||
|
`${r.min ?? ''}-${r.max ?? ''}` === e.target.value
|
||||||
|
)
|
||||||
|
if (range) updateFilter({ elapsedMsMin: range.min, elapsedMsMax: range.max })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border border-border bg-surface px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">{t('metrics.elapsed')}: {t('metrics.allTypes')}</option>
|
||||||
|
{ELAPSED_RANGES.map((r) => (
|
||||||
|
<option key={r.label} value={`${r.min ?? ''}-${r.max ?? ''}`}>{r.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Reset */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="rounded border border-border px-2 py-1 text-xs text-muted hover:bg-surface-secondary"
|
||||||
|
>
|
||||||
|
{t('metrics.resetFilters')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History Table */}
|
||||||
|
<DataTable<QueryMetricRow>
|
||||||
|
columns={historyColumns}
|
||||||
|
data={historyData?.content ?? []}
|
||||||
|
keyExtractor={(row) => row.query_id}
|
||||||
|
pageSize={filter.size ?? 20}
|
||||||
|
totalElements={historyData?.totalElements}
|
||||||
|
currentPage={historyData?.currentPage}
|
||||||
|
onPageChange={(p) => setFilter(prev => ({ ...prev, page: p }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import gc.mda.signal_batch.global.websocket.service.ActiveQueryManager;
|
|||||||
import gc.mda.signal_batch.global.websocket.service.CacheTrackSimplifier;
|
import gc.mda.signal_batch.global.websocket.service.CacheTrackSimplifier;
|
||||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
||||||
import gc.mda.signal_batch.global.websocket.service.TrackMemoryBudgetManager;
|
import gc.mda.signal_batch.global.websocket.service.TrackMemoryBudgetManager;
|
||||||
|
import gc.mda.signal_batch.monitoring.service.QueryMetricsBufferService;
|
||||||
|
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -56,6 +58,7 @@ public class GisServiceV2 {
|
|||||||
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||||
private final ChnPrmShipProperties chnPrmShipProperties;
|
private final ChnPrmShipProperties chnPrmShipProperties;
|
||||||
private final TrackMemoryBudgetManager memoryBudgetManager;
|
private final TrackMemoryBudgetManager memoryBudgetManager;
|
||||||
|
private final QueryMetricsBufferService queryMetricsBufferService;
|
||||||
|
|
||||||
@Value("${rest.v2.query.timeout-seconds:30}")
|
@Value("${rest.v2.query.timeout-seconds:30}")
|
||||||
private int restQueryTimeout;
|
private int restQueryTimeout;
|
||||||
@ -77,7 +80,8 @@ public class GisServiceV2 {
|
|||||||
VesselTrackToCompactConverter vesselTrackToCompactConverter,
|
VesselTrackToCompactConverter vesselTrackToCompactConverter,
|
||||||
ChnPrmShipCacheManager chnPrmShipCacheManager,
|
ChnPrmShipCacheManager chnPrmShipCacheManager,
|
||||||
ChnPrmShipProperties chnPrmShipProperties,
|
ChnPrmShipProperties chnPrmShipProperties,
|
||||||
TrackMemoryBudgetManager memoryBudgetManager) {
|
TrackMemoryBudgetManager memoryBudgetManager,
|
||||||
|
QueryMetricsBufferService queryMetricsBufferService) {
|
||||||
this.queryDataSource = queryDataSource;
|
this.queryDataSource = queryDataSource;
|
||||||
this.activeQueryManager = activeQueryManager;
|
this.activeQueryManager = activeQueryManager;
|
||||||
this.dailyTrackCacheManager = dailyTrackCacheManager;
|
this.dailyTrackCacheManager = dailyTrackCacheManager;
|
||||||
@ -89,6 +93,7 @@ public class GisServiceV2 {
|
|||||||
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
||||||
this.chnPrmShipProperties = chnPrmShipProperties;
|
this.chnPrmShipProperties = chnPrmShipProperties;
|
||||||
this.memoryBudgetManager = memoryBudgetManager;
|
this.memoryBudgetManager = memoryBudgetManager;
|
||||||
|
this.queryMetricsBufferService = queryMetricsBufferService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -282,6 +287,7 @@ public class GisServiceV2 {
|
|||||||
*/
|
*/
|
||||||
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request) {
|
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request) {
|
||||||
String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8);
|
String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
long startMs = System.currentTimeMillis();
|
||||||
boolean slotAcquired = false;
|
boolean slotAcquired = false;
|
||||||
boolean memoryReserved = false;
|
boolean memoryReserved = false;
|
||||||
|
|
||||||
@ -323,6 +329,8 @@ public class GisServiceV2 {
|
|||||||
result.size(), request.getVessels().size(),
|
result.size(), request.getVessels().size(),
|
||||||
dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip());
|
dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip());
|
||||||
|
|
||||||
|
enqueueRestMetric(queryId, request, result, startMs);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
@ -338,6 +346,30 @@ public class GisServiceV2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void enqueueRestMetric(String queryId, VesselTracksRequest request,
|
||||||
|
List<CompactVesselTrack> result, long startMs) {
|
||||||
|
try {
|
||||||
|
int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
|
||||||
|
queryMetricsBufferService.enqueue(QueryMetricsService.QueryMetric.builder()
|
||||||
|
.queryId(queryId)
|
||||||
|
.queryType("REST_V2")
|
||||||
|
.startTime(request.getStartTime())
|
||||||
|
.endTime(request.getEndTime())
|
||||||
|
.requestedMmsi(request.getVessels().size())
|
||||||
|
.dataPath(dailyTrackCacheManager.isEnabled() ? "HYBRID" : "DB")
|
||||||
|
.uniqueVessels(result.size())
|
||||||
|
.totalTracks(result.size())
|
||||||
|
.totalPoints(totalPoints)
|
||||||
|
.pointsAfterSimplify(totalPoints)
|
||||||
|
.totalChunks(1)
|
||||||
|
.elapsedMs(System.currentTimeMillis() - startMs)
|
||||||
|
.status("COMPLETED")
|
||||||
|
.build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Failed to enqueue REST metric: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 캐시 조회 로직 ──
|
// ── 캐시 조회 로직 ──
|
||||||
|
|
||||||
private List<CompactVesselTrack> queryWithCache(VesselTracksRequest request) {
|
private List<CompactVesselTrack> queryWithCache(VesselTracksRequest request) {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import java.util.stream.Collectors;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import gc.mda.signal_batch.global.websocket.dto.QueryStatusUpdate;
|
import gc.mda.signal_batch.global.websocket.dto.QueryStatusUpdate;
|
||||||
import gc.mda.signal_batch.global.websocket.dto.ViewportFilter;
|
import gc.mda.signal_batch.global.websocket.dto.ViewportFilter;
|
||||||
|
import gc.mda.signal_batch.monitoring.service.QueryMetricsBufferService;
|
||||||
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
@ -53,7 +54,7 @@ public class ChunkedTrackStreamingService {
|
|||||||
private final DailyTrackCacheManager dailyTrackCacheManager;
|
private final DailyTrackCacheManager dailyTrackCacheManager;
|
||||||
private final CacheTrackSimplifier cacheTrackSimplifier;
|
private final CacheTrackSimplifier cacheTrackSimplifier;
|
||||||
private final TrackMemoryBudgetManager memoryBudgetManager;
|
private final TrackMemoryBudgetManager memoryBudgetManager;
|
||||||
private final QueryMetricsService queryMetricsService;
|
private final QueryMetricsBufferService queryMetricsBufferService;
|
||||||
private static final ThreadLocal<WKTReader> wktReaderLocal = ThreadLocal.withInitial(WKTReader::new);
|
private static final ThreadLocal<WKTReader> wktReaderLocal = ThreadLocal.withInitial(WKTReader::new);
|
||||||
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
private static final int MAX_TRACKS_PER_CHUNK = 500000; // 청크당 최대 트랙 수 (10만 선박 지원)
|
private static final int MAX_TRACKS_PER_CHUNK = 500000; // 청크당 최대 트랙 수 (10만 선박 지원)
|
||||||
@ -104,7 +105,7 @@ public class ChunkedTrackStreamingService {
|
|||||||
DailyTrackCacheManager dailyTrackCacheManager,
|
DailyTrackCacheManager dailyTrackCacheManager,
|
||||||
CacheTrackSimplifier cacheTrackSimplifier,
|
CacheTrackSimplifier cacheTrackSimplifier,
|
||||||
TrackMemoryBudgetManager memoryBudgetManager,
|
TrackMemoryBudgetManager memoryBudgetManager,
|
||||||
QueryMetricsService queryMetricsService) {
|
QueryMetricsBufferService queryMetricsBufferService) {
|
||||||
this.queryJdbcTemplate = queryJdbcTemplate;
|
this.queryJdbcTemplate = queryJdbcTemplate;
|
||||||
this.queryDataSource = queryDataSource;
|
this.queryDataSource = queryDataSource;
|
||||||
this.activeQueryManager = activeQueryManager;
|
this.activeQueryManager = activeQueryManager;
|
||||||
@ -112,7 +113,7 @@ public class ChunkedTrackStreamingService {
|
|||||||
this.dailyTrackCacheManager = dailyTrackCacheManager;
|
this.dailyTrackCacheManager = dailyTrackCacheManager;
|
||||||
this.cacheTrackSimplifier = cacheTrackSimplifier;
|
this.cacheTrackSimplifier = cacheTrackSimplifier;
|
||||||
this.memoryBudgetManager = memoryBudgetManager;
|
this.memoryBudgetManager = memoryBudgetManager;
|
||||||
this.queryMetricsService = queryMetricsService;
|
this.queryMetricsBufferService = queryMetricsBufferService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1133,7 +1134,7 @@ public class ChunkedTrackStreamingService {
|
|||||||
String vpBounds = (vp != null && vp.isValid())
|
String vpBounds = (vp != null && vp.isValid())
|
||||||
? String.format("%.4f,%.4f,%.4f,%.4f", vp.getMinLon(), vp.getMinLat(), vp.getMaxLon(), vp.getMaxLat())
|
? String.format("%.4f,%.4f,%.4f,%.4f", vp.getMinLon(), vp.getMinLat(), vp.getMaxLon(), vp.getMaxLat())
|
||||||
: null;
|
: null;
|
||||||
queryMetricsService.saveAsync(QueryMetricsService.QueryMetric.builder()
|
queryMetricsBufferService.enqueue(QueryMetricsService.QueryMetric.builder()
|
||||||
.queryId(queryId)
|
.queryId(queryId)
|
||||||
.sessionId(sessionId)
|
.sessionId(sessionId)
|
||||||
.queryType("WEBSOCKET")
|
.queryType("WEBSOCKET")
|
||||||
|
|||||||
@ -2,13 +2,14 @@ package gc.mda.signal_batch.monitoring.controller;
|
|||||||
|
|
||||||
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿼리 메트릭 조회 API
|
* 쿼리 메트릭 조회 API
|
||||||
@ -18,11 +19,22 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/monitoring/query-metrics")
|
@RequestMapping("/api/monitoring/query-metrics")
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Tag(name = "Query Metrics", description = "쿼리 실행 메트릭 조회 API")
|
@Tag(name = "Query Metrics", description = "쿼리 실행 메트릭 조회 API")
|
||||||
public class QueryMetricsController {
|
public class QueryMetricsController {
|
||||||
|
|
||||||
private final QueryMetricsService queryMetricsService;
|
private final QueryMetricsService queryMetricsService;
|
||||||
|
private final JdbcTemplate queryJdbcTemplate;
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_SORT_COLUMNS = Set.of(
|
||||||
|
"created_at", "elapsed_ms", "unique_vessels", "total_points"
|
||||||
|
);
|
||||||
|
|
||||||
|
public QueryMetricsController(
|
||||||
|
QueryMetricsService queryMetricsService,
|
||||||
|
@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
||||||
|
this.queryMetricsService = queryMetricsService;
|
||||||
|
this.queryJdbcTemplate = queryJdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "최근 쿼리 메트릭 조회", description = "최근 N건의 쿼리 실행 메트릭을 조회합니다")
|
@Operation(summary = "최근 쿼리 메트릭 조회", description = "최근 N건의 쿼리 실행 메트릭을 조회합니다")
|
||||||
@ -37,4 +49,110 @@ public class QueryMetricsController {
|
|||||||
@RequestParam(defaultValue = "7") int days) {
|
@RequestParam(defaultValue = "7") int days) {
|
||||||
return ResponseEntity.ok(queryMetricsService.getStats(Math.min(days, 90)));
|
return ResponseEntity.ok(queryMetricsService.getStats(Math.min(days, 90)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history")
|
||||||
|
@Operation(summary = "쿼리 이력 조회 (페이지네이션)", description = "필터 + 서버사이드 페이지네이션")
|
||||||
|
public Map<String, Object> getQueryHistory(
|
||||||
|
@Parameter(description = "쿼리 유형 (WEBSOCKET, REST_V2)") @RequestParam(required = false) String queryType,
|
||||||
|
@Parameter(description = "데이터 경로 (CACHE, DB, HYBRID)") @RequestParam(required = false) String dataPath,
|
||||||
|
@Parameter(description = "상태 (COMPLETED, CANCELLED, ERROR, TIMEOUT)") @RequestParam(required = false) String status,
|
||||||
|
@Parameter(description = "응답시간 최소 (ms)") @RequestParam(required = false) Integer elapsedMsMin,
|
||||||
|
@Parameter(description = "응답시간 최대 (ms)") @RequestParam(required = false) Integer elapsedMsMax,
|
||||||
|
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
||||||
|
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size,
|
||||||
|
@Parameter(description = "정렬 컬럼") @RequestParam(defaultValue = "created_at") String sortBy,
|
||||||
|
@Parameter(description = "정렬 방향 (asc, desc)") @RequestParam(defaultValue = "desc") String sortDir) {
|
||||||
|
|
||||||
|
if (!ALLOWED_SORT_COLUMNS.contains(sortBy)) {
|
||||||
|
sortBy = "created_at";
|
||||||
|
}
|
||||||
|
String direction = "asc".equalsIgnoreCase(sortDir) ? "ASC" : "DESC";
|
||||||
|
size = Math.min(size, 100);
|
||||||
|
|
||||||
|
StringBuilder where = new StringBuilder("WHERE 1=1");
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
|
||||||
|
if (queryType != null && !queryType.isEmpty()) {
|
||||||
|
where.append(" AND query_type = ?");
|
||||||
|
params.add(queryType);
|
||||||
|
}
|
||||||
|
if (dataPath != null && !dataPath.isEmpty()) {
|
||||||
|
where.append(" AND data_path = ?");
|
||||||
|
params.add(dataPath);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isEmpty()) {
|
||||||
|
where.append(" AND status = ?");
|
||||||
|
params.add(status);
|
||||||
|
}
|
||||||
|
if (elapsedMsMin != null) {
|
||||||
|
where.append(" AND elapsed_ms >= ?");
|
||||||
|
params.add(elapsedMsMin);
|
||||||
|
}
|
||||||
|
if (elapsedMsMax != null) {
|
||||||
|
where.append(" AND elapsed_ms <= ?");
|
||||||
|
params.add(elapsedMsMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
String whereClause = where.toString();
|
||||||
|
|
||||||
|
// COUNT 쿼리
|
||||||
|
String countSql = "SELECT COUNT(*) FROM signal.t_query_metrics " + whereClause;
|
||||||
|
Integer totalElements = queryJdbcTemplate.queryForObject(countSql, Integer.class, params.toArray());
|
||||||
|
if (totalElements == null) totalElements = 0;
|
||||||
|
|
||||||
|
// 데이터 쿼리
|
||||||
|
String dataSql = """
|
||||||
|
SELECT id, query_id, query_type, created_at, data_path, status,
|
||||||
|
zoom_level, requested_mmsi, unique_vessels, total_tracks,
|
||||||
|
total_points, points_after_simplify, total_chunks,
|
||||||
|
response_bytes, elapsed_ms, db_query_ms, simplify_ms,
|
||||||
|
cache_hit_days, db_query_days
|
||||||
|
FROM signal.t_query_metrics
|
||||||
|
""" + whereClause +
|
||||||
|
" ORDER BY " + sortBy + " " + direction +
|
||||||
|
" LIMIT ? OFFSET ?";
|
||||||
|
|
||||||
|
List<Object> dataParams = new ArrayList<>(params);
|
||||||
|
dataParams.add(size);
|
||||||
|
dataParams.add(page * size);
|
||||||
|
|
||||||
|
List<Map<String, Object>> content = queryJdbcTemplate.queryForList(dataSql, dataParams.toArray());
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("content", content);
|
||||||
|
result.put("totalElements", totalElements);
|
||||||
|
result.put("totalPages", (int) Math.ceil((double) totalElements / size));
|
||||||
|
result.put("currentPage", page);
|
||||||
|
result.put("pageSize", size);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/summary")
|
||||||
|
@Operation(summary = "쿼리 메트릭 요약", description = "최근 N시간 요약 통계 (P95 포함)")
|
||||||
|
public Map<String, Object> getSummary(
|
||||||
|
@Parameter(description = "조회 기간 (시간)") @RequestParam(defaultValue = "24") int hours) {
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_queries,
|
||||||
|
COALESCE(AVG(elapsed_ms), 0) as avg_elapsed_ms,
|
||||||
|
COALESCE(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY elapsed_ms), 0) as p95_elapsed_ms,
|
||||||
|
COALESCE(MAX(elapsed_ms), 0) as max_elapsed_ms,
|
||||||
|
COUNT(CASE WHEN query_type = 'WEBSOCKET' THEN 1 END) as ws_count,
|
||||||
|
COUNT(CASE WHEN query_type LIKE 'REST%%' THEN 1 END) as rest_count,
|
||||||
|
COUNT(CASE WHEN data_path = 'CACHE' THEN 1 END) as cache_only_count,
|
||||||
|
COUNT(CASE WHEN data_path = 'DB' THEN 1 END) as db_only_count,
|
||||||
|
COUNT(CASE WHEN data_path = 'HYBRID' THEN 1 END) as hybrid_count,
|
||||||
|
COUNT(CASE WHEN status = 'COMPLETED' THEN 1 END) as completed_count,
|
||||||
|
COUNT(CASE WHEN status != 'COMPLETED' THEN 1 END) as failed_count,
|
||||||
|
COALESCE(AVG(unique_vessels), 0) as avg_vessels,
|
||||||
|
COALESCE(AVG(total_points), 0) as avg_points_before,
|
||||||
|
COALESCE(AVG(points_after_simplify), 0) as avg_points_after
|
||||||
|
FROM signal.t_query_metrics
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '%d hours'
|
||||||
|
""".formatted(Math.min(hours, 720));
|
||||||
|
|
||||||
|
return queryJdbcTemplate.queryForMap(sql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,110 @@
|
|||||||
|
package gc.mda.signal_batch.monitoring.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 메트릭 벌크 INSERT 버퍼 서비스
|
||||||
|
*
|
||||||
|
* ConcurrentLinkedQueue로 lock-free 수집 후 10초 간격으로 batchUpdate.
|
||||||
|
* 1요청 = 1레코드 보장: WebSocket은 쿼리 완료 시 1회, REST는 호출당 1회 enqueue.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class QueryMetricsBufferService {
|
||||||
|
|
||||||
|
private static final int MAX_FLUSH_SIZE = 500;
|
||||||
|
|
||||||
|
private static final String INSERT_SQL = """
|
||||||
|
INSERT INTO signal.t_query_metrics (
|
||||||
|
query_id, session_id, query_type, created_at,
|
||||||
|
start_time, end_time, zoom_level, viewport_bounds, requested_mmsi,
|
||||||
|
data_path, cache_hit_days, db_query_days, db_conn_total,
|
||||||
|
unique_vessels, total_tracks, total_points, points_after_simplify,
|
||||||
|
total_chunks, response_bytes,
|
||||||
|
elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
|
||||||
|
status
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, now(),
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcTemplate queryJdbcTemplate;
|
||||||
|
private final ConcurrentLinkedQueue<QueryMetricsService.QueryMetric> buffer = new ConcurrentLinkedQueue<>();
|
||||||
|
|
||||||
|
public QueryMetricsBufferService(
|
||||||
|
@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
||||||
|
this.queryJdbcTemplate = queryJdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메트릭 레코드를 버퍼에 추가 (lock-free)
|
||||||
|
*/
|
||||||
|
public void enqueue(QueryMetricsService.QueryMetric metric) {
|
||||||
|
if (metric == null) return;
|
||||||
|
buffer.offer(metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 10초 간격으로 버퍼 flush → batchUpdate
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 10_000)
|
||||||
|
public void flush() {
|
||||||
|
if (buffer.isEmpty()) return;
|
||||||
|
|
||||||
|
List<QueryMetricsService.QueryMetric> batch = new ArrayList<>(MAX_FLUSH_SIZE);
|
||||||
|
QueryMetricsService.QueryMetric metric;
|
||||||
|
while (batch.size() < MAX_FLUSH_SIZE && (metric = buffer.poll()) != null) {
|
||||||
|
batch.add(metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.isEmpty()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Object[]> args = batch.stream()
|
||||||
|
.map(this::toArgs)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
queryJdbcTemplate.batchUpdate(INSERT_SQL, args);
|
||||||
|
|
||||||
|
log.debug("Flushed {} query metrics to DB (remaining: {})", batch.size(), buffer.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to flush query metrics ({} records): {}", batch.size(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object[] toArgs(QueryMetricsService.QueryMetric m) {
|
||||||
|
return new Object[]{
|
||||||
|
m.getQueryId(), m.getSessionId(), m.getQueryType(),
|
||||||
|
m.getStartTime() != null ? Timestamp.valueOf(m.getStartTime()) : null,
|
||||||
|
m.getEndTime() != null ? Timestamp.valueOf(m.getEndTime()) : null,
|
||||||
|
m.getZoomLevel(), m.getViewportBounds(), m.getRequestedMmsi(),
|
||||||
|
m.getDataPath(), m.getCacheHitDays(), m.getDbQueryDays(), m.getDbConnTotal(),
|
||||||
|
m.getUniqueVessels(), m.getTotalTracks(), m.getTotalPoints(), m.getPointsAfterSimplify(),
|
||||||
|
m.getTotalChunks(), m.getResponseBytes(),
|
||||||
|
m.getElapsedMs(), m.getDbQueryMs(), m.getSimplifyMs(), m.getBackpressureEvents(),
|
||||||
|
m.getStatus()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 버퍼 크기 (모니터링용)
|
||||||
|
*/
|
||||||
|
public int getBufferSize() {
|
||||||
|
return buffer.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,20 +5,18 @@ import lombok.Getter;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿼리 실행 메트릭 DB 저장/조회 서비스
|
* 쿼리 실행 메트릭 조회 서비스
|
||||||
*
|
*
|
||||||
* WebSocket 리플레이 및 REST API 쿼리의 성능 메트릭을 signal.t_query_metrics에 저장.
|
* 적재는 QueryMetricsBufferService가 담당 (ConcurrentLinkedQueue + 10초 batch flush).
|
||||||
* streamChunkedTracks() finally 블록에서 비동기 INSERT 호출하여 응답 지연 없이 기록.
|
* 이 서비스는 조회 전용 + QueryMetric DTO 정의.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -26,54 +24,10 @@ public class QueryMetricsService {
|
|||||||
|
|
||||||
private final JdbcTemplate queryJdbcTemplate;
|
private final JdbcTemplate queryJdbcTemplate;
|
||||||
|
|
||||||
private static final String INSERT_SQL = """
|
|
||||||
INSERT INTO signal.t_query_metrics (
|
|
||||||
query_id, session_id, query_type, created_at,
|
|
||||||
start_time, end_time, zoom_level, viewport_bounds, requested_mmsi,
|
|
||||||
data_path, cache_hit_days, db_query_days, db_conn_total,
|
|
||||||
unique_vessels, total_tracks, total_points, points_after_simplify,
|
|
||||||
total_chunks, response_bytes,
|
|
||||||
elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
|
|
||||||
status
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, now(),
|
|
||||||
?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?
|
|
||||||
)
|
|
||||||
""";
|
|
||||||
|
|
||||||
public QueryMetricsService(@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
public QueryMetricsService(@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
||||||
this.queryJdbcTemplate = queryJdbcTemplate;
|
this.queryJdbcTemplate = queryJdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 메트릭 비동기 저장 — 쿼리 응답에 영향 없음
|
|
||||||
*/
|
|
||||||
@Async("trackStreamingExecutor")
|
|
||||||
public void saveAsync(QueryMetric metric) {
|
|
||||||
try {
|
|
||||||
queryJdbcTemplate.update(INSERT_SQL,
|
|
||||||
metric.queryId, metric.sessionId, metric.queryType,
|
|
||||||
metric.startTime != null ? Timestamp.valueOf(metric.startTime) : null,
|
|
||||||
metric.endTime != null ? Timestamp.valueOf(metric.endTime) : null,
|
|
||||||
metric.zoomLevel, metric.viewportBounds, metric.requestedMmsi,
|
|
||||||
metric.dataPath, metric.cacheHitDays, metric.dbQueryDays, metric.dbConnTotal,
|
|
||||||
metric.uniqueVessels, metric.totalTracks, metric.totalPoints, metric.pointsAfterSimplify,
|
|
||||||
metric.totalChunks, metric.responseBytes,
|
|
||||||
metric.elapsedMs, metric.dbQueryMs, metric.simplifyMs, metric.backpressureEvents,
|
|
||||||
metric.status
|
|
||||||
);
|
|
||||||
log.debug("Query metric saved: queryId={}, elapsed={}ms, status={}",
|
|
||||||
metric.queryId, metric.elapsedMs, metric.status);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to save query metric: queryId={}, error={}", metric.queryId, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 쿼리 메트릭 조회
|
* 최근 쿼리 메트릭 조회
|
||||||
*/
|
*/
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user