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:
parent
0854c55d9d
commit
824ef8279c
|
|
@ -44,6 +44,9 @@
|
|||
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.17" />
|
||||
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.10" />
|
||||
|
||||
<!-- Observability / Prometheus -->
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
|
||||
<!-- POS: local storage + API client -->
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageVersion Include="Refit" Version="7.2.22" />
|
||||
|
|
|
|||
115
docs/observability.md
Normal file
115
docs/observability.md
Normal 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` мы проверяем «значение увеличилось», а не точное число.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -336,8 +336,10 @@ public async Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@ public async Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
|
||||
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"),
|
||||
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
|
||||
opts.UseOpenIddict();
|
||||
opts.AddInterceptors(sp.GetRequiredService<
|
||||
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
|
||||
});
|
||||
|
||||
builder.Services.AddIdentity<User, Role>(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 —
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="CsvHelper" />
|
||||
<PackageReference Include="ClosedXML" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
104
tests/food-market.IntegrationTests/MetricsEndpointTests.cs
Normal file
104
tests/food-market.IntegrationTests/MetricsEndpointTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue