Files
Deal/src/core/Deal.Api/Observability/DealMetricsCollector.cs
T
Rustam Khalimov 410194b0cb Разбить Infrastructure и корень Deal.Api по назначению
Integrations -> Abstractions/Exceptions/Extensions/Models/Options/
Services (включая Storage); Persistence-конфигурации -> Configurations;
корень Deal.Api (оркестратор/планировщики/DTO) -> Services/Dtos.
namespace приведён к путям, using добавлены/дедуплицированы, FQN
обновлены.
2026-09-11 13:20:10 +03:00

131 lines
5.8 KiB
C#

using Deal.SharedKernel.Observability;
using Deal.Api.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Observability;
/// <summary>
/// Фоновый сборщик gauge-метрик ядра (этап 12, пакет A): глубины очередей и активные сессии.
/// </summary>
/// <remarks>
/// <para>
/// Каждые 15 с собирает снимок через <see cref="RuntimeDepthsCollector"/> (обход реестра тенантов и подсчёт
/// существующими сервисами/портами — логика не дублируется) и публикует значения в <see cref="DealMetrics"/>
/// (callback ObservableGauge отдаёт их Prometheus при scrape): суммарные глубины pipeline/ML-outbox и число
/// активных сессий. Ошибки сбора логируются и наружу не выбрасываются (сбой тенанта не валит проход);
/// перекрывающиеся проходы исключены in-flight guard (Interlocked); при остановке хоста таймер
/// останавливается, текущий проход отменяется. Gauge без сбора остаётся на последнем значении.
/// </para>
/// </remarks>
public sealed class DealMetricsCollector : IHostedService
{
/// <summary>
/// Период сбора gauge-метрик — 15 с (запас по нагрузке: запросы лёгкие, счётчики — агрегаты).
/// </summary>
public const int CollectionPeriodSeconds = 15;
private readonly RuntimeDepthsCollector _depthsCollector;
private readonly ILogger<DealMetricsCollector> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт фоновый сборщик gauge-метрик.
/// </summary>
/// <param name="depthsCollector">Сборщик глубин очередей/сессий (общий с операторским health).</param>
/// <param name="logger">Логгер ошибок цикла.</param>
public DealMetricsCollector(RuntimeDepthsCollector depthsCollector, ILogger<DealMetricsCollector> logger)
{
ArgumentNullException.ThrowIfNull(depthsCollector);
ArgumentNullException.ThrowIfNull(logger);
_depthsCollector = depthsCollector;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(
static state => ((DealMetricsCollector)state!).RunIteration(),
this,
TimeSpan.Zero,
TimeSpan.FromSeconds(CollectionPeriodSeconds));
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken ct)
{
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_timer?.Dispose();
_timer = null;
_shutdownCts.Cancel();
Task? iteration = Volatile.Read(ref _currentIteration);
if (iteration is not null)
{
try
{
await iteration.WaitAsync(ct);
}
catch (OperationCanceledException)
{
// Лимит остановки истёк — ошибки прохода проглочены внутри CollectCycleAsync.
}
}
}
/// <summary>
/// Один проход сбора (no-op, если проход уже идёт); публичен как точка запуска для тестов.
/// </summary>
/// <param name="ct">Токен отмены прохода.</param>
/// <returns>Задача прохода (завершается без исключений).</returns>
public Task RunCycleAsync(CancellationToken ct)
{
if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0)
{
return Task.CompletedTask;
}
Task iteration = CollectCycleAsync(ct);
Volatile.Write(ref _currentIteration, iteration);
return iteration;
}
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
private void RunIteration()
{
_ = RunCycleAsync(_shutdownCts.Token);
}
// Тело прохода: снимок глубин/сессий и публикация в meter Deal; guard сбрасывается в finally.
// ct: Токен отмены (остановка хоста).
private async Task CollectCycleAsync(CancellationToken ct)
{
try
{
RuntimeDepthsDto depths = await _depthsCollector.CollectAsync(ct);
DealMetrics.SetPipelineQueueDepth(depths.PipelineQueue);
DealMetrics.SetMlOutboxDepth(depths.MlOutbox);
DealMetrics.SetActiveSessions(depths.ActiveSessions);
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
_logger.LogError(exception, "Сборщик метрик: проход по тенантам не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
}