food-market/docs/observability.md
nns 824ef8279c feat(observability): Prometheus метрики /metrics + бизнес-счётчики (P1-17)
prometheus-net.AspNetCore@8.2.1 + EF Core DbCommandInterceptor.

Endpoint: GET /metrics (text exposition, без auth — типичная практика;
на prod закроем nginx allow private-network).

Стандартные метрики (через UseHttpMetrics):
- http_requests_received_total (code/method/controller/action)
- http_request_duration_seconds (histogram, p50/p95/p99 SLO)
- process_cpu_seconds_total / dotnet_total_memory_bytes / GC counters

Кастомные бизнес-метрики (AppMetrics):
- food_market_documents_posted_total{type} — все типы документов
- food_market_sales_posted_total — alias по retail-sale (явно в SLO)
- food_market_supplies_posted_total — alias по supply
- food_market_documents_error_total{type, reason} — ошибки проведения
  с разбивкой по причине (serialization=40001, insufficient_stock,
  number_conflict, validation, other)
- food_market_db_query_duration_seconds{kind} — гистограмма SQL через
  DbMetricsInterceptor (kind=query для SELECT, command для CUD)

Tenant-меток в кастомных метриках НЕТ сознательно: на multi-tenant хосте
раздуло бы cardinality. Per-org разрез — через /api/reports/*.

Counters добавлены в:
- SuppliesController.Post (success + serialization-error)
- RetailSalesController.Post (success)
- PosController.CreateAndPostSaleAsync (success + number_conflict)

docs/observability.md — scrape-конфиг prometheus.yml, образец Grafana
dashboard (4 ряда: Health/Business/Database/Runtime), prometheus rules
с alert'ами (HighErrorRate, DbSerializationContention, NoSalesIn30Min).

Тесты: 3 интеграционных (endpoint доступен и возвращает text/plain с
встроенными метриками; sales counter инкрементится после Post; db_query
гистограмма накапливается).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:20:01 +05:00

5.4 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

Минимальный набор панелей:

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