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>
This commit is contained in:
nns 2026-05-28 12:20:01 +05:00
parent 0854c55d9d
commit 824ef8279c
10 changed files with 384 additions and 1 deletions

View file

@ -44,6 +44,9 @@
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.17" /> <PackageVersion Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.10" /> <PackageVersion Include="Hangfire.PostgreSql" Version="1.20.10" />
<!-- Observability / Prometheus -->
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<!-- POS: local storage + API client --> <!-- POS: local storage + API client -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageVersion Include="Refit" Version="7.2.22" /> <PackageVersion Include="Refit" Version="7.2.22" />

115
docs/observability.md Normal file
View file

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

View file

@ -367,8 +367,10 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
// Уникальный конфликт по (OrgId, Number) — крайне редкий race на // Уникальный конфликт по (OrgId, Number) — крайне редкий race на
// GenerateNumberAsync; пересоздавать номер сложно без транзакции, // GenerateNumberAsync; пересоздавать номер сложно без транзакции,
// поэтому отказываем и POS попробует в следующем батче. // поэтому отказываем и POS попробует в следующем батче.
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("retail-sale", "number_conflict");
throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number"); throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number");
} }
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
return (sale.Id, sale.Number); return (sale.Id, sale.Number);
} }

View file

@ -336,8 +336,10 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
} }
catch (Exception ex) when (IsSerializationConflict(ex)) catch (Exception ex) when (IsSerializationConflict(ex))
{ {
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("supply", "serialization");
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." }); return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
} }
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("supply");
return NoContent(); return NoContent();
} }

View file

@ -416,6 +416,7 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
sale.PostedAt = DateTime.UtcNow; sale.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
return NoContent(); return NoContent();
} }

View file

@ -0,0 +1,73 @@
using Prometheus;
namespace foodmarket.Api.Infrastructure.Observability;
/// <summary>Бизнес-метрики поверх стандартных HTTP/DB-счётчиков, которые
/// добавляет <c>prometheus-net.AspNetCore</c> через <c>UseHttpMetrics()</c>
/// и EF-интерсептор. Сюда выносим то, что важно знать на дашборде вне
/// зависимости от HTTP-кода: количество проведённых документов, ошибок
/// проведения, длительность запросов к БД (агрегат поверх интерсептора).
///
/// Multi-tenant: лейблы — только тип документа и outcome (success/error).
/// Tenant-метку НЕ добавляем — на крупном multi-tenant хосте это раздуло бы
/// cardinality. Если потребуется per-org разрез — отдельный endpoint
/// `/api/reports/...` уже есть, метрики остаются глобальным health-сигналом.</summary>
public static class AppMetrics
{
/// <summary>Счётчик проведённых документов (Post). Метка <c>type</c>:
/// "retail-sale", "supply", "enter", "loss", "transfer", "inventory",
/// "supplier-return", "customer-return". Метрика инкрементится из
/// <see cref="IncrementPosted"/> в коде контроллеров на успешном Post.</summary>
public static readonly Counter DocumentsPosted = Metrics.CreateCounter(
"food_market_documents_posted_total",
"Количество успешно проведённых документов учёта.",
new CounterConfiguration { LabelNames = new[] { "type" } });
/// <summary>Счётчик ошибок при попытке проведения (BadRequest/Conflict
/// и серверные сбои). Метка <c>type</c> — тот же, что у DocumentsPosted;
/// <c>reason</c> — короткий код: "insufficient_stock", "serialization",
/// "validation", "other".</summary>
public static readonly Counter DocumentsError = Metrics.CreateCounter(
"food_market_documents_error_total",
"Количество ошибок при проведении документов.",
new CounterConfiguration { LabelNames = new[] { "type", "reason" } });
/// <summary>Алиас на DocumentsPosted с фиксированной меткой "retail-sale"
/// — Sprint-spec явно перечисляет sales_posted_total в чек-листе.</summary>
public static readonly Counter SalesPosted = Metrics.CreateCounter(
"food_market_sales_posted_total",
"Количество проведённых розничных чеков (alias-метрика).");
/// <summary>Аналогично для приёмок.</summary>
public static readonly Counter SuppliesPosted = Metrics.CreateCounter(
"food_market_supplies_posted_total",
"Количество проведённых приёмок (alias-метрика).");
/// <summary>Гистограмма длительности EF Core SQL-запросов в секундах.
/// Лейбл <c>kind</c> — "query" / "command" (SELECT vs CUD), помогает
/// разделить read-heavy и write-heavy нагрузку. Buckets подобраны под
/// типичные веб-операции 5мс…5с.</summary>
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 },
});
/// <summary>Сахар: успешное проведение документа. Инкрементит общий
/// counter + опциональный alias (для sales/supplies).</summary>
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();
}

