Merge pull request 'feat: 쿼리 메트릭 수집 확장 + 대시보드 성능 차트 추가' (#101) from feature/dashboard-metrics-charts into develop
This commit is contained in:
커밋
3d1f9631eb
@ -4,6 +4,9 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 쿼리 메트릭 수집 확장 + 대시보드 성능 차트 — client IP 수집(REST/WS), 응답 크기 추정, timeseries API, 대시보드 쿼리 성능 차트 5종(응답시간·볼륨·캐시경로·응답크기·Top 클라이언트)
|
||||
|
||||
## [2026-03-10]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
QueryMetricsPage,
|
||||
QueryMetricsParams,
|
||||
QueryMetricsSummary,
|
||||
QueryMetricsTimeSeries,
|
||||
ThroughputMetrics,
|
||||
} from './types.ts'
|
||||
|
||||
@ -66,4 +67,8 @@ export const monitorApi = {
|
||||
getQueryMetricsSummary(hours = 24): Promise<QueryMetricsSummary> {
|
||||
return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`)
|
||||
},
|
||||
|
||||
getQueryMetricsTimeSeries(days = 7): Promise<QueryMetricsTimeSeries> {
|
||||
return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}`)
|
||||
},
|
||||
}
|
||||
|
||||
@ -232,6 +232,34 @@ export interface QueryMetricsSummary {
|
||||
avg_vessels: number
|
||||
avg_points_before: number
|
||||
avg_points_after: number
|
||||
avg_response_size_bytes: number
|
||||
}
|
||||
|
||||
/* Query Metrics TimeSeries */
|
||||
|
||||
export interface TimeSeriesBucket {
|
||||
bucket: string
|
||||
query_count: number
|
||||
avg_elapsed_ms: number
|
||||
max_elapsed_ms: number
|
||||
avg_response_bytes: number
|
||||
ws_count: number
|
||||
rest_count: number
|
||||
cache_count: number
|
||||
db_count: number
|
||||
hybrid_count: number
|
||||
}
|
||||
|
||||
export interface TopClient {
|
||||
client_ip: string
|
||||
query_count: number
|
||||
avg_elapsed_ms: number
|
||||
}
|
||||
|
||||
export interface QueryMetricsTimeSeries {
|
||||
buckets: TimeSeriesBucket[]
|
||||
topClients: TopClient[]
|
||||
granularity: 'HOURLY' | 'DAILY'
|
||||
}
|
||||
|
||||
export interface QueryMetricsParams {
|
||||
|
||||
@ -21,6 +21,7 @@ interface LineChartProps {
|
||||
xKey: string
|
||||
height?: number
|
||||
label?: string
|
||||
yFormatter?: (value: number) => string
|
||||
}
|
||||
|
||||
export default function LineChart({
|
||||
@ -29,6 +30,7 @@ export default function LineChart({
|
||||
xKey,
|
||||
height = 240,
|
||||
label,
|
||||
yFormatter,
|
||||
}: LineChartProps) {
|
||||
return (
|
||||
<div>
|
||||
@ -46,6 +48,7 @@ export default function LineChart({
|
||||
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={yFormatter}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
@ -54,6 +57,7 @@ export default function LineChart({
|
||||
borderRadius: 'var(--sb-radius)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={yFormatter ? (v: number) => yFormatter(v) : undefined}
|
||||
/>
|
||||
{series.length > 1 && (
|
||||
<Legend
|
||||
|
||||
@ -49,6 +49,16 @@ const en = {
|
||||
'dashboard.hits': 'Hits',
|
||||
'dashboard.misses': 'Misses',
|
||||
'dashboard.dailyVolume': 'Daily Processing Volume',
|
||||
'dashboard.queryPerformance': 'Query Performance',
|
||||
'dashboard.responseTimeTrend': 'Response Time Trend',
|
||||
'dashboard.queryVolume': 'Query Volume',
|
||||
'dashboard.cachePathRatio': 'Cache Path Ratio',
|
||||
'dashboard.responseSizeTrend': 'Response Size Trend',
|
||||
'dashboard.topClients': 'Top Clients',
|
||||
'dashboard.avgElapsed': 'Avg',
|
||||
'dashboard.maxElapsed': 'Max',
|
||||
'dashboard.queries': 'queries',
|
||||
'dashboard.noChartData': 'No chart data available',
|
||||
|
||||
// Job Monitor
|
||||
'jobs.title': 'Job Monitor',
|
||||
|
||||
@ -49,6 +49,16 @@ const ko = {
|
||||
'dashboard.hits': '히트',
|
||||
'dashboard.misses': '미스',
|
||||
'dashboard.dailyVolume': '일별 처리량',
|
||||
'dashboard.queryPerformance': '쿼리 성능',
|
||||
'dashboard.responseTimeTrend': '응답시간 추이',
|
||||
'dashboard.queryVolume': '쿼리 볼륨',
|
||||
'dashboard.cachePathRatio': '캐시/경로 비율',
|
||||
'dashboard.responseSizeTrend': '응답 크기 추이',
|
||||
'dashboard.topClients': 'Top 클라이언트',
|
||||
'dashboard.avgElapsed': '평균',
|
||||
'dashboard.maxElapsed': '최대',
|
||||
'dashboard.queries': '건',
|
||||
'dashboard.noChartData': '차트 데이터가 없습니다',
|
||||
|
||||
// Job Monitor
|
||||
'jobs.title': 'Job 모니터',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { usePoller } from '../hooks/usePoller.ts'
|
||||
import { useCachedState } from '../hooks/useCachedState.ts'
|
||||
import { useI18n } from '../hooks/useI18n.ts'
|
||||
@ -10,11 +10,13 @@ import type {
|
||||
DailyStats,
|
||||
MetricsSummary,
|
||||
ProcessingDelay,
|
||||
QueryMetricsTimeSeries,
|
||||
RunningJob,
|
||||
} from '../api/types.ts'
|
||||
import MetricCard from '../components/charts/MetricCard.tsx'
|
||||
import StatusBadge from '../components/common/StatusBadge.tsx'
|
||||
import BarChart from '../components/charts/BarChart.tsx'
|
||||
import LineChart from '../components/charts/LineChart.tsx'
|
||||
import TimeRangeSelector from '../components/common/TimeRangeSelector.tsx'
|
||||
import { formatDuration, formatNumber, formatDateTime, formatPercent } from '../utils/formatters.ts'
|
||||
|
||||
@ -28,7 +30,19 @@ export default function Dashboard() {
|
||||
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('dash.delay', null)
|
||||
const [daily, setDaily] = useCachedState<DailyStats | null>('dash.daily', null)
|
||||
const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', [])
|
||||
const [queryTs, setQueryTs] = useCachedState<QueryMetricsTimeSeries | null>('dash.queryTs', null)
|
||||
const [days, setDays] = useState(7)
|
||||
const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() =>
|
||||
localStorage.getItem('dashboard-query-charts') !== 'collapsed',
|
||||
)
|
||||
|
||||
const toggleQueryCharts = useCallback(() => {
|
||||
setIsQueryChartsOpen(prev => {
|
||||
const next = !prev
|
||||
localStorage.setItem('dashboard-query-charts', next ? 'expanded' : 'collapsed')
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
usePoller(() => {
|
||||
batchApi.getStatistics(days).then(setStats).catch(() => {})
|
||||
@ -37,6 +51,7 @@ export default function Dashboard() {
|
||||
monitorApi.getDelay().then(setDelay).catch(() => {})
|
||||
batchApi.getDailyStats().then(setDaily).catch(() => {})
|
||||
batchApi.getRunningJobs().then(setRunning).catch(() => {})
|
||||
monitorApi.getQueryMetricsTimeSeries(days).then(setQueryTs).catch(() => {})
|
||||
}, POLL_INTERVAL, [days])
|
||||
|
||||
const memUsage = metrics
|
||||
@ -214,6 +229,146 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query Performance Charts */}
|
||||
<div className="sb-card">
|
||||
<button
|
||||
type="button"
|
||||
className="sb-card-header flex w-full items-center justify-between cursor-pointer"
|
||||
onClick={toggleQueryCharts}
|
||||
>
|
||||
<span>{t('dashboard.queryPerformance')}</span>
|
||||
<svg
|
||||
className={`h-5 w-5 text-muted transition-transform ${isQueryChartsOpen ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isQueryChartsOpen && (
|
||||
<div className="space-y-6 pt-2">
|
||||
{queryTs && queryTs.buckets.length > 0 ? (
|
||||
<>
|
||||
{/* Row 1: Response Time + Query Volume */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<LineChart
|
||||
label={t('dashboard.responseTimeTrend')}
|
||||
data={queryTs.buckets.map(b => ({
|
||||
time: formatBucket(b.bucket, queryTs.granularity),
|
||||
avg: Math.round(b.avg_elapsed_ms),
|
||||
max: Math.round(b.max_elapsed_ms),
|
||||
}))}
|
||||
series={[
|
||||
{ dataKey: 'avg', color: 'var(--sb-primary)', name: t('dashboard.avgElapsed') },
|
||||
{ dataKey: 'max', color: 'var(--sb-danger)', name: t('dashboard.maxElapsed') },
|
||||
]}
|
||||
xKey="time"
|
||||
height={220}
|
||||
yFormatter={v => `${v}ms`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<BarChart
|
||||
label={t('dashboard.queryVolume')}
|
||||
data={queryTs.buckets.map(b => ({
|
||||
time: formatBucket(b.bucket, queryTs.granularity),
|
||||
WS: b.ws_count,
|
||||
REST: b.rest_count,
|
||||
}))}
|
||||
xKey="time"
|
||||
height={220}
|
||||
series={[
|
||||
{ dataKey: 'WS', color: 'var(--sb-primary)', name: 'WebSocket', stackId: 'q' },
|
||||
{ dataKey: 'REST', color: 'var(--sb-success)', name: 'REST', stackId: 'q' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Cache Path + Response Size */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<BarChart
|
||||
label={t('dashboard.cachePathRatio')}
|
||||
data={queryTs.buckets.map(b => ({
|
||||
time: formatBucket(b.bucket, queryTs.granularity),
|
||||
Cache: b.cache_count,
|
||||
DB: b.db_count,
|
||||
Hybrid: b.hybrid_count,
|
||||
}))}
|
||||
xKey="time"
|
||||
height={220}
|
||||
series={[
|
||||
{ dataKey: 'Cache', color: 'var(--sb-success)', stackId: 'p' },
|
||||
{ dataKey: 'DB', color: 'var(--sb-warning)', stackId: 'p' },
|
||||
{ dataKey: 'Hybrid', color: 'var(--sb-primary)', stackId: 'p' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<LineChart
|
||||
label={t('dashboard.responseSizeTrend')}
|
||||
data={queryTs.buckets.map(b => ({
|
||||
time: formatBucket(b.bucket, queryTs.granularity),
|
||||
size: Math.round(b.avg_response_bytes / 1024),
|
||||
}))}
|
||||
series={[
|
||||
{ dataKey: 'size', color: 'var(--sb-primary)', name: 'KB' },
|
||||
]}
|
||||
xKey="time"
|
||||
height={220}
|
||||
yFormatter={v => `${v}KB`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Clients */}
|
||||
{queryTs.topClients.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium text-muted">{t('dashboard.topClients')}</div>
|
||||
<div className="space-y-2">
|
||||
{queryTs.topClients.map((c, i) => {
|
||||
const maxCount = queryTs.topClients[0].query_count
|
||||
const pct = maxCount > 0 ? (c.query_count / maxCount) * 100 : 0
|
||||
return (
|
||||
<div key={c.client_ip ?? i} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-32 truncate font-mono text-xs">{c.client_ip ?? '-'}</span>
|
||||
<div className="flex-1">
|
||||
<div className="h-4 rounded bg-surface-hover">
|
||||
<div
|
||||
className="h-4 rounded bg-primary"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-20 text-right text-xs text-muted">
|
||||
{c.query_count}{t('dashboard.queries')} · {Math.round(c.avg_elapsed_ms)}ms
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-muted">{t('dashboard.noChartData')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBucket(bucket: string, granularity: 'HOURLY' | 'DAILY'): string {
|
||||
if (granularity === 'HOURLY') {
|
||||
// "2026-03-10T14:00:00" → "14:00"
|
||||
const timePart = bucket.includes('T') ? bucket.split('T')[1] : bucket
|
||||
return timePart.slice(0, 5)
|
||||
}
|
||||
// "2026-03-10" → "03-10"
|
||||
return bucket.slice(5, 10)
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -188,8 +189,18 @@ public class GisControllerV2 {
|
||||
required = true,
|
||||
content = @Content(schema = @Schema(implementation = VesselTracksRequest.class))
|
||||
)
|
||||
@RequestBody VesselTracksRequest request) {
|
||||
return gisServiceV2.getVesselTracksV2(request);
|
||||
@RequestBody VesselTracksRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
return gisServiceV2.getVesselTracksV2(request, getClientIp(httpRequest));
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String[] headers = {"X-Forwarded-For", "X-Original-Forwarded-For", "X-Real-IP"};
|
||||
for (String header : headers) {
|
||||
String ip = request.getHeader(header);
|
||||
if (ip != null && !ip.isBlank()) return ip.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
@GetMapping("/vessels/recent-positions")
|
||||
|
||||
@ -285,7 +285,7 @@ public class GisServiceV2 {
|
||||
/**
|
||||
* 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment)
|
||||
*/
|
||||
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request) {
|
||||
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request, String clientIp) {
|
||||
String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
long startMs = System.currentTimeMillis();
|
||||
boolean slotAcquired = false;
|
||||
@ -329,7 +329,7 @@ public class GisServiceV2 {
|
||||
result.size(), request.getVessels().size(),
|
||||
dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip());
|
||||
|
||||
enqueueRestMetric(queryId, request, result, startMs);
|
||||
enqueueRestMetric(queryId, request, result, startMs, clientIp);
|
||||
|
||||
return result;
|
||||
|
||||
@ -347,9 +347,10 @@ public class GisServiceV2 {
|
||||
}
|
||||
|
||||
private void enqueueRestMetric(String queryId, VesselTracksRequest request,
|
||||
List<CompactVesselTrack> result, long startMs) {
|
||||
List<CompactVesselTrack> result, long startMs, String clientIp) {
|
||||
try {
|
||||
int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
|
||||
long responseBytes = (long) result.size() * 200 + (long) totalPoints * 40;
|
||||
queryMetricsBufferService.enqueue(QueryMetricsService.QueryMetric.builder()
|
||||
.queryId(queryId)
|
||||
.queryType("REST_V2")
|
||||
@ -362,8 +363,10 @@ public class GisServiceV2 {
|
||||
.totalPoints(totalPoints)
|
||||
.pointsAfterSimplify(totalPoints)
|
||||
.totalChunks(1)
|
||||
.responseBytes(responseBytes)
|
||||
.elapsedMs(System.currentTimeMillis() - startMs)
|
||||
.status("COMPLETED")
|
||||
.clientIp(clientIp)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to enqueue REST metric: {}", e.getMessage());
|
||||
|
||||
@ -71,6 +71,13 @@ public class StompTrackController {
|
||||
}
|
||||
};
|
||||
|
||||
// 세션 속성에서 CLIENT_IP 추출
|
||||
String clientIp = null;
|
||||
Map<String, Object> sessionAttrs = headerAccessor.getSessionAttributes();
|
||||
if (sessionAttrs != null && sessionAttrs.containsKey("CLIENT_IP")) {
|
||||
clientIp = (String) sessionAttrs.get("CLIENT_IP");
|
||||
}
|
||||
|
||||
// 비동기 스트리밍 시작 - 청크 모드 체크
|
||||
if (request.isChunkedMode()) {
|
||||
chunkedTrackStreamingService.streamChunkedTracks(
|
||||
@ -78,7 +85,8 @@ public class StompTrackController {
|
||||
queryId,
|
||||
sessionId,
|
||||
chunk -> sendChunkedDataToUser(userId, chunk),
|
||||
statusCallback
|
||||
statusCallback,
|
||||
clientIp
|
||||
);
|
||||
} else {
|
||||
trackStreamingService.streamTracks(
|
||||
|
||||
@ -724,7 +724,8 @@ public class ChunkedTrackStreamingService {
|
||||
String queryId,
|
||||
String sessionId,
|
||||
Consumer<TrackChunkResponse> chunkConsumer,
|
||||
Consumer<QueryStatusUpdate> statusConsumer) {
|
||||
Consumer<QueryStatusUpdate> statusConsumer,
|
||||
String clientIp) {
|
||||
boolean slotAcquired = false;
|
||||
QueryBenchmark benchmark = null;
|
||||
QueryContext ctx = null;
|
||||
@ -1158,6 +1159,7 @@ public class ChunkedTrackStreamingService {
|
||||
.simplifyMs(benchmark.simplifyTimeMs)
|
||||
.backpressureEvents(bpMetrics != null ? bpMetrics.backpressureEvents.get() : 0)
|
||||
.status(queryStatus)
|
||||
.clientIp(clientIp)
|
||||
.build());
|
||||
}
|
||||
|
||||
|
||||
@ -148,11 +148,59 @@ public class QueryMetricsController {
|
||||
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
|
||||
COALESCE(AVG(points_after_simplify), 0) as avg_points_after,
|
||||
COALESCE(AVG(response_bytes), 0) as avg_response_size_bytes
|
||||
FROM signal.t_query_metrics
|
||||
WHERE created_at >= NOW() - INTERVAL '%d hours'
|
||||
""".formatted(Math.min(hours, 720));
|
||||
|
||||
return queryJdbcTemplate.queryForMap(sql);
|
||||
}
|
||||
|
||||
@GetMapping("/timeseries")
|
||||
@Operation(summary = "쿼리 메트릭 시계열", description = "시간별/일별 버킷 집계 + Top 10 클라이언트")
|
||||
public Map<String, Object> getTimeSeries(
|
||||
@Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days) {
|
||||
|
||||
days = Math.min(days, 90);
|
||||
String granularity = days <= 7 ? "HOURLY" : "DAILY";
|
||||
String bucketExpr = days <= 7 ? "DATE_TRUNC('hour', created_at)" : "DATE(created_at)";
|
||||
|
||||
String bucketSql = """
|
||||
SELECT %s AS bucket,
|
||||
COUNT(*) AS query_count,
|
||||
COALESCE(AVG(elapsed_ms), 0) AS avg_elapsed_ms,
|
||||
COALESCE(MAX(elapsed_ms), 0) AS max_elapsed_ms,
|
||||
COALESCE(AVG(response_bytes), 0) AS avg_response_bytes,
|
||||
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_count,
|
||||
COUNT(CASE WHEN data_path = 'DB' THEN 1 END) AS db_count,
|
||||
COUNT(CASE WHEN data_path = 'HYBRID' THEN 1 END) AS hybrid_count
|
||||
FROM signal.t_query_metrics
|
||||
WHERE created_at >= NOW() - INTERVAL '%d days'
|
||||
GROUP BY bucket ORDER BY bucket
|
||||
""".formatted(bucketExpr, days);
|
||||
|
||||
List<Map<String, Object>> buckets = queryJdbcTemplate.queryForList(bucketSql);
|
||||
|
||||
String topClientsSql = """
|
||||
SELECT client_ip, COUNT(*) AS query_count,
|
||||
COALESCE(AVG(elapsed_ms), 0) AS avg_elapsed_ms
|
||||
FROM signal.t_query_metrics
|
||||
WHERE created_at >= NOW() - INTERVAL '%d days'
|
||||
AND client_ip IS NOT NULL
|
||||
GROUP BY client_ip
|
||||
ORDER BY query_count DESC LIMIT 10
|
||||
""".formatted(days);
|
||||
|
||||
List<Map<String, Object>> topClients = queryJdbcTemplate.queryForList(topClientsSql);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("buckets", buckets);
|
||||
result.put("topClients", topClients);
|
||||
result.put("granularity", granularity);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package gc.mda.signal_batch.monitoring.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@ -31,7 +32,7 @@ public class QueryMetricsBufferService {
|
||||
unique_vessels, total_tracks, total_points, points_after_simplify,
|
||||
total_chunks, response_bytes,
|
||||
elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
|
||||
status
|
||||
status, client_ip
|
||||
) VALUES (
|
||||
?, ?, ?, now(),
|
||||
?, ?, ?, ?, ?,
|
||||
@ -39,7 +40,7 @@ public class QueryMetricsBufferService {
|
||||
?, ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?, ?,
|
||||
?
|
||||
?, ?
|
||||
)
|
||||
""";
|
||||
|
||||
@ -51,6 +52,27 @@ public class QueryMetricsBufferService {
|
||||
this.queryJdbcTemplate = queryJdbcTemplate;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void ensureClientIpColumn() {
|
||||
try {
|
||||
queryJdbcTemplate.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'signal' AND table_name = 't_query_metrics' AND column_name = 'client_ip'
|
||||
) THEN
|
||||
ALTER TABLE signal.t_query_metrics ADD COLUMN client_ip VARCHAR(45);
|
||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_client_ip ON signal.t_query_metrics(client_ip, created_at);
|
||||
END IF;
|
||||
END $$
|
||||
""");
|
||||
log.info("t_query_metrics client_ip column ensured");
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to ensure client_ip column: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 레코드를 버퍼에 추가 (lock-free)
|
||||
*/
|
||||
@ -97,7 +119,7 @@ public class QueryMetricsBufferService {
|
||||
m.getUniqueVessels(), m.getTotalTracks(), m.getTotalPoints(), m.getPointsAfterSimplify(),
|
||||
m.getTotalChunks(), m.getResponseBytes(),
|
||||
m.getElapsedMs(), m.getDbQueryMs(), m.getSimplifyMs(), m.getBackpressureEvents(),
|
||||
m.getStatus()
|
||||
m.getStatus(), m.getClientIp()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -130,5 +130,6 @@ public class QueryMetricsService {
|
||||
private final long simplifyMs;
|
||||
private final int backpressureEvents;
|
||||
private final String status;
|
||||
private final String clientIp;
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,5 +38,17 @@ CREATE TABLE IF NOT EXISTS signal.t_query_metrics (
|
||||
status VARCHAR(20) DEFAULT 'COMPLETED' -- 'COMPLETED' | 'CANCELLED' | 'ERROR' | 'TIMEOUT'
|
||||
);
|
||||
|
||||
-- client_ip 컬럼 추가 (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'signal' AND table_name = 't_query_metrics' AND column_name = 'client_ip'
|
||||
) THEN
|
||||
ALTER TABLE signal.t_query_metrics ADD COLUMN client_ip VARCHAR(45);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_created ON signal.t_query_metrics(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_type ON signal.t_query_metrics(query_type, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_client_ip ON signal.t_query_metrics(client_ip, created_at);
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user