feat(metrics): 쿼리 메트릭 사용자 ID 수집 + 대시보드 IP/ID 토글
GC_SESSION JWT 쿠키에서 인증된 사용자 email을 추출하여 쿼리 메트릭에 기록. 대시보드 Top 클라이언트를 IP 기준 또는 사용자 ID 기준으로 전환 가능. 백엔드: - WebSocket 핸드셰이크에서 GC_SESSION 쿠키 JWT payload → email 추출 - QueryMetric에 clientId 필드 추가, t_query_metrics에 client_id 컬럼 자동 생성 - timeseries API에 groupBy=ip|id 파라미터 추가 프론트엔드: - Dashboard Top 클라이언트 섹션에 IP/ID 세그먼트 토글 추가 - 토글 전환 시 즉시 재조회 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
8a97321a90
커밋
3333b2cec1
@ -68,7 +68,7 @@ export const monitorApi = {
|
||||
return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`)
|
||||
},
|
||||
|
||||
getQueryMetricsTimeSeries(days = 7): Promise<QueryMetricsTimeSeries> {
|
||||
return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}`)
|
||||
getQueryMetricsTimeSeries(days = 7, groupBy: 'ip' | 'id' = 'ip'): Promise<QueryMetricsTimeSeries> {
|
||||
return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}&groupBy=${groupBy}`)
|
||||
},
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ export default function Dashboard() {
|
||||
const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', [])
|
||||
const [queryTs, setQueryTs] = useCachedState<QueryMetricsTimeSeries | null>('dash.queryTs', null)
|
||||
const [days, setDays] = useState(7)
|
||||
const [clientGroupBy, setClientGroupBy] = useState<'ip' | 'id'>('ip')
|
||||
const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() =>
|
||||
localStorage.getItem('dashboard-query-charts') !== 'collapsed',
|
||||
)
|
||||
@ -51,8 +52,8 @@ 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])
|
||||
monitorApi.getQueryMetricsTimeSeries(days, clientGroupBy).then(setQueryTs).catch(() => {})
|
||||
}, POLL_INTERVAL, [days, clientGroupBy])
|
||||
|
||||
const memUsage = metrics
|
||||
? Math.round((metrics.memory.used / metrics.memory.max) * 100)
|
||||
@ -327,7 +328,21 @@ export default function Dashboard() {
|
||||
{/* Top Clients */}
|
||||
{queryTs.topClients.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium text-muted">{t('dashboard.topClients')}</div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted">{t('dashboard.topClients')}</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[var(--border-primary)] text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 ${clientGroupBy === 'ip' ? 'bg-[var(--accent-primary)] text-white' : 'text-[var(--text-secondary)]'}`}
|
||||
onClick={() => setClientGroupBy('ip')}
|
||||
>IP</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 ${clientGroupBy === 'id' ? 'bg-[var(--accent-primary)] text-white' : 'text-[var(--text-secondary)]'}`}
|
||||
onClick={() => setClientGroupBy('id')}
|
||||
>ID</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{queryTs.topClients.map((c, i) => {
|
||||
const maxCount = queryTs.topClients[0].query_count
|
||||
|
||||
@ -22,8 +22,10 @@ import org.springframework.http.server.ServletServerHttpRequest;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.security.Principal;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@ -180,11 +182,18 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
|
||||
String clientIp = extractClientIp(request);
|
||||
attributes.put("CLIENT_IP", clientIp);
|
||||
|
||||
// User-Agent 추출
|
||||
if (request instanceof ServletServerHttpRequest) {
|
||||
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
|
||||
|
||||
// User-Agent 추출
|
||||
String userAgent = servletRequest.getHeader("User-Agent");
|
||||
attributes.put("USER_AGENT", userAgent);
|
||||
|
||||
// GC_SESSION 쿠키에서 JWT email 추출 (guide 서비스 인증)
|
||||
String clientId = extractEmailFromJwtCookie(servletRequest);
|
||||
if (clientId != null) {
|
||||
attributes.put("CLIENT_ID", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -225,5 +234,43 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
|
||||
// ServletServerHttpRequest가 아닌 경우 기본값
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* GC_SESSION 쿠키에서 JWT payload의 email 클레임 추출.
|
||||
* JWT 검증은 nginx auth_request에서 이미 완료 — 여기서는 payload 디코딩만 수행.
|
||||
*/
|
||||
private String extractEmailFromJwtCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) return null;
|
||||
|
||||
String token = null;
|
||||
for (Cookie cookie : cookies) {
|
||||
if ("GC_SESSION".equals(cookie.getName())) {
|
||||
token = cookie.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (token == null || token.isEmpty()) return null;
|
||||
|
||||
try {
|
||||
// JWT: header.payload.signature — payload만 Base64URL 디코딩
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
|
||||
// 간단한 JSON 파싱 (Jackson 의존 없이): "email":"value" 추출
|
||||
int emailIdx = payload.indexOf("\"email\"");
|
||||
if (emailIdx < 0) return null;
|
||||
|
||||
int colonIdx = payload.indexOf(':', emailIdx);
|
||||
int quoteStart = payload.indexOf('"', colonIdx + 1);
|
||||
int quoteEnd = payload.indexOf('"', quoteStart + 1);
|
||||
if (quoteStart < 0 || quoteEnd < 0) return null;
|
||||
|
||||
return payload.substring(quoteStart + 1, quoteEnd);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -71,11 +71,17 @@ public class StompTrackController {
|
||||
}
|
||||
};
|
||||
|
||||
// 세션 속성에서 CLIENT_IP 추출
|
||||
// 세션 속성에서 CLIENT_IP, CLIENT_ID 추출
|
||||
String clientIp = null;
|
||||
String clientId = null;
|
||||
Map<String, Object> sessionAttrs = headerAccessor.getSessionAttributes();
|
||||
if (sessionAttrs != null && sessionAttrs.containsKey("CLIENT_IP")) {
|
||||
clientIp = (String) sessionAttrs.get("CLIENT_IP");
|
||||
if (sessionAttrs != null) {
|
||||
if (sessionAttrs.containsKey("CLIENT_IP")) {
|
||||
clientIp = (String) sessionAttrs.get("CLIENT_IP");
|
||||
}
|
||||
if (sessionAttrs.containsKey("CLIENT_ID")) {
|
||||
clientId = (String) sessionAttrs.get("CLIENT_ID");
|
||||
}
|
||||
}
|
||||
|
||||
// 비동기 스트리밍 시작 - 청크 모드 체크
|
||||
@ -86,7 +92,8 @@ public class StompTrackController {
|
||||
sessionId,
|
||||
chunk -> sendChunkedDataToUser(userId, chunk),
|
||||
statusCallback,
|
||||
clientIp
|
||||
clientIp,
|
||||
clientId
|
||||
);
|
||||
} else {
|
||||
trackStreamingService.streamTracks(
|
||||
|
||||
@ -160,7 +160,8 @@ public class QueryMetricsController {
|
||||
@GetMapping("/timeseries")
|
||||
@Operation(summary = "쿼리 메트릭 시계열", description = "시간별/일별 버킷 집계 + Top 10 클라이언트")
|
||||
public Map<String, Object> getTimeSeries(
|
||||
@Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days) {
|
||||
@Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days,
|
||||
@Parameter(description = "Top 클라이언트 그룹 기준 (ip | id)") @RequestParam(defaultValue = "ip") String groupBy) {
|
||||
|
||||
days = Math.min(days, 90);
|
||||
String granularity = days <= 7 ? "HOURLY" : "DAILY";
|
||||
@ -184,15 +185,17 @@ public class QueryMetricsController {
|
||||
|
||||
List<Map<String, Object>> buckets = queryJdbcTemplate.queryForList(bucketSql);
|
||||
|
||||
boolean groupById = "id".equalsIgnoreCase(groupBy);
|
||||
String clientColumn = groupById ? "client_id" : "client_ip";
|
||||
String topClientsSql = """
|
||||
SELECT client_ip, COUNT(*) AS query_count,
|
||||
SELECT %s AS client, 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
|
||||
AND %s IS NOT NULL
|
||||
GROUP BY %s
|
||||
ORDER BY query_count DESC LIMIT 10
|
||||
""".formatted(days);
|
||||
""".formatted(clientColumn, days, clientColumn, clientColumn);
|
||||
|
||||
List<Map<String, Object>> topClients = queryJdbcTemplate.queryForList(topClientsSql);
|
||||
|
||||
@ -200,6 +203,7 @@ public class QueryMetricsController {
|
||||
result.put("buckets", buckets);
|
||||
result.put("topClients", topClients);
|
||||
result.put("granularity", granularity);
|
||||
result.put("groupBy", groupById ? "id" : "ip");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -32,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, client_ip
|
||||
status, client_ip, client_id
|
||||
) VALUES (
|
||||
?, ?, ?, now(),
|
||||
?, ?, ?, ?, ?,
|
||||
@ -40,7 +40,7 @@ public class QueryMetricsBufferService {
|
||||
?, ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?
|
||||
?, ?, ?
|
||||
)
|
||||
""";
|
||||
|
||||
@ -73,6 +73,27 @@ public class QueryMetricsBufferService {
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void ensureClientIdColumn() {
|
||||
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_id'
|
||||
) THEN
|
||||
ALTER TABLE signal.t_query_metrics ADD COLUMN client_id VARCHAR(100);
|
||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_client_id ON signal.t_query_metrics(client_id, created_at);
|
||||
END IF;
|
||||
END $$
|
||||
""");
|
||||
log.info("t_query_metrics client_id column ensured");
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to ensure client_id column: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 레코드를 버퍼에 추가 (lock-free)
|
||||
*/
|
||||
@ -119,7 +140,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.getClientIp()
|
||||
m.getStatus(), m.getClientIp(), m.getClientId()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -131,5 +131,6 @@ public class QueryMetricsService {
|
||||
private final int backpressureEvents;
|
||||
private final String status;
|
||||
private final String clientIp;
|
||||
private final String clientId;
|
||||
}
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user