View file

@ -0,0 +1,63 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace foodmarket.Api.Infrastructure.Observability;
/// <summary>EF Core <c>DbCommandInterceptor</c>, который засекает время каждого
/// SQL-запроса и пишет в <see cref="AppMetrics.DbQueryDuration"/>. Использует
/// диагностический контекст <c>CommandEventData.StartTime</c> чтобы корректно
/// измерить именно execution-фазу (без подготовки и инициализации соединения).
///
/// Лейбл <c>kind</c>:
/// • "query" — SELECT (Reader/Scalar);
/// • "command" — INSERT/UPDATE/DELETE/EXECUTE (NonQuery).
///
/// Интерсептор регистрируется в DI как Singleton — он stateless. EF DbContext
/// подхватывает его через <c>AddInterceptors</c>.</summary>
public sealed class DbMetricsInterceptor : DbCommandInterceptor
{
public override ValueTask<DbDataReader> 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<int> 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<object?> 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);
}
}

View file

@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Hangfire; using Hangfire;
using Hangfire.PostgreSql; using Hangfire.PostgreSql;
using Prometheus;
using foodmarket.Api.Infrastructure.RateLimiting; using foodmarket.Api.Infrastructure.RateLimiting;
using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Seed; using foodmarket.Api.Seed;
@ -40,11 +41,18 @@
builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation, builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>(); foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();
builder.Services.AddDbContext<AppDbContext>(opts => // Prometheus metrics: singleton-интерсептор EF засекает длительность каждого
// SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{ {
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"), opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name)); npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
opts.UseOpenIddict(); opts.UseOpenIddict();
opts.AddInterceptors(sp.GetRequiredService<
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
}); });
builder.Services.AddIdentity<User, Role>(opts => builder.Services.AddIdentity<User, Role>(opts =>
@ -267,6 +275,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseCors(CorsPolicy); 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'ов в БД. // до проверки credential'ов в БД.
app.UseRateLimiter(); app.UseRateLimiter();
@ -301,6 +315,11 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
app.MapControllers(); 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-фильтр // Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр
// проверяет System.Security.Claims.ClaimsPrincipal (стандартный // проверяет System.Security.Claims.ClaimsPrincipal (стандартный
// OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer — // OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer —

View file

@ -24,6 +24,7 @@
<PackageReference Include="Hangfire.PostgreSql" /> <PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="CsvHelper" /> <PackageReference Include="CsvHelper" />
<PackageReference Include="ClosedXML" /> <PackageReference Include="ClosedXML" />
<PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -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)));
/// <summary>/metrics доступен без авторизации (стандарт Prometheus) и
/// выдаёт text/plain в exposition format.</summary>
[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");
}
/// <summary>После проведения чека счётчик sales_posted увеличивается.</summary>
[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<JsonElement>()).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<JsonElement>()).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);
}
/// <summary>DB-длительность измеряется через интерсептор EF.</summary>
[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");
}
/// <summary>Читает значение sample'а метрики (тип counter без labels).
/// Возвращает 0 если строки нет.</summary>
private async Task<double> 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;
}
}