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