Sprint 13 — security + observability deep. 7 пунктов чек-листа ✓.
Подробности — docs/sprint13-progress.md и docs/food-market-server-postgres-role.md.
Главное:
- food-market-server (back.food-market.kz, legacy backend) теперь
работает на dedicated PG-роли food_market_server_app (NOSUPERUSER /
NOCREATEDB / NOCREATEROLE / NOREPLICATION / NOBYPASSRLS) с CRUD-only
грантами. Раньше использовался postgres-superuser с паролем 1q2w3e4r.
Бэкап конфига сохранён, rollback одной командой.
- SecurityHeadersMiddleware навешивает CSP / X-Frame-Options DENY /
X-Content-Type-Options nosniff / Referrer-Policy strict-origin /
Permissions-Policy. HSTS 365d + includeSubDomains + preload.
Те же заголовки в deploy/nginx.conf для SPA HTML.
- Rate-limit:
• Signup-IP — 3/час + 10/день (на stage'е переопределено через
.env RATE_SIGNUP_HOUR=30 чтобы не ломать e2e).
• Forgot-password — per-email 3/час + per-IP 10/час.
- SensitiveOpsAudit сервис, wired в:
• TwoFactor enroll/disable
• Employees.Update при смене RoleId (action=AssignRole,
payload с prev/next role + полный RolePermissions)
• MeAccount.ChangePassword (новый endpoint)
• MeSessions.RevokeAll (новый endpoint)
- POST /api/me/sessions/revoke-all — через
IOpenIddictAuthorizationManager.FindBySubjectAsync + TryRevokeAsync.
Integration-тест: refresh после revoke → 400/401.
- Hangfire dashboard — nginx-route добавлен (раньше /hangfire ловился
SPA-fallback'ом). Фильтр SuperAdmin'ом уже был. Тест: anon/tenant →
401/403/404.
- Grafana dashboard JSON (deploy/grafana/dashboards/food-market.json,
9 панелей) + инструкции импорта в docs/observability.md.
Проверено на stage'е: все 6 security-заголовков видны на /;
/hangfire → 401 (закрыт); 4-я форгот → 429; stage-smoke (5 этапов) ✓.
Тесты: 68 unit + 9 integration (включая 3 новых: SessionRevokeTests,
HangfireAccessTests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
174 lines
7.7 KiB
Markdown
174 lines
7.7 KiB
Markdown
# Observability (Prometheus / Grafana)
|
||
|
||
`food-market.api` экспортирует метрики Prometheus на `/metrics` (text exposition
|
||
format, без авторизации). На prod закрываем nginx-уровнем (allow private
|
||
network, deny all) или basic-auth.
|
||
|
||
## Базовые метрики (от prometheus-net)
|
||
|
||
| Метрика | Тип | Лейблы | Что показывает |
|
||
|---|---|---|---|
|
||
| `http_requests_received_total` | counter | code, method, controller, action | Сколько HTTP-запросов прошло — split per controller+action+status. |
|
||
| `http_request_duration_seconds` | histogram | code, method, controller, action | Длительность HTTP, гистограмма для p50/p95/p99 SLO. |
|
||
| `process_cpu_seconds_total` | counter | — | CPU time. |
|
||
| `process_resident_memory_bytes` | gauge | — | RSS. |
|
||
| `dotnet_total_memory_bytes` | gauge | — | Managed heap. |
|
||
| `dotnet_collection_count_total` | counter | generation | GC count по поколениям. |
|
||
|
||
## Кастомные метрики
|
||
|
||
| Метрика | Тип | Лейблы | Семантика |
|
||
|---|---|---|---|
|
||
| `food_market_documents_posted_total` | counter | type | Проведено документов (retail-sale, supply, enter, loss, transfer, inventory, supplier-return, customer-return). |
|
||
| `food_market_sales_posted_total` | counter | — | Alias для `documents_posted{type="retail-sale"}` (явно перечислен в SLO). |
|
||
| `food_market_supplies_posted_total` | counter | — | Alias для `documents_posted{type="supply"}`. |
|
||
| `food_market_documents_error_total` | counter | type, reason | Ошибки проведения: reason `serialization` (40001), `insufficient_stock`, `number_conflict`, `validation`, `other`. |
|
||
| `food_market_db_query_duration_seconds` | histogram | kind | Длительность SQL-запросов EF Core. `kind=query` (SELECT), `kind=command` (INSERT/UPDATE/DELETE/SCALAR). |
|
||
|
||
Tenant-меток в кастомных метриках НЕТ сознательно: на multi-tenant хосте они
|
||
бы раздули cardinality. Per-org разрез — через `/api/reports/*` (там
|
||
authz-фильтр уже работает).
|
||
|
||
## Scrape-конфиг (prometheus.yml)
|
||
|
||
```yaml
|
||
scrape_configs:
|
||
- job_name: food-market-api
|
||
metrics_path: /metrics
|
||
scrape_interval: 15s
|
||
static_configs:
|
||
- targets: ['food-market-api:8080']
|
||
```
|
||
|
||
## Готовый Grafana dashboard
|
||
|
||
В репо лежит JSON-дашборд, готовый к импорту:
|
||
`deploy/grafana/dashboards/food-market.json`. Содержит 9 панелей:
|
||
|
||
1. HTTP — RPS по статус-коду (stacked).
|
||
2. HTTP — latency p50/p95/p99 (5-минутный rolling).
|
||
3. Бизнес — документы посчитаны (Post), per-type RPS.
|
||
4. Бизнес — ошибки проведения per-type/reason.
|
||
5. DB — длительность EF-запросов (heatmap).
|
||
6. HTTP — % 5xx за 5 мин (stat-панель с порогами).
|
||
7. HTTP — % 4xx за 5 мин.
|
||
8. Процесс — память (RSS + managed heap).
|
||
9. GC — сборки в секунду по поколениям.
|
||
|
||
### Импорт в Grafana
|
||
|
||
Через UI:
|
||
1. Grafana → Dashboards → New → Import.
|
||
2. Upload JSON file → выбрать `deploy/grafana/dashboards/food-market.json`.
|
||
3. Datasource — выбрать Prometheus (по дефолту в шаблонной переменной
|
||
`${DS_PROMETHEUS}` написано «Prometheus»).
|
||
4. Import.
|
||
|
||
Через CLI (`curl` к Grafana API, требует Bearer-токен от
|
||
service-account c ролью Editor):
|
||
|
||
```bash
|
||
GRAFANA_URL=http://grafana.local:3000
|
||
GRAFANA_TOKEN=<your-sa-token>
|
||
DS_UID=$(curl -s -H "Authorization: Bearer $GRAFANA_TOKEN" \
|
||
"$GRAFANA_URL/api/datasources/name/Prometheus" | jq -r .uid)
|
||
jq --arg uid "$DS_UID" '
|
||
.dashboard = .;
|
||
.dashboard.id = null;
|
||
.overwrite = true;
|
||
.inputs = [{"name":"DS_PROMETHEUS","type":"datasource","pluginId":"prometheus","value":$uid}];
|
||
{dashboard: .dashboard, overwrite: true, inputs: .inputs, folderId: 0}
|
||
' deploy/grafana/dashboards/food-market.json \
|
||
| curl -s -X POST -H "Authorization: Bearer $GRAFANA_TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d @- "$GRAFANA_URL/api/dashboards/import"
|
||
```
|
||
|
||
Через provisioning (когда Grafana поднимается рядом):
|
||
```yaml
|
||
# /etc/grafana/provisioning/dashboards/food-market.yaml
|
||
apiVersion: 1
|
||
providers:
|
||
- name: food-market
|
||
orgId: 1
|
||
folder: 'food-market'
|
||
type: file
|
||
disableDeletion: false
|
||
updateIntervalSeconds: 60
|
||
options:
|
||
path: /etc/grafana/dashboards/food-market
|
||
```
|
||
Положить `food-market.json` в `/etc/grafana/dashboards/food-market/`.
|
||
|
||
### Альтернатива — минимальный набор панелей (если делать руками):
|
||
|
||
### Health row
|
||
|
||
* **Request rate** — `sum(rate(http_requests_received_total[5m])) by (code)`
|
||
→ стек по 2xx/3xx/4xx/5xx.
|
||
* **Error rate (5xx)** — `sum(rate(http_requests_received_total{code=~"5.."}[5m]))`
|
||
с alert `> 0.1 req/s` (5 минут) → Telegram.
|
||
* **p95 latency** — `histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))`.
|
||
|
||
### Business row
|
||
|
||
* **Sales/hour** — `rate(food_market_sales_posted_total[1h]) * 3600`.
|
||
* **Supplies posted** — `increase(food_market_supplies_posted_total[1d])`.
|
||
* **Document errors** — `sum(rate(food_market_documents_error_total[5m])) by (type, reason)`.
|
||
Alert `serialization rate > 1 req/min`: указывает на лок-контеншн Postgres.
|
||
|
||
### Database row
|
||
|
||
* **EF query rate** — `sum(rate(food_market_db_query_duration_seconds_count[5m])) by (kind)`.
|
||
* **EF query p95** — `histogram_quantile(0.95,
|
||
sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le, kind))`.
|
||
|
||
### Runtime row
|
||
|
||
* **CPU** — `rate(process_cpu_seconds_total[1m]) * 100`.
|
||
* **Memory** — `process_resident_memory_bytes / 1024 / 1024`.
|
||
* **GC Gen2 collections** — `rate(dotnet_collection_count_total{generation="2"}[5m])`.
|
||
|
||
## Alerts (prometheus rules) — пример
|
||
|
||
```yaml
|
||
groups:
|
||
- name: food-market
|
||
rules:
|
||
- alert: HighErrorRate
|
||
expr: sum(rate(http_requests_received_total{code=~"5.."}[5m])) > 0.1
|
||
for: 5m
|
||
labels: { severity: warning }
|
||
annotations:
|
||
summary: "food-market.api возвращает >0.1 5xx/s"
|
||
- alert: DbSerializationContention
|
||
expr: rate(food_market_documents_error_total{reason="serialization"}[5m]) > 0.016
|
||
for: 10m
|
||
labels: { severity: warning }
|
||
annotations:
|
||
summary: "Сериализационные конфликты EF >1/мин"
|
||
- alert: NoSalesIn30Min
|
||
expr: increase(food_market_sales_posted_total[30m]) == 0
|
||
for: 30m
|
||
labels: { severity: info }
|
||
annotations:
|
||
summary: "Нет продаж 30 минут — POS оффлайн или магазин закрыт"
|
||
```
|
||
|
||
## Локальная отладка
|
||
|
||
```bash
|
||
# Чтобы посмотреть метрики из локального API:
|
||
curl http://localhost:5081/metrics | head -50
|
||
|
||
# Конкретная метрика:
|
||
curl -s http://localhost:5081/metrics | grep food_market_sales_posted_total
|
||
```
|
||
|
||
## Поведение в тестовом окружении
|
||
|
||
В интеграционных тестах prometheus-метрики поднимаются как часть
|
||
WebApplicationFactory; счётчики живут per-process (статические `Metrics.Create...`).
|
||
Состояние accumulated между тестами в той же сборке — поэтому в
|
||
`MetricsEndpointTests` мы проверяем «значение увеличилось», а не точное число.
|