food-market/docs/observability.md
nns 8e54e2e0d6 feat(s13): security headers + rate-limits + sensitive-ops audit + session revoke + Grafana
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>
2026-06-07 12:30:10 +05:00

174 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` мы проверяем «значение увеличилось», а не точное число.