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 ScalarExecutedAsync( + DbCommand command, CommandExecutedEventData eventData, object? result, + CancellationToken cancellationToken = default) + { + AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds); + return base.ScalarExecutedAsync(command, eventData, result, cancellationToken); + } + + public override object? ScalarExecuted( + DbCommand command, CommandExecutedEventData eventData, object? result) + { + AppMetrics.DbQueryDuration.WithLabels("query").Observe(eventData.Duration.TotalSeconds); + return base.ScalarExecuted(command, eventData, result); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index d176b64..d46a5d6 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Hangfire; using Hangfire.PostgreSql; +using Prometheus; using foodmarket.Api.Infrastructure.RateLimiting; using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Seed; @@ -40,11 +41,18 @@ builder.Services.AddScoped(); - builder.Services.AddDbContext(opts => + // Prometheus metrics: singleton-интерсептор EF засекает длительность каждого + // SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики + // включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем + // (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом. + builder.Services.AddSingleton(); + builder.Services.AddDbContext((sp, opts) => { opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"), npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name)); opts.UseOpenIddict(); + opts.AddInterceptors(sp.GetRequiredService< + foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>()); }); builder.Services.AddIdentity(opts => @@ -267,6 +275,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme app.UseSerilogRequestLogging(); app.UseCors(CorsPolicy); + // Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds). + // Включаем сразу после CORS и до аутентификации, чтобы видеть и 401/403 + // (это сигнал об атаке/неверной конфигурации). prometheus-net уже зашивает + // reserved-лейблы code/method/controller/action из route template'а — + // дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings). + app.UseHttpMetrics(); // До аутентификации: лимитируем перебор пароля ещё на входе, не доводя // до проверки credential'ов в БД. app.UseRateLimiter(); @@ -301,6 +315,11 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme app.MapControllers(); + // /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером + // (rate=15s типично). Доступ без авторизации — стандартная практика; + // на prod ограничиваем nginx-уровнем (allow 10.0.0.0/8, deny all) или basic-auth. + app.MapMetrics("/metrics"); + // Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр // проверяет System.Security.Claims.ClaimsPrincipal (стандартный // OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer — diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj index ed58bf4..b2f9c34 100644 --- a/src/food-market.api/food-market.api.csproj +++ b/src/food-market.api/food-market.api.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/food-market.IntegrationTests/MetricsEndpointTests.cs b/tests/food-market.IntegrationTests/MetricsEndpointTests.cs new file mode 100644 index 0000000..cff835f --- /dev/null +++ b/tests/food-market.IntegrationTests/MetricsEndpointTests.cs @@ -0,0 +1,104 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class MetricsEndpointTests +{ + private readonly ApiFactory _factory; + public MetricsEndpointTests(ApiFactory factory) => _factory = factory; + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + /// /metrics доступен без авторизации (стандарт Prometheus) и + /// выдаёт text/plain в exposition format. + [Fact] + public async Task Metrics_endpoint_unauthenticated_returns_prometheus_text() + { + using var client = _factory.CreateClient(); + using var resp = await client.GetAsync("/metrics"); + resp.IsSuccessStatusCode.Should().BeTrue($"/metrics вернул {(int)resp.StatusCode}"); + resp.Content.Headers.ContentType!.MediaType.Should().StartWith("text/plain"); + var text = await resp.Content.ReadAsStringAsync(); + // process_* + dotnet_* — встроенные коллекторы prometheus-net. + text.Should().Contain("process_cpu_seconds_total"); + // HTTP-метрика появляется после первого UseHttpMetrics-обработанного запроса + // (этот же GET /metrics на пути HTTP-pipeline уже учитан в счётчике). + text.Should().Contain("http_requests_received_total"); + } + + /// После проведения чека счётчик sales_posted увеличивается. + [Fact] + public async Task Sales_posted_counter_increments_after_post() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"metr-sale-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Подкормка склада, чек, проводим. + var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 50m } }, + }); + enter.EnsureSuccessStatusCode(); + var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + + // Снимаем "до". + var before = await ReadMetricSample("food_market_sales_posted_total", null); + + var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null, + customerId = (string?)null, currencyId = refs.CurrencyId, payment = 0, paidCash = 100m, paidCard = 0m, + lines = new[] { new { productId = p1, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m } }, + notes = "sale", + }); + sale.EnsureSuccessStatusCode(); + var sid = (await sale.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode(); + + var after = await ReadMetricSample("food_market_sales_posted_total", null); + after.Should().BeGreaterThan(before); + } + + /// DB-длительность измеряется через интерсептор EF. + [Fact] + public async Task Db_query_duration_metric_observed() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"metr-db-{Guid.NewGuid():N}"); + // Любой запрос с EF в обработчике (signup уже сделал SELECT/INSERT'ы). + await api.GetJsonAsync("/api/catalog/products?pageSize=10"); + + using var client = _factory.CreateClient(); + var text = await client.GetStringAsync("/metrics"); + text.Should().Contain("food_market_db_query_duration_seconds_count"); + } + + /// Читает значение sample'а метрики (тип counter без labels). + /// Возвращает 0 если строки нет. + private async Task ReadMetricSample(string metricName, string? label) + { + using var client = _factory.CreateClient(); + var text = await client.GetStringAsync("/metrics"); + var prefix = label is null ? metricName : metricName; + foreach (var line in text.Split('\n')) + { + if (line.StartsWith("#")) continue; + if (!line.StartsWith(prefix)) continue; + // Формат: "metric_name{labels} value timestamp?" или "metric_name value" + var parts = line.Trim().Split(' '); + if (parts.Length >= 2 && double.TryParse(parts[^1], System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + return v; + } + return 0; + } +}