diff --git a/docs/test-results/evidence/issue-457/grafana-applied.md b/docs/test-results/evidence/issue-457/grafana-applied.md new file mode 100644 index 00000000..515c0eb4 --- /dev/null +++ b/docs/test-results/evidence/issue-457/grafana-applied.md @@ -0,0 +1,9 @@ +# fix457-grafana-01 — 실제 대시보드 적용·과거 화면 확인 + +- 기록 시각: 2026-10-10 02:12 KST. 실행 당시 정확한 API 호출 시각은 별도로 수집하지 못했다. +- 목적·위치: 인증된 GCP 관측 VM의 Grafana. 비밀은 기존 컨테이너 내부 파일에서만 읽고 출력·파일·Git에 기록하지 않았다. +- 방법: 현재 패널 ID·제목·PromQL을 머지된 기준 JSON과 비교, Dashboard API로 갱신, Annotation API로 기존 실행의 검증된 시작·복구 시각 2건 등록, GET API와 브라우저 화면으로 재조회. +- 성공 기준: 기존 패널 보존, 새 누적 그래프·결과 불명 카드 표시, 정확한 표식 2건, 기존 실행 5xx 1건. +- 결과: 기준 대시보드 **v8/18패널**과 저장소 기준 패널 ID·제목·PromQL 일치. 적용 후 **v9/20패널**, annotation **2건** 저장. `ha1009163528`의 절대 시간 범위에서 카드 **2xx 3,599·3xx 0·4xx 0·5xx 1·결과 불명 0건**, HTTP 500 누적 **0→1 계단**, OpenProxy A 포트 불가 구간과 시작·복구 세로선 확인. +- 표식 원본: 2026-10-10 **01:38:39.554 KST**(종료 명령 반환·포트 불응 확인), **01:39:16.246 KST**(프로세스 1·포트 준비 확인). API 재조회에서 동일한 두 시각과 실행 ID 태그 확인. +- 한계: UI 화면은 요청 ID의 DB 최종 상태를 증명하지 않는다. Annotation 레코드는 Grafana DB에 있고 Dashboard JSON에는 포함되지 않는다. diff --git a/docs/test-results/evidence/issue-457/live-query.md b/docs/test-results/evidence/issue-457/live-query.md new file mode 100644 index 00000000..35ffd654 --- /dev/null +++ b/docs/test-results/evidence/issue-457/live-query.md @@ -0,0 +1,8 @@ +# fix457-live-query-01 — 기존 실행의 Prometheus 쿼리 재측정 + +- 기록 시각: 2026-10-10 02:12 KST. 실행 당시 정확한 조회 시작 시각은 수집하지 못했다. +- 목적·위치: GCP 관측 VM 내부 Docker 네트워크의 Prometheus에서 기존 `ha1009163528` 시계열을 읽기 전용 조회. 새 k6·장애 실행은 하지 않았다. +- 방법: Prometheus `/api/v1/query_range`에서 원시 `k6_ha_outcome_500_total`과 새 `sum(last_over_time(...[10m])) or VU*0` 쿼리를 절대 시간 범위로 조회. 주소·인증값은 기록하지 않았다. +- 성공 기준: 500 누적값이 첫 오류 전 0, 첫 Counter 수신 이후 1이고 이후 유지. +- 결과: 원시 500 Counter는 **11개 표본**이 저장됐고 모두 값 1. 첫 표본은 **2026-10-10 01:38:40 KST**. 새 쿼리는 5초 간격 조회에서 **01:38:20~35의 0 → 01:38:40~55의 1**을 반환했다. +- 해석: 이전 그래프가 희미했던 이유를 “원시 표본이 한 개뿐”으로 단정할 수 없다. 첫 표본이 이미 1이므로 `rate()`는 그 이전 증가를 계산하지 못한다. 조회 해상도와 Remote Write 주기로 오류 완료 시각과 최대 수초 차이 날 수 있다. diff --git a/docs/test-results/evidence/issue-457/promql-attempt-01.md b/docs/test-results/evidence/issue-457/promql-attempt-01.md new file mode 100644 index 00000000..effc9a2b --- /dev/null +++ b/docs/test-results/evidence/issue-457/promql-attempt-01.md @@ -0,0 +1,8 @@ +# fix457-promql-attempt-01 — 합성 Fixture 첫 시도 + +- 기록 시각: 2026-10-10 02:12 KST. 실행 당시 ID와 정확한 시각을 사전 기록하지 못해 이 ID는 사후 부여했다. +- 목적·위치: 로컬 Docker의 기존 `prom/prometheus:v3.5.5` 이미지에서 `promtool test rules`로 단발 오류 쿼리를 검증. +- 명령: `promtool test rules monitoring/grafana/tests/ha_dashboard_http_classes.yml` (읽기 전용 마운트 컨테이너). +- 기대: 합성 Counter 한 표본을 새 쿼리가 1로 유지. +- 결과: **실패 2건**. `rSparse` 500·결과 불명 기대 1에 대해 실제 0. 나머지 기존 테스트는 실패로 보고되지 않았다. +- 다음 조치: 표본 평가 시각이 맞는지 별도 재시험. 이 단계에서는 제품 쿼리 오류인지 Fixture 문제인지 확정하지 않았다. diff --git a/docs/test-results/evidence/issue-457/promql-attempt-02.md b/docs/test-results/evidence/issue-457/promql-attempt-02.md new file mode 100644 index 00000000..db964ac9 --- /dev/null +++ b/docs/test-results/evidence/issue-457/promql-attempt-02.md @@ -0,0 +1,8 @@ +# fix457-promql-attempt-02 — 합성 평가 시각 조정 + +- 기록 시각: 2026-10-10 02:12 KST. 실행 당시 ID와 정확한 시각을 사전 기록하지 못해 이 ID는 사후 부여했다. +- 목적·위치: 로컬 Docker Prometheus v3.5.5에서 합성 표본의 평가 시각을 500=10초·결과 불명=20초로 맞춤. +- 명령: `promtool test rules monitoring/grafana/tests/ha_dashboard_http_classes.yml` (읽기 전용 마운트 컨테이너). +- 기대: 시각을 맞추면 두 Counter가 1로 평가. +- 결과: **실패 2건**. 두 기대값 모두 여전히 실제 0. +- 다음 조치: 누적 계산 이전의 원시 시계열 존재 여부를 직접 테스트. diff --git a/docs/test-results/evidence/issue-457/promql-attempt-03.md b/docs/test-results/evidence/issue-457/promql-attempt-03.md new file mode 100644 index 00000000..b13385e1 --- /dev/null +++ b/docs/test-results/evidence/issue-457/promql-attempt-03.md @@ -0,0 +1,8 @@ +# fix457-promql-attempt-03 — 원시 시계열 확인 + +- 기록 시각: 2026-10-10 02:12 KST. 실행 당시 ID와 정확한 시각을 사전 기록하지 못해 이 ID는 사후 부여했다. +- 목적·위치: 로컬 Docker Prometheus v3.5.5에서 `rSparse` 원시 500 Counter 자체를 조회. +- 명령: `promtool test rules monitoring/grafana/tests/ha_dashboard_http_classes.yml` (원시 Counter 기대값을 추가한 Fixture). +- 기대: 원시 Counter 1, 누적 500 1, 누적 결과 불명 1. +- 결과: **실패 3건**. 원시 Counter 결과가 `nil`이고 두 누적 쿼리가 0. 맨 앞을 누락 표본(`_`)으로 둔 이 Fixture는 이 실행에서 원시 시계열을 만들지 못했다. +- 다음 조치: 한 표본을 Fixture 시작 시각에 두고, 이후에는 표본을 생략해 `rate()`와 `last_over_time()` 차이를 확인. diff --git a/docs/test-results/evidence/issue-457/promql-final.md b/docs/test-results/evidence/issue-457/promql-final.md new file mode 100644 index 00000000..e01b41a0 --- /dev/null +++ b/docs/test-results/evidence/issue-457/promql-final.md @@ -0,0 +1,9 @@ +# fix457-promql-final — 희소 Counter 최종 합성 검증 + +- 기록 시각: 2026-10-10 02:12 KST. 실행 전 이 ID와 성공 기준을 선언했다. +- 목적·위치: 로컬 Docker의 기존 `prom/prometheus:v3.5.5` 이미지, 실행 후 컨테이너 자동 제거. +- 명령: `promtool test rules monitoring/grafana/tests/ha_dashboard_http_classes.yml` (저장소 파일을 읽기 전용 마운트). +- 입력: `rSparse`에 VU 연속 표본, HTTP 500 Counter 1표본, 결과 불명 Counter 1표본. 다른 시험 그룹은 403 전용·201 전용·미존재 실행. +- 성공 기준: 500·결과 불명 누적값 1, 500의 `rate(...[15s])` 결과 없음, 기존 0 대체 기대값 유지. +- 결과: **SUCCESS**. PromQL 기대값 총 12개 통과, 컨테이너 회수. +- 한계: 시작 시각에 한 표본을 둔 합성 입력이다. 실제 장애 실행의 0→1 계단은 별도 GCP 과거 데이터 조회로 검증했다. diff --git a/docs/test-results/evidence/issue-457/static-final.md b/docs/test-results/evidence/issue-457/static-final.md new file mode 100644 index 00000000..df748b1f --- /dev/null +++ b/docs/test-results/evidence/issue-457/static-final.md @@ -0,0 +1,8 @@ +# fix457-static-final — 로컬 대시보드 정적 검증 + +- 기록 시각: 2026-10-10 02:12 KST. 실행 직전 식별자를 선언했으며 명령별 초 단위 시각은 별도로 수집하지 않았다. +- 목적·위치: 분리된 로컬 `fix/457` 작업 공간에서 JSON, 패널 겹침, 쿼리·데이터소스 경계를 확인. +- 명령: `python3 -m unittest monitoring.grafana.tests.test_ha_dashboard -v`; `python3 -m json.tool monitoring/grafana/dashboards/docgrid-ha-demo.json`; `git diff --check`. +- 성공 기준: Python 12/12, JSON 파싱, diff 공백 검사 모두 통과. +- 결과: Python **12/12 통과**(0.002초), JSON 유효, `git diff --check` 오류 0건. +- 한계: 정적 검사는 실제 Grafana 화면·Prometheus 과거 시계열을 증명하지 않는다. 별도 실행에서 확인했다. diff --git a/docs/test-results/opensql-ha-dashboard-sparse-failure-20261010.md b/docs/test-results/opensql-ha-dashboard-sparse-failure-20261010.md new file mode 100644 index 00000000..47390f4b --- /dev/null +++ b/docs/test-results/opensql-ha-dashboard-sparse-failure-20261010.md @@ -0,0 +1,31 @@ +# OpenSQL HA 대시보드 단발 오류·장애 시각 표시 검증 + +- 기록 시각: 2026-10-10 02:12 KST. 일부 진단 재시도의 정확한 시작 시각은 수집하지 못해 아래 실행별 로그에 그 한계를 적었다. +- 변경 범위: Grafana 시연 Dashboard JSON·설명·정적/PromQL 회귀 시험. 백엔드 코드, DB, OpenProxy, k6 부하 스크립트는 변경하지 않았다. +- 기존 원본 실행: `ha1009163528` (30 req/s × 120초, HTTP 201 3,599건·500 1건·결과 불명 0건). 이 문서 작업 중 새 장애나 부하를 실행하지 않았다. 원래 요청 ID 원장과 DB 대조 원본은 비공개 보관하며 이 PR에 복사하지 않는다. + +## 변경 전후와 판정 + +| 항목 | 이전 | 변경 후 실제 확인 | 해석·한계 | +|---|---|---|---| +| 500 표시 | `rate(...[30s])` 결과별 응답/초에 포함 | `last_over_time(...[10m])` 누적 계단에서 기존 실행의 **0→1건** | 실제 첫 Counter 표본은 이미 1이었다. 장애 명령 반환 시각과 그래프 시각은 Remote Write 전송 주기 때문에 다를 수 있다. | +| 결과 불명 | 누적 카드 없음 | 별도 카드 **0건** | `HTTP 2xx~5xx` 카드와 분리. DB 반영 여부를 뜻하지 않는다. | +| 성공 처리율 | 30초 이동 창 | 15초 이동 창 | 더 짧은 관측 창이며 정확한 장애 RTO가 아니다. | +| 앱 5xx | 초당 비율·30초 창 | 15초 증가량 패널 | Prometheus 보간값이라 정확한 1건과 숫자가 같지 않을 수 있다. LB 자체 오류는 빠질 수 있다. | +| 장애 시각 | built-in annotation 조회만 설정 | 원본 기록의 시작·복구 시각 **2건**을 Grafana DB에 기록, 과거 화면에서 세로선 확인 | JSON만 다른 Grafana에 가져오면 이 두 레코드는 함께 복사되지 않는다. | + +### 별도 실행 기록 + +| 실행 ID | 목적·위치 | 결과 | 기록 | +|---|---|---|---| +| `fix457-static-final` | 로컬 JSON/배치/정적 단위 시험 | 12/12 통과, JSON 유효, diff 오류 없음 | [정적 검증](evidence/issue-457/static-final.md) | +| `fix457-promql-attempt-01` | 로컬 Docker 희소 표본 첫 합성 시험 | 2개 기대값 실패 | [1차 진단](evidence/issue-457/promql-attempt-01.md) | +| `fix457-promql-attempt-02` | 평가 시각 조정 재시험 | 2개 기대값 실패 | [2차 진단](evidence/issue-457/promql-attempt-02.md) | +| `fix457-promql-attempt-03` | 원시 입력 시계열 추가 확인 | 3개 기대값 실패 | [3차 진단](evidence/issue-457/promql-attempt-03.md) | +| `fix457-promql-final` | 합성 Counter 한 표본·0 대체·`rate()` 한계 | 최종 12개 PromQL 기대값 통과 | [최종 PromQL](evidence/issue-457/promql-final.md) | +| `fix457-live-query-01` | GCP 관측 VM 내부 Prometheus 과거 실행 읽기 | 원시 500 표본 11개; 새 쿼리 0→1 | [실측 쿼리](evidence/issue-457/live-query.md) | +| `fix457-grafana-01` | 인증된 Grafana 적용·과거 화면 조회 | Dashboard v8→v9, 20패널, annotation 2건, 카드 3,599/0/0/1/0 | [화면 확인](evidence/issue-457/grafana-applied.md) | + +초기 PromQL 실패 3회는 제품 장애가 아니다. 합성 Fixture의 맨 앞 누락 표본(`_`)만으로는 이 `promtool` 실행에서 늦게 시작하는 원시 시계열을 만들지 못했다. 세 번째 실행에서 원시 시계열 자체가 없음을 확인하고, 한 표본을 시작 시각에 두는 Fixture로 바꿨다. 이 입력에서는 `rate()`가 값을 내지 못하지만 새 누적 쿼리는 1을 유지한다. 실제 GCP 과거 시계열에서도 새 쿼리의 0→1 변화는 별도로 확인했다. + +장애 표식의 원본 시간은 2026-10-10 **01:38:39.554 KST**(종료 명령 반환·포트 불응 확인)와 **01:39:16.246 KST**(프로세스 1·포트 준비 확인)이다. 이 둘의 차이를 앱 쓰기 공백이나 RTO로 부르지 않는다. Grafana annotation 레코드는 서버 DB에 있으며 Dashboard JSON에는 조회 설정만 들어간다. DB 요청 ID 대조는 이번 수정에서 재실행하지 않았으므로 Grafana 그래프만으로 RPO를 주장하지 않는다. diff --git a/monitoring/grafana/README.md b/monitoring/grafana/README.md index ae998643..2be59000 100644 --- a/monitoring/grafana/README.md +++ b/monitoring/grafana/README.md @@ -40,19 +40,28 @@ Git에서 다음 파일을 수정한다. `dashboards/docgrid-ha-demo.json`은 별도 GCP 관측 VM에서 사용하는 수동 가져오기용 Dashboard다. 번들 Data Source가 아니라 인증된 관측 VM의 Prometheus UID `ha-prometheus`를 요구한다. 앱 A/B, Patroni 3노드의 수집·역할 상태와 관측 VM에서 본 OpenProxy A/B TCP 포트 연결을 타임라인으로 표시하고, -현재 primary·timeline·연결 가능한 프록시 포트 수, 실행 ID별 HTTP 2xx·3xx·4xx·5xx 누적 응답 건수, 앱별 5xx, -Hikari 대기·연결 timeout, standby WAL 수신·재생 차이, k6 결과별 응답/초와 활성 VU를 보여준다. +현재 primary·timeline·연결 가능한 프록시 포트 수, 실행 ID별 HTTP 2xx·3xx·4xx·5xx와 결과 불명 누적 건수, +앱별 최근 15초 5xx 증가량, Hikari 대기·연결 timeout, standby WAL 수신·재생 차이, +k6 HTTP 201 응답/초·실패 누적 계단과 활성 VU를 보여준다. `run_id` 변수는 k6 VU 지표에서 읽어 201 응답이 0건인 실행도 선택할 수 있게 한다. 각 코드 대역 카드에 해당 응답이 없더라도 선택한 실행의 VU 지표가 수집됐다면 0건으로 표시한다. `All`이면 최근 10분 이내 여러 실행의 건수가 합산될 수 있으므로 영상에서는 특정 실행 ID를 선택한다. 새 실행 ID가 자동 새로고침만으로 목록에 나타나는지는 검증되지 않았으므로 촬영 전 목록을 다시 열어 확인한다. 코드 대역 카드에는 응답이 오지 않은 -결과 불명 요청이 포함되지 않으며, 이 건수는 아래 결과별 응답/초 그래프와 안전 원장에서 별도로 확인한다. +결과 불명 요청이 포함되지 않으므로 별도 결과 불명 카드와 안전 원장에서 확인한다. + +HTTP 201 응답/초는 15초 이동 평균이다. 500·503·기타 예상 외 응답·결과 불명은 +`last_over_time(...[10m])`으로 마지막 Counter 표본을 유지하는 누적 계단으로 따로 그린다. +첫 오류 표본이 이미 1이면 `rate()`는 그 이전 0→1 사건을 계산하지 못할 수 있다. +계단의 시각은 k6 Remote Write 전송 주기만큼 실제 응답 완료보다 늦을 수 있고, +10분 뒤에는 값이 화면에서 사라질 수 있다. 오래된 실행은 실행 ID와 절대 시간 범위를 함께 선택한다. +앱별 5xx `increase(...[15s])`는 Prometheus의 보간값이므로 정확한 총 건수처럼 읽지 않는다. 이 화면의 `up=1`은 지표 수집 성공일 뿐 HTTP 쓰기 성공이 아니며, `up=0`만으로 VM 중단을 확정하지 않는다. 앱별 5xx는 LB가 직접 반환한 오류를 포함하지 않을 수 있다. OpenProxy 포트 연결은 관측 VM에서 Blackbox Exporter의 TCP probe로 확인하며 SQL 성공을 보장하지 않는다. 앱→프록시 연결 경로는 현재 Prometheus에 직접 수집하지 않으므로 운영자 장애 로그와 HTTP 원장을 -함께 본다. 장애 주입·복구 annotation은 설정만 있으며 실제 이벤트를 기록해야 화면에 나타난다. 그래프만으로 +함께 본다. 장애 주입·복구 annotation은 Grafana DB에 해당 실행의 검증된 시각을 기록해야 화면에 나타난다. +Dashboard JSON만 다른 Grafana로 가져와도 과거 annotation 레코드는 복사되지 않는다. 그래프만으로 성공 응답 데이터의 보존이나 RPO를 주장하지 않으며 요청 ID별 DB 대조 결과를 별도로 확인한다. Remote Write의 누적 Trend p95/p99는 복구 시점 지연으로 오해하기 쉬워 패널에서 제외했다. 실행·장애 계측 결과는 [GCP HA 대시보드 검증 기록](../../docs/test-results/opensql-ha-live-dashboard-fault-rehearsal-20261009.md)에 둔다. diff --git a/monitoring/grafana/dashboards/docgrid-ha-demo.json b/monitoring/grafana/dashboards/docgrid-ha-demo.json index 5809379a..c2c4117e 100644 --- a/monitoring/grafana/dashboards/docgrid-ha-demo.json +++ b/monitoring/grafana/dashboards/docgrid-ha-demo.json @@ -1,6 +1,6 @@ { "annotations": {"list": [{"builtIn": 1, "datasource": {"type": "grafana", "uid": "-- Grafana --"}, "enable": true, "hide": false, "iconColor": "rgba(255, 152, 48, 1)", "name": "장애 주입·복구 기록", "type": "dashboard"}]}, - "description": "GCP OpenSQL HA 시연: 앱·DB 지표 수집, Patroni 역할, 관측 VM에서 OpenProxy 포트 연결, 실행 ID별 k6 HTTP 응답코드 대역 누적 건수와 결과별 응답/초를 같은 시간축에서 본다. 결과 불명처럼 HTTP 응답이 없는 요청은 코드 대역 카드에 포함되지 않는다. 앱→프록시 연결 경로는 아직 직접 수집하지 않는다. 정합성은 요청 ID 원장과 DB 대조로 판정한다.", + "description": "GCP OpenSQL HA 시연: 앱·DB 지표 수집, Patroni 역할, 관측 VM에서 OpenProxy 포트 연결, 실행 ID별 k6 HTTP 응답코드 대역·결과 불명 누적 건수, 201 응답/초와 실패 누적 변화를 같은 시간축에서 본다. 결과 불명처럼 HTTP 응답이 없는 요청은 코드 대역 카드에 포함되지 않는다. 앱→프록시 연결 경로는 아직 직접 수집하지 않는다. 정합성은 요청 ID 원장과 DB 대조로 판정한다.", "editable": false, "graphTooltip": 1, "id": null, @@ -88,7 +88,7 @@ "title": "앱 Hikari 연결 대기", "description": "앱별 Hikari pending 연결 요청 수. DB 전환 중 풀 대기가 쌓이는지 관측한다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 0, "y": 20, "w": 12, "h": 4}, + "gridPos": {"x": 0, "y": 25, "w": 12, "h": 4}, "fieldConfig": {"defaults": {"min": 0, "unit": "short", "custom": {"axisSoftMax": 10, "showPoints": "never"}}, "overrides": []}, "targets": [{"refId": "A", "expr": "hikaricp_connections_pending{job=\"docgrid-app\"}", "legendFormat": "{{node}} / {{pool}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] }, @@ -98,19 +98,19 @@ "title": "standby 수신 후 WAL 재생 대기", "description": "각 standby가 이미 받은 WAL과 재생한 WAL의 차이(Byte). primary와 standby 사이 전체 복제 지연이나 RPO는 이 값만으로 판정할 수 없다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 12, "y": 24, "w": 12, "h": 4}, + "gridPos": {"x": 12, "y": 29, "w": 12, "h": 4}, "fieldConfig": {"defaults": {"min": 0, "unit": "bytes", "custom": {"showPoints": "never"}}, "overrides": []}, "targets": [{"refId": "A", "expr": "clamp_min(patroni_xlog_received_location{job=\"patroni\"} - patroni_xlog_replayed_location{job=\"patroni\"}, 0) and on(instance) (patroni_replica{job=\"patroni\"} == 1)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] }, { "id": 11, "type": "timeseries", - "title": "앱별 HTTP 5xx/초", - "description": "앱 Actuator가 관측한 5xx만 표시한다. LB가 앱에 전달하지 않고 반환한 5xx는 포함되지 않을 수 있어 k6 전체 결과와 함께 읽는다.", + "title": "앱별 HTTP 5xx 증분 (최근 15초)", + "description": "앱 Actuator의 최근 15초 5xx Counter 증가량(건/15초, Prometheus 보간값). 정확한 총 오류 건수가 아니며 LB가 앱에 전달하지 않고 반환한 5xx는 포함되지 않을 수 있다. k6 전체 결과와 함께 읽는다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 0, "y": 24, "w": 12, "h": 4}, - "fieldConfig": {"defaults": {"min": 0, "unit": "reqps", "custom": {"axisSoftMax": 10, "showPoints": "never"}}, "overrides": []}, - "targets": [{"refId": "A", "expr": "sum by (node) (rate(http_server_requests_seconds_count{job=\"docgrid-app\",status=~\"5..\"}[30s])) or on(node) (sum by (node) (up{job=\"docgrid-app\"} == 1) * 0)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + "gridPos": {"x": 0, "y": 29, "w": 12, "h": 4}, + "fieldConfig": {"defaults": {"min": 0, "unit": "short", "custom": {"axisSoftMax": 2, "showPoints": "never"}}, "overrides": []}, + "targets": [{"refId": "A", "expr": "sum by (node) (increase(http_server_requests_seconds_count{job=\"docgrid-app\",status=~\"5..\"}[15s])) or on(node) (sum by (node) (up{job=\"docgrid-app\"} == 1) * 0)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] }, { "id": 12, @@ -118,7 +118,7 @@ "title": "앱별 Hikari 연결 timeout 증분 (최근 15초)", "description": "최근 15초 동안 증가한 Hikari 연결 timeout 수. 앱 지표 수집 실패는 0으로 숨기지 않는다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 12, "y": 20, "w": 12, "h": 4}, + "gridPos": {"x": 12, "y": 25, "w": 12, "h": 4}, "fieldConfig": {"defaults": {"min": 0, "unit": "short", "custom": {"axisSoftMax": 10, "showPoints": "never"}}, "overrides": []}, "targets": [{"refId": "A", "expr": "sum by (node) (increase(hikaricp_connections_timeout_total{job=\"docgrid-app\"}[15s])) or on(node) (sum by (node) (up{job=\"docgrid-app\"} == 1) * 0)", "legendFormat": "{{node}}", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] }, @@ -133,18 +133,12 @@ { "id": 7, "type": "timeseries", - "title": "HTTP 결과별 응답/초", - "description": "응답이 끝난 시각의 k6 Counter를 30초 창으로 계산한다. 기타 예상 외 응답에는 201 이외의 2xx도 포함한다. timeout·결과 불명은 응답 지연만큼 장애 주입 시각보다 늦게 나타날 수 있다.", + "title": "HTTP 201 응답/초", + "description": "응답이 끝난 시각의 k6 HTTP 201 Counter를 최근 15초 창의 초당 평균으로 계산한다. 이동 창이므로 시작·회복 시각이나 총 성공 건수는 원장과 별도로 판정한다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, "gridPos": {"x": 0, "y": 14, "w": 16, "h": 6}, "fieldConfig": {"defaults": {"min": 0, "unit": "reqps", "custom": {"showPoints": "never"}}, "overrides": []}, - "targets": [ - {"refId": "A", "expr": "sum(rate(k6_ha_outcome_201_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "HTTP 201", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, - {"refId": "B", "expr": "sum(rate(k6_ha_outcome_500_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "HTTP 500", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, - {"refId": "C", "expr": "sum(rate(k6_ha_outcome_503_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "HTTP 503", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, - {"refId": "D", "expr": "sum(rate(k6_ha_outcome_other_failed_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "기타 예상 외 응답", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, - {"refId": "E", "expr": "sum(rate(k6_ha_outcome_unknown_total{run_id=~\"$run_id\"}[30s]))", "legendFormat": "결과 불명", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}} - ] + "targets": [{"refId": "A", "expr": "sum(rate(k6_ha_outcome_201_total{run_id=~\"$run_id\"}[15s]))", "legendFormat": "HTTP 201", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] }, { "id": 8, @@ -162,7 +156,7 @@ "title": "2xx 누적 응답", "description": "선택한 실행 ID에서 HTTP 2xx 응답을 받은 누적 건수. k6 VU 지표가 있고 2xx 응답이 없으면 0으로 표시한다. 결과 불명은 포함하지 않는다. All 선택 시 최근 10분에 기록된 모든 실행의 합계다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 0, "y": 11, "w": 6, "h": 3}, + "gridPos": {"x": 0, "y": 11, "w": 4, "h": 3}, "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []}, "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_2xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "2xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] @@ -173,7 +167,7 @@ "title": "3xx 누적 응답", "description": "선택한 실행 ID에서 HTTP 3xx 응답을 받은 누적 건수. k6 VU 지표가 있고 3xx 응답이 없으면 0으로 표시한다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 6, "y": 11, "w": 6, "h": 3}, + "gridPos": {"x": 4, "y": 11, "w": 4, "h": 3}, "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "yellow", "value": 1}]}}, "overrides": []}, "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_3xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "3xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] @@ -184,7 +178,7 @@ "title": "4xx 누적 응답", "description": "선택한 실행 ID에서 HTTP 4xx 응답을 받은 누적 건수. 결과 불명은 포함하지 않는다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 12, "y": 11, "w": 6, "h": 3}, + "gridPos": {"x": 8, "y": 11, "w": 4, "h": 3}, "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "yellow", "value": 1}]}}, "overrides": []}, "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_4xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "4xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] @@ -195,10 +189,36 @@ "title": "5xx 누적 응답", "description": "선택한 실행 ID에서 HTTP 5xx 응답을 받은 누적 건수. 결과 불명은 포함하지 않는다.", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, - "gridPos": {"x": 18, "y": 11, "w": 6, "h": 3}, + "gridPos": {"x": 12, "y": 11, "w": 6, "h": 3}, "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []}, "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_http_5xx_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "5xx", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 19, + "type": "stat", + "title": "결과 불명 누적", + "description": "선택한 실행 ID에서 HTTP 응답을 받지 못해 결과를 모르는 누적 요청 수. DB 반영 여부는 요청 ID별 대조가 필요하다. 선택 실행의 VU 지표가 있고 결과 불명이 없으면 0으로 표시한다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 18, "y": 11, "w": 6, "h": 3}, + "fieldConfig": {"defaults": {"unit": "none", "decimals": 0, "noValue": "시험 지표 없음", "thresholds": {"mode": "absolute", "steps": [{"color": "gray", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []}, + "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "value", "colorMode": "value", "graphMode": "none"}, + "targets": [{"refId": "A", "expr": "sum(last_over_time(k6_ha_outcome_unknown_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "instant": true, "legendFormat": "결과 불명", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}] + }, + { + "id": 20, + "type": "timeseries", + "title": "HTTP 실패·결과 불명 누적", + "description": "선택 실행의 k6 결과 Counter 최종 표본을 10분 유지해 단발 실패도 계단으로 표시한다. 500·503·기타 예상 외 응답·결과 불명은 서로 다른 분류이며, k6 전송 주기만큼 그래프 시각이 늦을 수 있다. SQL 결과는 요청 ID 원장·DB 대조로 확인한다.", + "datasource": {"type": "prometheus", "uid": "ha-prometheus"}, + "gridPos": {"x": 0, "y": 20, "w": 24, "h": 5}, + "fieldConfig": {"defaults": {"min": 0, "unit": "short", "decimals": 0, "custom": {"axisSoftMax": 5, "showPoints": "never", "lineInterpolation": "stepAfter"}}, "overrides": []}, + "targets": [ + {"refId": "A", "expr": "sum(last_over_time(k6_ha_outcome_500_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "legendFormat": "HTTP 500", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "B", "expr": "sum(last_over_time(k6_ha_outcome_503_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "legendFormat": "HTTP 503", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "C", "expr": "sum(last_over_time(k6_ha_outcome_other_failed_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "legendFormat": "기타 예상 외 응답", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}}, + {"refId": "D", "expr": "sum(last_over_time(k6_ha_outcome_unknown_total{run_id=~\"$run_id\"}[10m])) or (max(last_over_time(k6_vus{run_id=~\"$run_id\"}[10m])) * 0)", "legendFormat": "결과 불명", "datasource": {"type": "prometheus", "uid": "ha-prometheus"}} + ] } ], "refresh": "5s", diff --git a/monitoring/grafana/tests/ha_dashboard_http_classes.yml b/monitoring/grafana/tests/ha_dashboard_http_classes.yml index 62344ee0..76c46a86 100644 --- a/monitoring/grafana/tests/ha_dashboard_http_classes.yml +++ b/monitoring/grafana/tests/ha_dashboard_http_classes.yml @@ -51,3 +51,36 @@ tests: - expr: 'sum(last_over_time(k6_ha_http_2xx_total{run_id=~"missing"}[10m])) or (max(last_over_time(k6_vus{run_id=~"missing"}[10m])) * 0)' eval_time: 3m exp_samples: [] + # A one-off 500 has one Counter sample: rate() cannot show it, last_over_time() can. + - interval: 5s + input_series: + - series: 'k6_vus{run_id="rSparse"}' + values: '1 1 1 1 1 1 1' + - series: 'k6_ha_outcome_500_total{run_id="rSparse"}' + values: '1 _ _ _ _ _ _' + - series: 'k6_ha_outcome_unknown_total{run_id="rSparse"}' + values: '1 _ _ _ _ _ _' + promql_expr_test: + - expr: 'sum(last_over_time(k6_ha_outcome_500_total{run_id=~"rSparse"}[10m])) or (max(last_over_time(k6_vus{run_id=~"rSparse"}[10m])) * 0)' + eval_time: 0s + exp_samples: + - labels: '{}' + value: 1 + - expr: 'sum(last_over_time(k6_ha_outcome_500_total{run_id=~"rSparse"}[10m])) or (max(last_over_time(k6_vus{run_id=~"rSparse"}[10m])) * 0)' + eval_time: 5s + exp_samples: + - labels: '{}' + value: 1 + - expr: 'k6_ha_outcome_500_total{run_id="rSparse"}' + eval_time: 5s + exp_samples: + - labels: '{__name__="k6_ha_outcome_500_total",run_id="rSparse"}' + value: 1 + - expr: 'sum(rate(k6_ha_outcome_500_total{run_id=~"rSparse"}[15s]))' + eval_time: 5s + exp_samples: [] + - expr: 'sum(last_over_time(k6_ha_outcome_unknown_total{run_id=~"rSparse"}[10m])) or (max(last_over_time(k6_vus{run_id=~"rSparse"}[10m])) * 0)' + eval_time: 5s + exp_samples: + - labels: '{}' + value: 1 diff --git a/monitoring/grafana/tests/test_ha_dashboard.py b/monitoring/grafana/tests/test_ha_dashboard.py index 386f7db8..ed234a0c 100644 --- a/monitoring/grafana/tests/test_ha_dashboard.py +++ b/monitoring/grafana/tests/test_ha_dashboard.py @@ -63,7 +63,7 @@ def test_outcome_buckets_include_other_and_unknown(self): self.assertNotIn("p95", expressions) def test_http_class_cards_keep_received_statuses_separate_from_unknown(self): - """A selected run exposes four cumulative code classes alongside rates.""" + """A selected run separates four HTTP classes from unknown outcomes.""" panels = {panel["id"]: panel for panel in self.dashboard["panels"]} promql_fixture = ( DASHBOARD.parent.parent / "tests" / "ha_dashboard_http_classes.yml" @@ -87,6 +87,11 @@ def test_http_class_cards_keep_received_statuses_separate_from_unknown(self): ) self.assertIn("결과 불명", self.dashboard["description"]) self.assertLess(panels[15]["gridPos"]["y"], panels[7]["gridPos"]["y"]) + unknown = panels[19] + self.assertEqual("stat", unknown["type"]) + self.assertIn("k6_ha_outcome_unknown_total", unknown["targets"][0]["expr"]) + self.assertIn("last_over_time", unknown["targets"][0]["expr"]) + self.assertEqual("gray", unknown["fieldConfig"]["defaults"]["thresholds"]["steps"][0]["color"]) def test_run_filter_and_interpretation(self): """A recorded run is selectable, and RPO remains an external-DB claim.""" @@ -159,11 +164,32 @@ def test_app_error_and_pool_timeout_cannot_replace_k6_result(self): panels = {panel["id"]: panel for panel in self.dashboard["panels"]} self.assertIn("http_server_requests_seconds_count", panels[11]["targets"][0]["expr"]) self.assertIn("LB", panels[11]["description"]) + self.assertIn("increase(", panels[11]["targets"][0]["expr"]) + self.assertIn("[15s]", panels[11]["targets"][0]["expr"]) + self.assertIn("15초", panels[11]["title"]) + self.assertEqual("short", panels[11]["fieldConfig"]["defaults"]["unit"]) self.assertIn("hikaricp_connections_timeout_total", panels[12]["targets"][0]["expr"]) self.assertIn("[15s]", panels[12]["targets"][0]["expr"]) self.assertIn("axisSoftMax", panels[12]["fieldConfig"]["defaults"]["custom"]) self.assertEqual("never", panels[7]["fieldConfig"]["defaults"]["custom"]["showPoints"]) - self.assertIn("k6_ha_outcome_500_total", panels[7]["targets"][1]["expr"]) + self.assertEqual(1, len(panels[7]["targets"])) + self.assertIn("k6_ha_outcome_201_total", panels[7]["targets"][0]["expr"]) + self.assertIn("[15s]", panels[7]["targets"][0]["expr"]) + + def test_sparse_failures_are_cumulative_steps(self): + """A single 500 sample must not depend on two samples for rate().""" + panels = {panel["id"]: panel for panel in self.dashboard["panels"]} + failure = panels[20] + self.assertEqual("timeseries", failure["type"]) + self.assertEqual("stepAfter", failure["fieldConfig"]["defaults"]["custom"]["lineInterpolation"]) + self.assertEqual("short", failure["fieldConfig"]["defaults"]["unit"]) + self.assertEqual(4, len(failure["targets"])) + for target, suffix in zip(failure["targets"], ("500", "503", "other_failed", "unknown")): + self.assertIn(f"k6_ha_outcome_{suffix}_total", target["expr"]) + self.assertIn("last_over_time", target["expr"]) + self.assertNotIn("rate(", target["expr"]) + self.assertIn("k6_vus", target["expr"]) + self.assertIn("원장", failure["description"]) def test_annotation_source_is_explicit_fault_event(self): """Show recorded fault events rather than a marker for every failed scrape."""