diff --git a/Directory.Packages.props b/Directory.Packages.props
index fd78287..1d1cf4e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -44,6 +44,9 @@
+
+
+
diff --git a/docs/observability.md b/docs/observability.md
new file mode 100644
index 0000000..c3c7cb3
--- /dev/null
+++ b/docs/observability.md
@@ -0,0 +1,115 @@
+# 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
+
+Минимальный набор панелей:
+
+### 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` мы проверяем «значение увеличилось», а не точное число.
diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs
index a5acf9c..744843e 100644
--- a/src/food-market.api/Controllers/Pos/PosController.cs
+++ b/src/food-market.api/Controllers/Pos/PosController.cs
@@ -367,8 +367,10 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
// Уникальный конфликт по (OrgId, Number) — крайне редкий race на
// GenerateNumberAsync; пересоздавать номер сложно без транзакции,
// поэтому отказываем и POS попробует в следующем батче.
+ foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("retail-sale", "number_conflict");
throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number");
}
+ foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
return (sale.Id, sale.Number);
}
diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
index 1bf8501..352ad50 100644
--- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs
+++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
@@ -336,8 +336,10 @@ public async Task Post(Guid id, CancellationToken ct)
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
+ foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("supply", "serialization");
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
+ foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("supply");
return NoContent();
}
diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
index c919076..c63e431 100644
--- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs
+++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
@@ -416,6 +416,7 @@ public async Task Post(Guid id, CancellationToken ct)
sale.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
+ foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
return NoContent();
}
diff --git a/src/food-market.api/Infrastructure/Observability/AppMetrics.cs b/src/food-market.api/Infrastructure/Observability/AppMetrics.cs
new file mode 100644
index 0000000..b87d4cb
--- /dev/null
+++ b/src/food-market.api/Infrastructure/Observability/AppMetrics.cs
@@ -0,0 +1,73 @@
+using Prometheus;
+
+namespace foodmarket.Api.Infrastructure.Observability;
+
+/// Бизнес-метрики поверх стандартных HTTP/DB-счётчиков, которые
+/// добавляет prometheus-net.AspNetCore через UseHttpMetrics()
+/// и EF-интерсептор. Сюда выносим то, что важно знать на дашборде вне
+/// зависимости от HTTP-кода: количество проведённых документов, ошибок
+/// проведения, длительность запросов к БД (агрегат поверх интерсептора).
+///
+/// Multi-tenant: лейблы — только тип документа и outcome (success/error).
+/// Tenant-метку НЕ добавляем — на крупном multi-tenant хосте это раздуло бы
+/// cardinality. Если потребуется per-org разрез — отдельный endpoint
+/// `/api/reports/...` уже есть, метрики остаются глобальным health-сигналом.
+public static class AppMetrics
+{
+ /// Счётчик проведённых документов (Post). Метка type:
+ /// "retail-sale", "supply", "enter", "loss", "transfer", "inventory",
+ /// "supplier-return", "customer-return". Метрика инкрементится из
+ /// в коде контроллеров на успешном Post.
+ public static readonly Counter DocumentsPosted = Metrics.CreateCounter(
+ "food_market_documents_posted_total",
+ "Количество успешно проведённых документов учёта.",
+ new CounterConfiguration { LabelNames = new[] { "type" } });
+
+ /// Счётчик ошибок при попытке проведения (BadRequest/Conflict
+ /// и серверные сбои). Метка type — тот же, что у DocumentsPosted;
+ /// reason — короткий код: "insufficient_stock", "serialization",
+ /// "validation", "other".
+ public static readonly Counter DocumentsError = Metrics.CreateCounter(
+ "food_market_documents_error_total",
+ "Количество ошибок при проведении документов.",
+ new CounterConfiguration { LabelNames = new[] { "type", "reason" } });
+
+ /// Алиас на DocumentsPosted с фиксированной меткой "retail-sale"
+ /// — Sprint-spec явно перечисляет sales_posted_total в чек-листе.
+ public static readonly Counter SalesPosted = Metrics.CreateCounter(
+ "food_market_sales_posted_total",
+ "Количество проведённых розничных чеков (alias-метрика).");
+
+ /// Аналогично для приёмок.
+ public static readonly Counter SuppliesPosted = Metrics.CreateCounter(
+ "food_market_supplies_posted_total",
+ "Количество проведённых приёмок (alias-метрика).");
+
+ /// Гистограмма длительности EF Core SQL-запросов в секундах.
+ /// Лейбл kind — "query" / "command" (SELECT vs CUD), помогает
+ /// разделить read-heavy и write-heavy нагрузку. Buckets подобраны под
+ /// типичные веб-операции 5мс…5с.
+ public static readonly Histogram DbQueryDuration = Metrics.CreateHistogram(
+ "food_market_db_query_duration_seconds",
+ "Длительность EF Core SQL-запросов.",
+ new HistogramConfiguration
+ {
+ LabelNames = new[] { "kind" },
+ Buckets = new[] { 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 },
+ });
+
+ /// Сахар: успешное проведение документа. Инкрементит общий
+ /// counter + опциональный alias (для sales/supplies).
+ public static void IncrementPosted(string type)
+ {
+ DocumentsPosted.WithLabels(type).Inc();
+ switch (type)
+ {
+ case "retail-sale": SalesPosted.Inc(); break;
+ case "supply": SuppliesPosted.Inc(); break;
+ }
+ }
+
+ public static void IncrementError(string type, string reason)
+ => DocumentsError.WithLabels(type, reason).Inc();
+}
diff --git a/src/food-market.api/Infrastructure/Observability/DbMetricsInterceptor.cs b/src/food-market.api/Infrastructure/Observability/DbMetricsInterceptor.cs
new file mode 100644
index 0000000..b1b8cfa
--- /dev/null
+++ b/src/food-market.api/Infrastructure/Observability/DbMetricsInterceptor.cs
@@ -0,0 +1,63 @@
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace foodmarket.Api.Infrastructure.Observability;
+
+/// EF Core DbCommandInterceptor, который засекает время каждого
+/// SQL-запроса и пишет в . Использует
+/// диагностический контекст CommandEventData.StartTime чтобы корректно
+/// измерить именно execution-фазу (без подготовки и инициализации соединения).
+///
+/// Лейбл kind:
+/// • "query" — SELECT (Reader/Scalar);
+/// • "command" — INSERT/UPDATE/DELETE/EXECUTE (NonQuery).
+///
+/// Интерсептор регистрируется в DI как Singleton — он stateless. EF DbContext
+/// подхватывает его через AddInterceptors.
+public sealed class DbMetricsInterceptor : DbCommandInterceptor
+{
+ public override ValueTask ReaderExecutedAsync(
+ DbCommand command, CommandExecutedEventData eventData, DbDataReader result,
+ CancellationToken cancellationToken = default)
+ {
+ AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds);
+ return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
+ }
+
+ public override DbDataReader ReaderExecuted(
+ DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
+ {
+ AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds);
+ return base.ReaderExecuted(command, eventData, result);
+ }
+
+ public override ValueTask NonQueryExecutedAsync(
+ DbCommand command, CommandExecutedEventData eventData, int result,
+ CancellationToken cancellationToken = default)
+ {
+ AppMetrics.DbQueryDuration.WithLabels("command").Observe(eventData.Duration.TotalSeconds);
+ return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
+ }
+
+ public override int NonQueryExecuted(
+ DbCommand command, CommandExecutedEventData eventData, int result)
+ {
+ AppMetrics.DbQueryDuration.WithLabels("command").Observe(eventData.Duration.TotalSeconds);
+ return base.NonQueryExecuted(command, eventData, result);
+ }
+
+ public override ValueTask