Overnight progress while 4h-soak runs in background:
1. ApiReferenceDocsJob.cs + scripts/gen-api-reference.py — return-type
regex теперь ловит nested generics любой глубины. Было 195
endpoint'ов в auto-gen reference; стало 240 (+45). EmployeesController
GET /api/organization/employees был пропущен из-за
Task<ActionResult<PagedResult<EmployeeDto>>>.
2. docs/observability.md — добавлен food_market_disk_free_bytes (Sprint 20)
+ раздел "quality-watchdog метрики" (5 метрик textfile exporter'a из
Sprint 26: run_total, step_failure_total, endpoint_p95_ms,
last_run_status, incidents_total). Готовые dashboards теперь содержат
оба JSON (food-market.json + quality-watchdog.json).
3. tests/integration/07-import-export-flows.spec.ts — POST 1C-CSV import
(semicolon-CSV cp1251) → создаются продукты с группой автоматом;
POST /api/org/export (НЕ /api/admin/org-export) → возвращает
{id, status}; orgB не видит export orgA. Прогон 8.2s.
4. tests/food-market.IntegrationTests/PruneQualityTestOrgsTests.cs —
2 [Fact]'a для метода из Sprint 25. Удаляет только quality-* старше
threshold, не трогает реальные org. Требует Testcontainers.
5. .forgejo/workflows/regression.yml — добавлен шаг integration suite
после flows+visual. Telegram: "35 flows + 60 visual + 8 integration".
Soak-real (4h @ 50 RPS) запущен в setsid-detach session, продолжается.
Итоговые числа добавлю в sprint28-progress.md после завершения.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
9.3 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). |
food_market_disk_free_bytes |
gauge | mount | Sprint 20: свободное место на диске (обновляется ежечасным DiskMonitoringJob). |
Tenant-меток в кастомных метриках НЕТ сознательно: на multi-tenant хосте они
бы раздули cardinality. Per-org разрез — через /api/reports/* (там
authz-фильтр уже работает).
quality-watchdog метрики (Sprint 26+)
~/quality-watchdog.sh после каждого прогона пишет
~/.fm-watchdog/textfile/quality_watchdog.prom — формат Prometheus
textfile. Подбирается через
node_exporter --collector.textfile.directory=$HOME/.fm-watchdog/textfile.
| Метрика | Тип | Лейблы | Семантика |
|---|---|---|---|
quality_watchdog_run_total |
counter | result |
Кол-во прогонов watchdog'a, разделённых на green/red. |
quality_watchdog_step_failure_total |
counter | step |
Падений per-step (health, auth_me, products, ui_flow, metrics, signalr, multi_tenant, perf). |
quality_watchdog_endpoint_p95_ms |
gauge | endpoint |
p95 latency последнего прогона per-endpoint. |
quality_watchdog_last_run_status |
gauge | — | 1 если все шаги зелёные, 0 иначе. |
quality_watchdog_incidents_total |
counter | — | Создано incident-файлов (2× consecutive fail) за всё время. |
Эти метрики питают deploy/grafana/dashboards/quality-watchdog.json
(Sprint 26, 10 панелей).
Scrape-конфиг (prometheus.yml)
scrape_configs:
- job_name: food-market-api
metrics_path: /metrics
scrape_interval: 15s
static_configs:
- targets: ['food-market-api:8080']
Готовые Grafana dashboards
В репо два готовых JSON-дашборда:
| Файл | UID | Назначение |
|---|---|---|
deploy/grafana/dashboards/food-market.json |
fm-baseline |
Sprint 13 baseline: HTTP / EF / бизнес-метрики |
deploy/grafana/dashboards/quality-watchdog.json |
fm-quality-watchdog |
Sprint 26: smoke success / p95 / multi-tenant violations / incidents |
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 мы проверяем «значение увеличилось», а не точное число.