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>
105 lines
5.2 KiB
C#
105 lines
5.2 KiB
C#
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;
|
||
}
|
||
}
|