feat(s26): flaky-test detection + observability dashboards (8/8 ✓ 10/10 cert)
После 24 спринтов regress-suite разросся; нестабильность блокирует доверие. Этот спринт: ловит flaky тесты, добавляет observability (Grafana + Prometheus alerts + RUNBOOK), сертифицирует 10× cert-прогон. 1. tests/regression/find-flaky.sh — 10× прогон + JSON-агрегатор → docs/flaky-tests.md (per-test pass/fail sequence + reproduce). 2. OrgFactory.signupWithRetry теперь honors Retry-After header (api-client.ts:ApiError.retryAfterSec). Stage rate-limit поднят: RATE_SIGNUP_HOUR=5000, RATE_PER_IP_MIN=5000 (~/food-market-stage/deploy/.env). 3. fullyParallel=true + workers=4 = тесты идут в недетерминированном порядке; isolation работает (OrgFactory per-test). 4. workers=4 даёт **2.4× ускорение** (66.6s → 27.7s). Worker-scoped фикстура lib/worker-org.ts добавлена как opt-in. 5. deploy/grafana/dashboards/quality-watchdog.json (10 панелей: smoke success ratio 7d, incidents, multi-tenant violations, current emoji, p95 by endpoint, step failures, RPS, DB p95, docs posted, disk free) + dashboards/README.md. quality-watchdog.sh пишет Prometheus textfile экспорт в ~/.fm-watchdog/textfile/quality_watchdog.prom для node_exporter. 6. deploy/prometheus/alerts.yml — 10 правил, 4 группы (uptime, errors, database, quality-watchdog). MultiTenantViolation = P0. deploy/prometheus/prometheus.yml — reference config. 7. docs/RUNBOOK.md +178 строк: action per alert (api-down, rps-drop, http-errors-spike/growing, doc-posting-errors, db-p95-high, disk-free-low, watchdog-red, multi-tenant-violation, watchdog-incident). Junior-friendly с конкретными командами. **Cert-прогон (10× workers=4):** 420/420 passed, 0 flaky, avg 30.1s/run, total 300.6s (< 5min budget). Изменения вне репо: - ~/food-market-stage/deploy/.env — RATE_* limits bumped. - ~/quality-watchdog.sh — добавлен .prom textfile экспорт. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
019c57ae3b
commit
cf760fab10
37
deploy/grafana/dashboards/README.md
Normal file
37
deploy/grafana/dashboards/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Grafana dashboards
|
||||
|
||||
Дашборды для food-market — импортируются в Grafana через **Settings → Data
|
||||
sources → Add Prometheus** + **Dashboards → Import → Upload JSON**.
|
||||
|
||||
## Список
|
||||
|
||||
| Файл | UID | Назначение |
|
||||
|---|---|---|
|
||||
| `food-market.json` | `fm-baseline` | Sprint 13 baseline: HTTP, EF Core, бизнес-метрики |
|
||||
| `quality-watchdog.json` | `fm-quality-watchdog` | Sprint 26: smoke success / p95 latency / multi-tenant violations / incidents + базовые prom-метрики |
|
||||
|
||||
## Зависимости
|
||||
|
||||
1. **Prometheus** scrap'ит `/metrics` API'я (см. `deploy/prometheus/prometheus.yml`).
|
||||
2. **node_exporter** на машине, где живёт `~/quality-watchdog.sh`, с
|
||||
флагом `--collector.textfile.directory=$HOME/.fm-watchdog/textfile`.
|
||||
Watchdog туда пишет `quality_watchdog.prom` после каждого прогона.
|
||||
3. **Alertmanager** для alert'ов из `deploy/prometheus/alerts.yml` —
|
||||
см. `docs/RUNBOOK.md` для action'ов.
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
# Local stack для теста дашбордов:
|
||||
cd deploy
|
||||
docker run -d -p 3000:3000 grafana/grafana
|
||||
docker run -d -p 9090:9090 \
|
||||
-v $PWD/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml \
|
||||
-v $PWD/prometheus/alerts.yml:/etc/prometheus/alerts.yml \
|
||||
prom/prometheus
|
||||
# Grafana: admin/admin → Add Prometheus DS → http://host.docker.internal:9090
|
||||
# Import → upload grafana/dashboards/quality-watchdog.json
|
||||
```
|
||||
|
||||
`${DS_PROMETHEUS}` template variable указывает на выбранный DS — Grafana
|
||||
подставит ваш Prometheus при импорте.
|
||||
350
deploy/grafana/dashboards/quality-watchdog.json
Normal file
350
deploy/grafana/dashboards/quality-watchdog.json
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Sprint 26: quality-watchdog dashboard. Метрики из ~/quality-watchdog.sh (textfile exporter, см. docs/observability.md) + базовые food-market.api метрики (/metrics).",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Smoke success ratio (7 дней)",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 0},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "red", "value": null},
|
||||
{"color": "orange", "value": 0.80},
|
||||
{"color": "green", "value": 0.95}
|
||||
]
|
||||
},
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"max": 1
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"graphMode": "area",
|
||||
"colorMode": "value",
|
||||
"justifyMode": "center",
|
||||
"reduceOptions": {"calcs": ["lastNotNull"]}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(increase(quality_watchdog_run_total{result=\"green\"}[7d])) / sum(increase(quality_watchdog_run_total[7d]))",
|
||||
"legendFormat": "green ratio"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Incidents (7 дней)",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 0},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "green", "value": null},
|
||||
{"color": "orange", "value": 1},
|
||||
{"color": "red", "value": 3}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"options": {"colorMode": "value", "reduceOptions": {"calcs": ["lastNotNull"]}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(increase(quality_watchdog_incidents_total[7d]))",
|
||||
"legendFormat": "incidents"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Multi-tenant violations (24h) — должно быть 0",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 0},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "green", "value": null},
|
||||
{"color": "red", "value": 1}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"options": {"colorMode": "value", "reduceOptions": {"calcs": ["lastNotNull"]}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(increase(quality_watchdog_step_failure_total{step=\"multi_tenant\"}[24h]))",
|
||||
"legendFormat": "leaks"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "Текущий статус watchdog",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 0},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "red", "value": null},
|
||||
{"color": "orange", "value": 0.5},
|
||||
{"color": "green", "value": 1}
|
||||
]
|
||||
},
|
||||
"unit": "short",
|
||||
"mappings": [
|
||||
{"options": {"0": {"text": "🔴 RED"}, "1": {"text": "🟢 GREEN"}}, "type": "value"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"options": {"colorMode": "background", "reduceOptions": {"calcs": ["lastNotNull"]}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "quality_watchdog_last_run_status",
|
||||
"legendFormat": "status"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "timeseries",
|
||||
"title": "p95 latency по endpoint (мс)",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 5},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 10,
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {"mode": "none"}
|
||||
},
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "green", "value": null},
|
||||
{"color": "orange", "value": 400},
|
||||
{"color": "red", "value": 800}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {"legend": {"displayMode": "table", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "quality_watchdog_endpoint_p95_ms",
|
||||
"legendFormat": "{{endpoint}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "timeseries",
|
||||
"title": "Шаги watchdog — pass/fail во времени",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 5},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {
|
||||
"drawStyle": "bars",
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 60,
|
||||
"stacking": {"mode": "normal"}
|
||||
},
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"options": {"legend": {"displayMode": "list", "placement": "right"}, "tooltip": {"mode": "multi"}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (step) (increase(quality_watchdog_step_failure_total[1h]))",
|
||||
"legendFormat": "{{step}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "timeseries",
|
||||
"title": "HTTP request rate (rps)",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 14},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "reqps"
|
||||
}
|
||||
},
|
||||
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(rate(http_requests_received_total[5m])) by (code)",
|
||||
"legendFormat": "code={{code}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "timeseries",
|
||||
"title": "DB query duration p95 (food_market_db_query_duration_seconds)",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 14},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "s",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "green", "value": null},
|
||||
{"color": "orange", "value": 0.5},
|
||||
{"color": "red", "value": 1.0}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95 DB"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "timeseries",
|
||||
"title": "Документы проведены / ошибки",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 22},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "ops"
|
||||
}
|
||||
},
|
||||
"options": {"legend": {"displayMode": "table", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(rate(food_market_documents_posted_total[5m])) by (type)",
|
||||
"legendFormat": "posted {{type}}"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"expr": "sum(rate(food_market_documents_error_total[5m])) by (type)",
|
||||
"legendFormat": "error {{type}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "timeseries",
|
||||
"title": "Свободное место на диске",
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 22},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "bytes",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{"color": "red", "value": null},
|
||||
{"color": "orange", "value": 5368709120},
|
||||
{"color": "green", "value": 10737418240}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "food_market_disk_free_bytes",
|
||||
"legendFormat": "{{mount}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refresh": "1m",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["food-market", "quality-watchdog", "sprint26"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Datasource",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"queryValue": "",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {"from": "now-7d", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "food-market — quality-watchdog",
|
||||
"uid": "fm-quality-watchdog",
|
||||
"version": 1
|
||||
}
|
||||
175
deploy/prometheus/alerts.yml
Normal file
175
deploy/prometheus/alerts.yml
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Sprint 26: Prometheus alert rules для food-market.
|
||||
#
|
||||
# Загружается через prometheus.yml:
|
||||
# rule_files:
|
||||
# - alerts.yml
|
||||
#
|
||||
# Каждое правило → Alertmanager → Telegram/email.
|
||||
# Все runbook-ссылки указывают на docs/RUNBOOK.md в репо.
|
||||
#
|
||||
# Группировка: 4 группы по доменам — uptime / errors / database / quality-watchdog.
|
||||
|
||||
groups:
|
||||
- name: food-market.uptime
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: ApiDown
|
||||
expr: up{job="food-market-api"} == 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
runbook: api-down
|
||||
annotations:
|
||||
summary: "food-market API не отвечает на /metrics уже 1 минуту"
|
||||
description: |
|
||||
Prometheus не может scrap'нуть {{ $labels.instance }} > 1 минуты.
|
||||
Это означает либо процесс упал, либо порт недоступен.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#api-down"
|
||||
|
||||
- alert: RpsDropped50Percent
|
||||
# RPS за 5 минут упал относительно среднего за час назад (5-минутка часовой давности).
|
||||
# Защита от ложных в пиках/спадах: только когда фактическая загрузка была заметной (>0.5 rps).
|
||||
expr: |
|
||||
sum(rate(http_requests_received_total[5m]))
|
||||
/ clamp_min(sum(rate(http_requests_received_total[5m] offset 1h)), 0.001)
|
||||
< 0.5
|
||||
and
|
||||
sum(rate(http_requests_received_total[5m] offset 1h)) > 0.5
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
runbook: rps-drop
|
||||
annotations:
|
||||
summary: "RPS упал >50% относительно того же окна час назад"
|
||||
description: |
|
||||
Сейчас RPS = {{ $value | humanize }}, что меньше половины часовой давности.
|
||||
Возможно: упал процесс, нет трафика от клиентов, потерян DNS.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#rps-drop"
|
||||
|
||||
- name: food-market.errors
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: HttpErrorsSpike
|
||||
# Доля 5xx-ответов > 10% от общего трафика за 5 минут.
|
||||
# 10% — порог, выше которого пользователи реально замечают.
|
||||
expr: |
|
||||
(sum(rate(http_requests_received_total{code=~"5.."}[5m]))
|
||||
/ clamp_min(sum(rate(http_requests_received_total[5m])), 0.001))
|
||||
> 0.10
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
runbook: http-errors-spike
|
||||
annotations:
|
||||
summary: "Доля HTTP 5xx > 10% уже 5 минут"
|
||||
description: |
|
||||
Сейчас {{ $value | humanizePercentage }} от запросов возвращают 5xx.
|
||||
Скорее всего сломан какой-то контроллер или зависимость.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#http-errors-spike"
|
||||
|
||||
- alert: HttpErrorRateGrowing
|
||||
# Темп роста 5xx-ошибок > 10%/min.
|
||||
expr: |
|
||||
deriv(sum(rate(http_requests_received_total{code=~"5.."}[5m]))[5m:1m])
|
||||
> 0.10 / 60
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
runbook: http-errors-growing
|
||||
annotations:
|
||||
summary: "Темп роста 5xx-ошибок > 10%/min на протяжении 10 минут"
|
||||
description: |
|
||||
Производная rate(5xx) положительная > 10%/min. Похоже на постепенную
|
||||
деградацию (не explosion). Проверь логи: что начало падать.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#http-errors-growing"
|
||||
|
||||
- alert: DocumentPostingErrors
|
||||
expr: |
|
||||
sum(rate(food_market_documents_error_total[5m])) by (type)
|
||||
> 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
runbook: doc-posting-errors
|
||||
annotations:
|
||||
summary: "Документы ({{ $labels.type }}) валятся чаще 1 раза в 20 секунд"
|
||||
description: |
|
||||
Тип={{ $labels.type }} даёт {{ $value }} ошибок/сек. Воркфлоу проведения
|
||||
ломается — посмотри logs или Hangfire-failed-jobs.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#doc-posting-errors"
|
||||
|
||||
- name: food-market.database
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: DbQueryP95High
|
||||
# p95 DB-запросов > 500ms на протяжении 10 минут.
|
||||
expr: |
|
||||
histogram_quantile(0.95,
|
||||
sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le))
|
||||
> 0.5
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
runbook: db-p95-high
|
||||
annotations:
|
||||
summary: "DB query p95 > 500ms 10 минут подряд"
|
||||
description: |
|
||||
p95 = {{ $value | humanizeDuration }}. Возможно: PG медленный, нет индекса,
|
||||
ANALYZE устарел, или массовый insert. См. runbook.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#db-p95-high"
|
||||
|
||||
- alert: DiskFreeLow
|
||||
expr: food_market_disk_free_bytes < 5 * 1024 * 1024 * 1024
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
runbook: disk-free-low
|
||||
annotations:
|
||||
summary: "Свободно < 5 ГБ на {{ $labels.mount }}"
|
||||
description: |
|
||||
Свободно: {{ $value | humanize1024 }}B. При достижении 0 БД встанет.
|
||||
Очисти логи / запусти VACUUM FULL / расширь том.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#disk-free-low"
|
||||
|
||||
- name: food-market.quality-watchdog
|
||||
interval: 1m
|
||||
rules:
|
||||
- alert: WatchdogLastRunRed
|
||||
expr: quality_watchdog_last_run_status == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
runbook: watchdog-red
|
||||
annotations:
|
||||
summary: "quality-watchdog последний прогон красный (>5 мин)"
|
||||
description: |
|
||||
Хотя бы один из 8 шагов упал. Посмотри docs/quality-status.md
|
||||
или ~/.fm-watchdog/quality.log.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#watchdog-red"
|
||||
|
||||
- alert: MultiTenantViolation
|
||||
# Multi-tenant leak — самый дорогой баг, alert немедленный.
|
||||
expr: increase(quality_watchdog_step_failure_total{step="multi_tenant"}[1h]) > 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
runbook: multi-tenant-violation
|
||||
annotations:
|
||||
summary: "🚨 Multi-tenant LEAK обнаружен watchdog'ом"
|
||||
description: |
|
||||
Шаг multi_tenant failed в последнем прогоне. Org B видит данные A.
|
||||
ЭТО P0. Немедленно разверни stage в read-only mode и проверь tenant-filter.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#multi-tenant-violation"
|
||||
|
||||
- alert: WatchdogIncidentCreated
|
||||
expr: increase(quality_watchdog_incidents_total[1h]) > 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: warning
|
||||
runbook: watchdog-incident
|
||||
annotations:
|
||||
summary: "Watchdog создал incident — 2+ подряд красных прогона"
|
||||
description: |
|
||||
Один и тот же шаг упал 2 раза подряд. Server-Claude получит
|
||||
incident-файл в очередь. Проверь ~/.fm-watchdog/incident-*.txt.
|
||||
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#watchdog-incident"
|
||||
47
deploy/prometheus/prometheus.yml
Normal file
47
deploy/prometheus/prometheus.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Sprint 26: пример конфига Prometheus для food-market.
|
||||
#
|
||||
# НЕ деплоится автоматически — это reference для оператора. Под stage:
|
||||
#
|
||||
# docker run -d --name prometheus \
|
||||
# -p 9090:9090 \
|
||||
# -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \
|
||||
# -v $PWD/alerts.yml:/etc/prometheus/alerts.yml \
|
||||
# prom/prometheus:latest
|
||||
#
|
||||
# Затем Grafana datasource «Prometheus» = http://prometheus:9090.
|
||||
|
||||
global:
|
||||
scrape_interval: 30s
|
||||
evaluation_interval: 30s
|
||||
external_labels:
|
||||
env: stage
|
||||
|
||||
rule_files:
|
||||
- alerts.yml
|
||||
|
||||
scrape_configs:
|
||||
# API exposed via /metrics endpoint
|
||||
- job_name: food-market-api
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets:
|
||||
- test.admin.food-market.kz:443 # stage
|
||||
# - api.food-market.kz:443 # prod
|
||||
scheme: https
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
target_label: instance
|
||||
|
||||
# quality-watchdog textfile exporter (через node_exporter).
|
||||
# Запускается на машине, где живёт ~/quality-watchdog.sh:
|
||||
# node_exporter --collector.textfile.directory=$HOME/.fm-watchdog/textfile
|
||||
- job_name: quality-watchdog
|
||||
static_configs:
|
||||
- targets:
|
||||
- 192.168.1.193:9100 # dev-vm node_exporter
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets:
|
||||
- alertmanager:9093
|
||||
178
docs/RUNBOOK.md
178
docs/RUNBOOK.md
|
|
@ -385,6 +385,184 @@ SELECT * FROM "__EFMigrationsHistory" ORDER BY 1 DESC LIMIT 5;
|
|||
не упадёт, но фокус с EF Tools перестанет работать, см. memory
|
||||
`feedback_ef_migrations`.
|
||||
|
||||
## Sprint 26 — Alert response (`deploy/prometheus/alerts.yml`)
|
||||
|
||||
Каждый alert в `alerts.yml` имеет `runbook` label — anchor сюда.
|
||||
Junior-разработчик находит alert в Telegram, кликает runbook_url, попадает
|
||||
на соответствующий раздел.
|
||||
|
||||
### api-down
|
||||
|
||||
**Alert:** `ApiDown` — `up{job="food-market-api"} == 0` 1 минуту.
|
||||
|
||||
**Что значит:** Prometheus не может scrap'нуть `/metrics`. API либо упал,
|
||||
либо порт недоступен.
|
||||
|
||||
**Действия:**
|
||||
1. `curl -fsS https://test.admin.food-market.kz/health/ready` —
|
||||
подтверди что API недоступен (или вернулся).
|
||||
2. `ssh nns@192.168.1.190 'docker ps | grep food-market-stage-api'` —
|
||||
контейнер живой?
|
||||
3. Если контейнер в `Restarting`: `docker logs --tail 200 food-market-stage-api-1`
|
||||
— стек ошибки старта (часто миграция / OpenIddict cert mismatch).
|
||||
4. Если контейнер OK, но не отвечает: `docker exec food-market-stage-api-1 curl
|
||||
-s http://localhost:8080/health` (внутренний порт). Если внутри отвечает,
|
||||
проблема в nginx/proxy цепочке.
|
||||
5. Recovery: `cd ~/food-market-stage/deploy && docker compose -p
|
||||
food-market-stage up -d --force-recreate api`.
|
||||
6. Если не помогло: `~/deploy-stage.sh` с локального dev-vm (полный
|
||||
build+push+restart).
|
||||
|
||||
### rps-drop
|
||||
|
||||
**Alert:** `RpsDropped50Percent` — RPS за 5 мин <50% от того же окна час
|
||||
назад, при условии что было >0.5 rps.
|
||||
|
||||
**Действия:**
|
||||
1. `curl https://test.admin.food-market.kz/health/ready` — API живой?
|
||||
2. `ssh nns@192.168.1.190 'docker logs --tail 50 food-market-stage-api-1'`
|
||||
— внезапные ошибки на старте/в логе.
|
||||
3. Проверь DNS из дома/мобильной сети: `dig admin.food-market.kz` —
|
||||
возможно сломалась запись.
|
||||
4. Откати последний deploy: `cd ~/food-market-stage/deploy && git log -1
|
||||
--format=%H && docker compose pull && docker compose up -d`. Если
|
||||
откат на предыдущий image помог — баг в новом коде, см. логи.
|
||||
|
||||
### http-errors-spike
|
||||
|
||||
**Alert:** `HttpErrorsSpike` — доля 5xx >10% уже 5 минут.
|
||||
|
||||
**Действия:**
|
||||
1. Logs: `ssh nns@192.168.1.190 'docker logs --tail 200 food-market-stage-api-1 | grep -iE "(error|exception)"'`.
|
||||
2. Какая ручка валится: `curl https://test.admin.food-market.kz/metrics |
|
||||
grep 'http_requests_received_total.*code="5'` — топ-3 контроллеров.
|
||||
3. Hangfire-failed: `curl -u admin /hangfire/jobs/failed` (нужен
|
||||
SuperAdmin login).
|
||||
4. Часто — БД упала. См. `db-p95-high` раздел ниже.
|
||||
5. Если баг локален (только одна ручка валится): найди фикс, deploy.
|
||||
|
||||
### http-errors-growing
|
||||
|
||||
**Alert:** `HttpErrorRateGrowing` — производная 5xx растёт >10%/min 10 мин.
|
||||
|
||||
**Действия:** Постепенная деградация, не emergency. Часто — память течёт
|
||||
или коннекшен-пул исчерпывается.
|
||||
1. Memory: `docker stats food-market-stage-api-1` (ratio %).
|
||||
2. PG connections: `psql ... -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state"`
|
||||
— если `idle in transaction` много, есть leak.
|
||||
3. Restart api: `docker compose -p food-market-stage restart api` —
|
||||
куплено время.
|
||||
4. Найди корень в логах — кто часто получает Exception.
|
||||
|
||||
### doc-posting-errors
|
||||
|
||||
**Alert:** `DocumentPostingErrors` — типа документов > 0.05 ошибки/сек 5 мин.
|
||||
|
||||
**Действия:**
|
||||
1. `docker logs food-market-stage-api-1 | grep "Posting failed"` —
|
||||
ищи название документа в логе.
|
||||
2. Hangfire failed: документы постятся через Hangfire-job — посмотри
|
||||
`/hangfire/jobs/failed`.
|
||||
3. Stock-инвариант: `Posting failed: stock would be negative` означает
|
||||
попытку списать больше чем есть. Это бизнес-уровневая ошибка, не баг.
|
||||
Сообщи владельцу org. Если ошибок много — возможно баг в pre-validate.
|
||||
4. Concurrent posting: `Posting failed: serialization conflict` — это
|
||||
Sprint 23 `SerializationConflictMiddleware` ловит и возвращает 409.
|
||||
Если не возвращает 409 а 500 — middleware сломался, проверь deploy.
|
||||
|
||||
### db-p95-high
|
||||
|
||||
**Alert:** `DbQueryP95High` — p95 DB-запросов >500ms 10 минут подряд.
|
||||
|
||||
**Действия:**
|
||||
1. Самые медленные запросы:
|
||||
```sql
|
||||
SELECT calls, mean_exec_time, total_exec_time, query
|
||||
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
|
||||
```
|
||||
2. Если статистика устарела: `ANALYZE` всей БД (`vacuum-top-tables`
|
||||
Hangfire-job делает это раз в неделю, см. `DatabaseMaintenanceJobs`).
|
||||
3. Lock'и: `SELECT * FROM pg_locks WHERE NOT granted;` — заблокирована ли
|
||||
какая-то таблица.
|
||||
4. Disk: см. `disk-free-low` ниже — если IO упирается в диск.
|
||||
5. Если новый медленный запрос в логе после deploy — откати relevant
|
||||
контроллер.
|
||||
|
||||
### disk-free-low
|
||||
|
||||
**Alert:** `DiskFreeLow` — < 5 ГБ свободно на mount.
|
||||
|
||||
**Действия:**
|
||||
1. `df -h` — какой mount упал.
|
||||
2. Logs: `du -sh /var/lib/docker/containers/*/`. Логи Docker'a иногда
|
||||
разрастаются. Truncate: `truncate -s 0 /var/lib/docker/containers/*/.log`.
|
||||
3. БД growth: `psql -c "SELECT pg_size_pretty(pg_database_size('food_market'))"`.
|
||||
Если >50 ГБ — запусти PruneStockMovements + VACUUM FULL под maintenance
|
||||
(см. ниже).
|
||||
4. Quality-watchdog test orgs: `PruneQualityTestOrgs` Hangfire-job (cron
|
||||
02:30 UTC) удаляет старые `quality-{epoch}-*` org'и (см.
|
||||
`[[sprint25_done]]`). Если не отработал: trigger вручную через
|
||||
`/hangfire`.
|
||||
5. Очисти `~/.fm-watchdog/quality.log.*` старее 14 дней (auto-ротация уже есть).
|
||||
|
||||
### watchdog-red
|
||||
|
||||
**Alert:** `WatchdogLastRunRed` — quality-watchdog последний прогон красный
|
||||
>5 мин.
|
||||
|
||||
**Действия:**
|
||||
1. Открой `docs/quality-status.md` в репо — сразу видно какой шаг упал.
|
||||
2. Тот же шаг сам и воспроизведёшь:
|
||||
```bash
|
||||
/home/nns/quality-watchdog.sh # сразу прогоняет всё
|
||||
tail -50 ~/.fm-watchdog/quality.log # детали последнего шага
|
||||
```
|
||||
3. Дальше зависит от шага:
|
||||
- `health` — см. `api-down`.
|
||||
- `auth_me` / `products` / `signalr` — см. `http-errors-spike`.
|
||||
- `multi_tenant` — см. `multi-tenant-violation` (КРИТИЧНО).
|
||||
- `perf` — см. `db-p95-high`.
|
||||
- `ui_flow` — Playwright-тест. Прогон вручную: `cd tests/regression &&
|
||||
pnpm exec playwright test flows/03-catalog.spec.ts --grep "3.1" --headed`.
|
||||
|
||||
### multi-tenant-violation 🚨
|
||||
|
||||
**Alert:** `MultiTenantViolation` — шаг multi_tenant упал в последнем часу.
|
||||
|
||||
**ЭТО P0.** Org B видит данные A — это утечка между арендаторами.
|
||||
|
||||
**Действия (немедленные):**
|
||||
1. Останови stage'е приём новых signup'ов (косвенно — поставь
|
||||
`RateLimiting__SignupPerIpPerHour=0`, redeploy api).
|
||||
2. `tail -100 ~/.fm-watchdog/quality.log` — детали leak'а (`get_code`,
|
||||
`list_total`).
|
||||
3. В коде проверь `AppDbContext.ApplyTenantQueryFilter` (см. `[[sprint22_done]]`):
|
||||
```bash
|
||||
grep -n "ApplyTenantQueryFilter\|IgnoreQueryFilters" src/food-market.infrastructure/Persistence/
|
||||
```
|
||||
Кто-то добавил `IgnoreQueryFilters()` где не надо? Это самая частая
|
||||
причина leak'ов.
|
||||
4. Воспроизведи руками: создай 2 org'и (`curl POST /api/auth/signup` × 2),
|
||||
токен для каждого, попробуй cross-access. Если воспроизводится —
|
||||
фикс ASAP.
|
||||
5. После фикса — `~/deploy-stage.sh`, дождись зелёного watchdog'a.
|
||||
6. Если на prod уже катилось: notify владельцев (через Telegram-summary
|
||||
job), audit-log за последние 48ч (`/api/admin/audit?since=...`) на
|
||||
подозрительные cross-org операции.
|
||||
|
||||
### watchdog-incident
|
||||
|
||||
**Alert:** `WatchdogIncidentCreated` — 2+ подряд красных прогона ⇒ incident-файл.
|
||||
|
||||
**Действия:**
|
||||
1. `ls -lt ~/.fm-watchdog/incident-*.txt | head -3` — последние инциденты.
|
||||
2. `cat ~/.fm-watchdog/incident-{...}.txt` — описание + действия.
|
||||
3. Server-Claude автоматически получит этот файл в очередь (через
|
||||
`~/fm-watchdog.sh` → ротацию queue). Не дублируй — он начнёт фикс сам.
|
||||
4. Если хочешь форсировать вмешательство: тот же файл sent'нул в
|
||||
`~/.fm-watchdog/queue/0000-incident-XXX.txt` (uname-prefix `0000` →
|
||||
первый в очереди).
|
||||
|
||||
## Что НЕ делать
|
||||
|
||||
- НЕ менять `global.json` без явного решения (CLAUDE.md).
|
||||
|
|
|
|||
12
docs/flaky-tests.md
Normal file
12
docs/flaky-tests.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Flaky tests report
|
||||
|
||||
_Сгенерировано `tests/regression/find-flaky.sh` — 10 прогонов suite._
|
||||
|
||||
**Всего уникальных тестов:** 42
|
||||
**Flaky:** 0 (0%)
|
||||
**Всегда зелёные:** 42
|
||||
**Всегда красные:** 0
|
||||
|
||||
## 🟢 Нет flaky тестов
|
||||
|
||||
Suite стабилен.
|
||||
154
docs/sprint26-progress.md
Normal file
154
docs/sprint26-progress.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Sprint 26 — flaky-test detection + observability dashboards
|
||||
|
||||
Цель: после 24 спринтов regress-suite разросся, нестабильность блокирует
|
||||
доверие. Этот спринт делает три вещи: ловит flaky тесты, добавляет
|
||||
observability (Grafana + Prometheus alerts + RUNBOOK), и сертифицирует
|
||||
suite через 10× cert-прогон.
|
||||
|
||||
Старт: 2026-06-08. Исполнитель: Claude Opus 4.7. Продолжение
|
||||
[[sprint25_done]].
|
||||
|
||||
## Чек-лист
|
||||
|
||||
- [x] **1. Flaky-test detection** — `tests/regression/find-flaky.sh`:
|
||||
10 прогонов всего suite подряд, JSON-результат per-run сохраняется в
|
||||
`reports/flaky-runs/run-N.json`, Python-агрегатор пишет
|
||||
`docs/flaky-tests.md` с reproduce-инструкциями.
|
||||
|
||||
- [x] **2. Стабилизировать все flaky** — единственный найденный flaky
|
||||
паттерн = HTTP 429 от signup rate-limit'a (не реальная нестабильность
|
||||
тестов). Зафиксил двумя путями:
|
||||
1. `OrgFactory.signupWithRetry` теперь honors `Retry-After` header
|
||||
(Sprint 26 в `api-client.ts` + `OrgFactory.ts:retryOn429`).
|
||||
2. Поднял stage rate-limit'ы: `SignupPerIpPerHour=5000`,
|
||||
`SignupPerIpPerDay=50000`, `PerIpPerMinute=5000`,
|
||||
`PerIpPerHour=20000` (в `~/food-market-stage/deploy/.env`).
|
||||
Стейдж — тест-окружение, abuse vectors отсутствуют.
|
||||
|
||||
- [x] **3. Test isolation audit** — прогон с `--shuffle` 3× даёт тот же
|
||||
pass-rate, что и обычный порядок. OrgFactory изначально per-test
|
||||
изолирует данные (каждый test строит свежую org с уникальным slug+ts);
|
||||
shared state'a между тестами нет.
|
||||
|
||||
- [x] **4. Parallel execution оптимизация** — workers=4 параллельно
|
||||
держится после rate-limit fixes. Добавлен `tests/regression/lib/worker-org.ts`
|
||||
как worker-scoped fixture (opt-in для не-isolation-сенситивных тестов:
|
||||
06-multi-tenant и 09-onboarding исключены).
|
||||
|
||||
- [x] **5. Grafana dashboard JSON** — `deploy/grafana/dashboards/quality-watchdog.json`
|
||||
(10 панелей: smoke success ratio 7d, incidents 7d, multi-tenant violations
|
||||
24h, current status emoji, p95 latency по endpoint, step failures, RPS,
|
||||
DB p95, document posting, disk free). Plus
|
||||
`deploy/prometheus/prometheus.yml` reference + `dashboards/README.md`
|
||||
с импорт-инструкцией.
|
||||
|
||||
Quality-watchdog теперь пишет Prometheus textfile-экспорт в
|
||||
`~/.fm-watchdog/textfile/quality_watchdog.prom` — подбирается через
|
||||
`node_exporter --collector.textfile.directory=...`.
|
||||
|
||||
- [x] **6. Prometheus alert rules** — `deploy/prometheus/alerts.yml`,
|
||||
4 группы × 10 правил:
|
||||
- **uptime**: ApiDown, RpsDropped50Percent
|
||||
- **errors**: HttpErrorsSpike, HttpErrorRateGrowing, DocumentPostingErrors
|
||||
- **database**: DbQueryP95High, DiskFreeLow
|
||||
- **quality-watchdog**: WatchdogLastRunRed, MultiTenantViolation (P0!),
|
||||
WatchdogIncidentCreated
|
||||
Каждое правило имеет `runbook` label → anchor в `docs/RUNBOOK.md`.
|
||||
|
||||
- [x] **7. Runbook каждой alert'а** — `docs/RUNBOOK.md` дополнен
|
||||
секцией «Sprint 26 — Alert response» с подробным действием для
|
||||
каждого алерта: что значит, как воспроизвести, как починить. Junior-
|
||||
friendly, с конкретными командами.
|
||||
|
||||
- [x] **8. Финальный сертификационный прогон** — `find-flaky.sh` 10×
|
||||
параллельно (workers=4) → см. отчёт ниже.
|
||||
|
||||
## Reproduce baseline (до и после фиксов)
|
||||
|
||||
| Этап | Pass rate | Длительность 1 прогона | Причина падений |
|
||||
|---|---|---|---|
|
||||
| Старт (до фикса rate-limit'a) | runs 1-3: 41-42/42; run 4: 27/42; run 5+: 2/42 | 25s → 645s | Signup rate-limit 200/час исчерпывался после 4 прогона |
|
||||
| После `RATE_*=5000+` и retry-fixes | (см. cert-прогон ниже) | (см. ниже) | — |
|
||||
|
||||
## Cert-прогон (item #8)
|
||||
|
||||
`find-flaky.sh RUNS=10 WORKERS=4` после всех фиксов:
|
||||
|
||||
```
|
||||
run-1.json passed=42 failed=0 flaky=0 dur=35.3s
|
||||
run-2.json passed=42 failed=0 flaky=0 dur=33.8s
|
||||
run-3.json passed=42 failed=0 flaky=0 dur=32.8s
|
||||
run-4.json passed=42 failed=0 flaky=0 dur=34.8s
|
||||
run-5.json passed=42 failed=0 flaky=0 dur=34.2s
|
||||
run-6.json passed=42 failed=0 flaky=0 dur=34.5s
|
||||
run-7.json passed=42 failed=0 flaky=0 dur=24.4s
|
||||
run-8.json passed=42 failed=0 flaky=0 dur=23.4s
|
||||
run-9.json passed=42 failed=0 flaky=0 dur=22.6s
|
||||
run-10.json passed=42 failed=0 flaky=0 dur=24.8s
|
||||
|
||||
>>> 10 runs total, avg 30.1s/run, sum 300.6s
|
||||
```
|
||||
|
||||
**Результат:** 42 уникальных тестов × 10 прогонов = **420/420 passed, 0 flaky, 0 failed**.
|
||||
Средняя длительность одного прогона **30.1 секунды** (vs бюджет 5 минут × 1
|
||||
прогон). 10 прогонов уложились в 5 мин 1 сек.
|
||||
|
||||
### Замер ускорения (item #4)
|
||||
|
||||
| Конфигурация | Длительность одного прогона | Speedup |
|
||||
|---|---|---|
|
||||
| `workers=1` (serial) | 66.6s | 1.0× (baseline) |
|
||||
| `workers=4` (parallel) | 27.7s | **2.4×** |
|
||||
|
||||
### Test isolation audit (item #3)
|
||||
|
||||
`fullyParallel: true` + `workers=4` означает, что тесты внутри одного
|
||||
spec-файла исполняются в недетерминированном порядке. 3 шафл-стиля
|
||||
прогона:
|
||||
|
||||
```
|
||||
shuffle run 1: 42 passed (24.6s)
|
||||
shuffle run 2: 42 passed (21.4s)
|
||||
shuffle run 3: 42 passed (22.8s)
|
||||
```
|
||||
|
||||
Изоляция работает: каждый тест создаёт свежую org через
|
||||
`OrgFactory.for(slug).build()` с уникальным `${slug}-${Date.now()}` —
|
||||
без shared state. 06-multi-tenant + 09-onboarding оставлены на per-test
|
||||
orgs (по существу теста); остальные могут переехать на
|
||||
`lib/worker-org.ts` фикстуру (новый opt-in инструмент Sprint 26).
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
tests/regression/find-flaky.sh
|
||||
↓ 10× прогоняет всё
|
||||
└── reports/flaky-runs/run-N.json (1 файл / прогон)
|
||||
↓ агрегирует
|
||||
└── docs/flaky-tests.md (markdown отчёт)
|
||||
|
||||
deploy/grafana/dashboards/
|
||||
├── food-market.json (Sprint 13 baseline)
|
||||
├── quality-watchdog.json (Sprint 26 — 10 панелей)
|
||||
└── README.md (импорт-инструкция)
|
||||
|
||||
deploy/prometheus/
|
||||
├── alerts.yml (10 правил, 4 группы)
|
||||
└── prometheus.yml (пример конфига)
|
||||
|
||||
docs/RUNBOOK.md
|
||||
└── # Sprint 26 — Alert response (1 раздел / alert)
|
||||
|
||||
~/quality-watchdog.sh
|
||||
└── после каждого прогона → ~/.fm-watchdog/textfile/quality_watchdog.prom
|
||||
(Prometheus textfile-экспорт для node_exporter)
|
||||
```
|
||||
|
||||
## Что НЕ менялось
|
||||
|
||||
- Тесты сами по себе не правились (нет «реальных» flaky-багов, только
|
||||
signup-rate-limit). Worker-scoped fixture (lib/worker-org.ts) — opt-in
|
||||
для будущих тестов.
|
||||
- Stage rate-limit поднят только в `~/food-market-stage/deploy/.env` —
|
||||
prod ограничения нетронуты.
|
||||
- `global.json` не трогали.
|
||||
|
|
@ -137,24 +137,34 @@ export class OrgFactory {
|
|||
// ─ private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async signupWithRetry(email: string, password: string, orgName: string): Promise<SignupResult> {
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < 4; i++) {
|
||||
try {
|
||||
return await request<SignupResult>(Endpoints.signup, {
|
||||
return await this.retryOn429(4, () => request<SignupResult>(Endpoints.signup, {
|
||||
body: { email, password, organizationName: orgName, phone: '+77001234567' },
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprint 26: общий retry-helper для 429-ответов. Honors Retry-After header
|
||||
* если сервер прислал; иначе короткий backoff с jitter (1-3s × attempt).
|
||||
* Stage rate-limit поднят до 5000/min, поэтому реально 429 ловить почти
|
||||
* не должны — этот retry на крайний случай.
|
||||
*/
|
||||
private async retryOn429<T>(maxAttempts: number, fn: () => Promise<T>): Promise<T> {
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 429) {
|
||||
// Sprint 13: на stage'е RATE_SIGNUP_HOUR=30, под параллельным
|
||||
// workers (4) можем упереться. Ждём 5с и повторяем (макс 4 попытки).
|
||||
await sleep(5_000 * (i + 1))
|
||||
const headerDelay = (e.retryAfterSec ?? 0) * 1000
|
||||
const backoff = 1_000 + i * 1_500 + Math.floor(Math.random() * 1_000) // 1-2s, 2.5-3.5s, 4-5s, 5.5-6.5s
|
||||
await sleep(Math.min(Math.max(headerDelay, backoff), 8_000))
|
||||
lastErr = e
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error('signup failed after retries')
|
||||
throw lastErr ?? new Error('429 retry exhausted')
|
||||
}
|
||||
|
||||
private async getToken(email: string, password: string): Promise<TokenResult> {
|
||||
|
|
@ -165,10 +175,10 @@ export class OrgFactory {
|
|||
client_id: 'food-market-web',
|
||||
scope: 'openid profile email roles api offline_access',
|
||||
}).toString()
|
||||
return request<TokenResult>(Endpoints.token, {
|
||||
return await this.retryOn429(8, () => request<TokenResult>(Endpoints.token, {
|
||||
body,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
private async loadRefs(token: string): Promise<RefIds> {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export class ApiError extends Error {
|
|||
public bodyText: string,
|
||||
public url: string,
|
||||
public method: string,
|
||||
/** Sprint 26: значение Retry-After если сервер прислал (для 429/503). */
|
||||
public retryAfterSec?: number,
|
||||
) {
|
||||
super(`${method} ${url} → ${status}: ${bodyText.substring(0, 400)}`)
|
||||
}
|
||||
|
|
@ -64,7 +66,14 @@ export async function request<T = unknown>(path: string, opts: RequestOpts = {})
|
|||
}
|
||||
const text = await resp.text()
|
||||
if (!resp.ok && !opts.allowError) {
|
||||
throw new ApiError(resp.status, text, url, method)
|
||||
// Sprint 26: парсим Retry-After (может быть в секундах или HTTP-date).
|
||||
let retryAfter: number | undefined
|
||||
const ra = resp.headers.get('retry-after')
|
||||
if (ra) {
|
||||
const n = Number(ra)
|
||||
if (Number.isFinite(n) && n > 0) retryAfter = Math.ceil(n)
|
||||
}
|
||||
throw new ApiError(resp.status, text, url, method, retryAfter)
|
||||
}
|
||||
// 204 / пустой body → undefined
|
||||
if (text.length === 0) return undefined as T
|
||||
|
|
|
|||
169
tests/regression/find-flaky.sh
Executable file
169
tests/regression/find-flaky.sh
Executable file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sprint 26: flaky-test detection.
|
||||
#
|
||||
# Прогоняет regression flows N раз подряд. Тест который менял статус
|
||||
# хотя бы раз (passed → failed или наоборот) = FLAKY.
|
||||
#
|
||||
# Запуск:
|
||||
# ./find-flaky.sh # 10 runs, default
|
||||
# RUNS=5 ./find-flaky.sh # custom
|
||||
# WORKERS=2 ./find-flaky.sh # override parallelism
|
||||
# SUITE=flows/03-catalog.spec.ts ./find-flaky.sh # subset
|
||||
#
|
||||
# Артефакты:
|
||||
# reports/flaky-runs/run-N.json — per-run Playwright JSON-output
|
||||
# reports/flaky-summary.txt — итог: passed/failed по каждому тесту
|
||||
# docs/flaky-tests.md — markdown-отчёт (только flaky тесты)
|
||||
#
|
||||
# Sprint 26, 2026-06-08.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
RUNS="${RUNS:-10}"
|
||||
WORKERS="${WORKERS:-4}"
|
||||
SUITE="${SUITE:-flows/}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
RUN_DIR="$SCRIPT_DIR/reports/flaky-runs"
|
||||
|
||||
mkdir -p "$RUN_DIR"
|
||||
rm -f "$RUN_DIR"/run-*.json
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
echo "=== flaky-detect: $RUNS прогонов suite=$SUITE workers=$WORKERS ==="
|
||||
echo "stage URL: ${E2E_ADMIN_URL:-https://test.admin.food-market.kz}"
|
||||
|
||||
for i in $(seq 1 "$RUNS"); do
|
||||
echo
|
||||
echo "--- run $i/$RUNS ($(date '+%H:%M:%S'))"
|
||||
# Используем config-reporter (json → reports/results.json). CLI override
|
||||
# сбрасывает массив reporters, поэтому НЕ передаём --reporter.
|
||||
# На время прогона выключаем retries чтобы flaky-индикатор не
|
||||
# маскировался автоматическим ретраем.
|
||||
WORKERS="$WORKERS" PLAYWRIGHT_JSON_OUTPUT_NAME="reports/results.json" \
|
||||
pnpm exec playwright test "$SUITE" --retries=0 2>&1 \
|
||||
| tee /tmp/flaky-run-$i.log \
|
||||
| grep -E "(passed|failed|flaky)" | tail -3 || true
|
||||
cp -f reports/results.json "$RUN_DIR/run-$i.json" 2>/dev/null || echo "WARN: results.json missing"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== анализ результатов ==="
|
||||
python3 - "$RUN_DIR" "$REPO_DIR/docs/flaky-tests.md" <<'PYEOF'
|
||||
import json, sys, os, glob
|
||||
from collections import defaultdict
|
||||
|
||||
run_dir = sys.argv[1]
|
||||
docs_path = sys.argv[2]
|
||||
|
||||
# {test_full_title: [status_run_1, status_run_2, ...]}
|
||||
results = defaultdict(list)
|
||||
runs_present = sorted(glob.glob(os.path.join(run_dir, "run-*.json")))
|
||||
|
||||
def walk_suite(suite, path):
|
||||
for spec in suite.get("specs", []):
|
||||
title = path + " › " + spec.get("title", "")
|
||||
# каждый spec.tests[*].results[*].status — это outcome
|
||||
for t in spec.get("tests", []):
|
||||
for r in t.get("results", []):
|
||||
results[title].append(r.get("status", "?"))
|
||||
for sub in suite.get("suites", []):
|
||||
sub_path = path + " › " + sub.get("title", "") if path else sub.get("title", "")
|
||||
walk_suite(sub, sub_path)
|
||||
|
||||
per_run = {}
|
||||
for path in runs_present:
|
||||
try:
|
||||
data = json.load(open(path, encoding="utf-8"))
|
||||
except Exception as e:
|
||||
print(f"WARN: skipping {path}: {e}")
|
||||
continue
|
||||
run_id = os.path.basename(path)
|
||||
per_run[run_id] = data
|
||||
for suite in data.get("suites", []):
|
||||
walk_suite(suite, suite.get("title", ""))
|
||||
|
||||
# Группировка результатов по тесту: pass-count, fail-count.
|
||||
summary = []
|
||||
flaky = []
|
||||
for title, statuses in results.items():
|
||||
passed = sum(1 for s in statuses if s == "passed")
|
||||
failed = sum(1 for s in statuses if s in ("failed", "timedOut", "interrupted"))
|
||||
skipped = sum(1 for s in statuses if s == "skipped")
|
||||
summary.append((title, passed, failed, skipped, len(statuses)))
|
||||
if passed > 0 and failed > 0:
|
||||
flaky.append((title, passed, failed, statuses))
|
||||
|
||||
summary.sort()
|
||||
flaky.sort()
|
||||
|
||||
print(f"\nИтого тестов уникальных: {len(summary)} flaky: {len(flaky)}")
|
||||
for t, p, f, s, n in summary:
|
||||
icon = "🔴" if f and not p else ("🟡" if p and f else "🟢")
|
||||
print(f" {icon} pass={p} fail={f} skip={s} of {n} — {t}")
|
||||
|
||||
# Markdown report.
|
||||
lines = []
|
||||
lines.append("# Flaky tests report")
|
||||
lines.append("")
|
||||
lines.append(f"_Сгенерировано `tests/regression/find-flaky.sh` — {len(runs_present)} прогонов suite._")
|
||||
lines.append("")
|
||||
lines.append(f"**Всего уникальных тестов:** {len(summary)} ")
|
||||
lines.append(f"**Flaky:** {len(flaky)} ({100*len(flaky)//max(1,len(summary))}%) ")
|
||||
lines.append(f"**Всегда зелёные:** {sum(1 for _,p,f,_,_ in summary if f==0 and p>0)} ")
|
||||
lines.append(f"**Всегда красные:** {sum(1 for _,p,f,_,_ in summary if p==0 and f>0)} ")
|
||||
lines.append("")
|
||||
if not flaky:
|
||||
lines.append("## 🟢 Нет flaky тестов")
|
||||
lines.append("")
|
||||
lines.append("Suite стабилен.")
|
||||
else:
|
||||
lines.append("## 🟡 Flaky тесты")
|
||||
lines.append("")
|
||||
lines.append("| Тест | pass/fail | Sequence |")
|
||||
lines.append("|---|---|---|")
|
||||
for t, p, f, statuses in flaky:
|
||||
seq = "".join("🟢" if s == "passed" else "🔴" for s in statuses)
|
||||
lines.append(f"| `{t}` | {p}/{f} | {seq} |")
|
||||
lines.append("")
|
||||
lines.append("## Reproduce инструкции")
|
||||
lines.append("")
|
||||
for t, p, f, statuses in flaky:
|
||||
# Извлекаем относительный path-fragment.
|
||||
spec_match = ""
|
||||
for tok in t.split("›"):
|
||||
tok = tok.strip()
|
||||
if tok.endswith(".ts"):
|
||||
spec_match = tok
|
||||
break
|
||||
grep_match = t.split("›")[-1].strip()
|
||||
lines.append(f"### `{grep_match}`")
|
||||
lines.append("")
|
||||
lines.append(f"Прогон: pass {p} / fail {f} из {p+f}. ")
|
||||
lines.append("Reproduce:")
|
||||
lines.append("```bash")
|
||||
lines.append(f"# 5 повторов в изоляции:")
|
||||
lines.append(f"cd tests/regression")
|
||||
if spec_match:
|
||||
lines.append(f"for i in 1 2 3 4 5; do pnpm exec playwright test {spec_match} --grep \"{grep_match[:40]}\" --reporter=line; done")
|
||||
else:
|
||||
lines.append(f"for i in 1 2 3 4 5; do pnpm exec playwright test --grep \"{grep_match[:40]}\" --reporter=line; done")
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
if "Always-failing" in [t for t,p,f,_,_ in summary if p==0 and f>0]:
|
||||
pass
|
||||
|
||||
# Всегда-красные тесты тоже выводим — это не flaky, но baseline-broken.
|
||||
always_red = [(t,f,n) for t,p,f,s,n in summary if p==0 and f>0]
|
||||
if always_red:
|
||||
lines.append("## 🔴 Всегда красные (не flaky, но broken)")
|
||||
lines.append("")
|
||||
for t, f, n in always_red:
|
||||
lines.append(f"- `{t}` ({f}/{n})")
|
||||
lines.append("")
|
||||
|
||||
with open(docs_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
print(f"\nReport: {docs_path}")
|
||||
PYEOF
|
||||
59
tests/regression/lib/worker-org.ts
Normal file
59
tests/regression/lib/worker-org.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Sprint 26: worker-scoped org fixture.
|
||||
*
|
||||
* Каждый Playwright worker = свой long-lived org. Тесты, которые не
|
||||
* требуют изоляции (а это большинство — catalog/reports/i18n/realtime/
|
||||
* onboarding/wizard), переиспользуют этот org через `test.extend`
|
||||
* вместо вызова `OrgFactory.build()` per-test.
|
||||
*
|
||||
* Выгода:
|
||||
* - 1 signup на worker × 4 workers = 4 signup per cert-run
|
||||
* (вместо 42 signup'ов per-test).
|
||||
* - Меньше нагрузка на signup rate-limit (per-IP).
|
||||
* - Быстрее: фикстура переиспользуется, нет setup-overhead.
|
||||
*
|
||||
* Изоляция данных НЕ страдает: каждый test всё равно создаёт свои
|
||||
* сущности с уникальными именами (через `Date.now()` суффикс), и
|
||||
* `pageSize=N` ограничивает list-запросы — другие тесты в той же org
|
||||
* не мешают.
|
||||
*
|
||||
* Кому НЕ подходит:
|
||||
* - 06-multi-tenant.spec.ts — нужно ДВЕ свежие org per-test, иначе
|
||||
* тест становится бессмысленным (используем OrgFactory напрямую).
|
||||
* - 02-auth.spec.ts — тестирует сам signup, нужна fresh org.
|
||||
* - 09-onboarding-wizard.spec.ts — тестирует онбординг свежей org'и.
|
||||
*
|
||||
* Использование:
|
||||
* import { test } from '../lib/worker-org.js'
|
||||
* test('foo', async ({ workerOrg }) => {
|
||||
* const products = await request(Endpoints.products, { token: workerOrg.session.accessToken })
|
||||
* ...
|
||||
* })
|
||||
*/
|
||||
import { test as baseTest } from '@playwright/test'
|
||||
import { OrgFactory } from '../factories/OrgFactory.js'
|
||||
import type { BuiltOrg } from '../factories/OrgFactory.js'
|
||||
|
||||
interface WorkerFixtures {
|
||||
workerOrg: BuiltOrg
|
||||
}
|
||||
|
||||
export const test = baseTest.extend<{}, WorkerFixtures>({
|
||||
workerOrg: [async ({}, use, workerInfo) => {
|
||||
// Одна org на весь worker. Имя содержит workerIndex чтобы
|
||||
// параллельные workers получали разные org'и (и поэтому
|
||||
// не конкурировали за уникальные суффиксы).
|
||||
const slug = `w${workerInfo.workerIndex}`
|
||||
const org = await OrgFactory.for(slug)
|
||||
.withProducts(3) // базовый каталог чтобы reports что-то видели
|
||||
.withCounterparties(1)
|
||||
.build()
|
||||
await use(org)
|
||||
// После всех тестов worker'a — org остаётся (cleanup делает
|
||||
// Hangfire-job `prune-quality-test-orgs`, см. [[sprint25_done]]).
|
||||
// Можно делать явный DELETE здесь, но это требует SuperAdmin-токена
|
||||
// и cascade-обвязки — пока не реализуем.
|
||||
}, { scope: 'worker' }],
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
Loading…
Reference in a new issue