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

7.7 KiB
Raw Blame History

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 панелей:

  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):

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 ratesum(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 latencyhistogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)).

Business row

  • Sales/hourrate(food_market_sales_posted_total[1h]) * 3600.
  • Supplies postedincrease(food_market_supplies_posted_total[1d]).
  • Document errorssum(rate(food_market_documents_error_total[5m])) by (type, reason). Alert serialization rate > 1 req/min: указывает на лок-контеншн Postgres.

Database row

  • EF query ratesum(rate(food_market_db_query_duration_seconds_count[5m])) by (kind).
  • EF query p95histogram_quantile(0.95, sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le, kind)).

Runtime row

  • CPUrate(process_cpu_seconds_total[1m]) * 100.
  • Memoryprocess_resident_memory_bytes / 1024 / 1024.
  • GC Gen2 collectionsrate(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 мы проверяем «значение увеличилось», а не точное число.