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>
7.7 KiB
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)
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 панелей:
- HTTP — RPS по статус-коду (stacked).
- HTTP — latency p50/p95/p99 (5-минутный rolling).
- Бизнес — документы посчитаны (Post), per-type RPS.
- Бизнес — ошибки проведения per-type/reason.
- DB — длительность EF-запросов (heatmap).
- HTTP — % 5xx за 5 мин (stat-панель с порогами).
- HTTP — % 4xx за 5 мин.
- Процесс — память (RSS + managed heap).
- GC — сборки в секунду по поколениям.
Импорт в Grafana
Через UI:
- Grafana → Dashboards → New → Import.
- Upload JSON file → выбрать
deploy/grafana/dashboards/food-market.json. - Datasource — выбрать Prometheus (по дефолту в шаблонной переменной
${DS_PROMETHEUS}написано «Prometheus»). - Import.
Через CLI (curl к Grafana API, требует Bearer-токен от
service-account c ролью Editor):
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 поднимается рядом):
# /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). Alertserialization 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) — пример
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 оффлайн или магазин закрыт"
Локальная отладка
# Чтобы посмотреть метрики из локального 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 мы проверяем «значение увеличилось», а не точное число.