food-market/tests/food-market.IntegrationTests/MetricsEndpointTests.cs
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

105 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}