Добавить метрику расхода ИИ-бюджета

Gauge deal.ai.budget.used.ratio{tenant} (доля израсходованного бюджета периода) собирается RuntimeDepthsCollector и публикуется DealMetricsCollector — основа для алертинга. Закрывает BL-ALERT-BUDGET.
This commit is contained in:
Rustam Khalimov
2026-09-11 17:30:26 +03:00
parent 000d433438
commit d2b716bbf4
11 changed files with 272 additions and 21 deletions
+1 -1
View File
@@ -42,7 +42,7 @@
| ID | Пункт | Источник | Приоритет | Статус | | ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---| |---|---|---|---|---|
| BL-ALERT-BUDGET | Метрика расхода токенов для алерта бюджета (сейчас алерт не настроен — нет метрики) | этап 12, A | P2 | TECHDEBT | | BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
| BL-LOG-ACTOR | Идентичность актора в access-логах (сейчас login/tenant только в `audit_log`) | этап 12, T6 | P3 | TECHDEBT | | BL-LOG-ACTOR | Идентичность актора в access-логах (сейчас login/tenant только в `audit_log`) | этап 12, T6 | P3 | TECHDEBT |
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG | | BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG | | BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
+3 -2
View File
@@ -12,8 +12,9 @@
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру > `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр > (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника». > исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1307/1307 PASS**, telegram **130/130**, фронт `build` + > Метрика алертинга `deal.ai.budget.used.ratio{tenant}`. Ядро: build 5 sln 0/0,
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. > `Deal.Tests.Unit` **1310/1310 PASS**, telegram **130/130**, фронт `build` + `lint:i18n` зелёные.
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений > Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики > telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис. > (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
@@ -272,7 +272,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
- `deal_ml_calls_total` / `deal_ml_tokens_total` — вызовы и оценка токенов локального ML; - `deal_ml_calls_total` / `deal_ml_tokens_total` — вызовы и оценка токенов локального ML;
- `deal_audit_events_total{event,actor}` — события аудита по типу/актору; - `deal_audit_events_total{event,actor}` — события аудита по типу/актору;
- `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` — суммарные глубины очередей (пайплайн, MlOutbox) - `deal_pipeline_queue_depth`, `deal_ml_outbox_depth` — суммарные глубины очередей (пайплайн, MlOutbox)
по всем тенантам; `deal_sessions_active` — активные непросроченные сессии пользователей и операторов. по всем тенантам; `deal_sessions_active` — активные непросроченные сессии пользователей и операторов;
- `deal_ai_budget_used_ratio{tenant}` — доля израсходованного ИИ-бюджета периода (0..1) по тенантам
(осознанное исключение из низкокардинального правила: бюджеты пер-тенантные, алерт должен знать тенанта).
Gauge-значения собирает фоновый `DealMetricsCollector` ядра (каждые 15 с) через существующие Gauge-значения собирает фоновый `DealMetricsCollector` ядра (каждые 15 с) через существующие
сервисы/хранилища (`PipelineProcessingService.QueueCountsAsync`, `IMlLearningStore.CountOutboxAsync`, сервисы/хранилища (`PipelineProcessingService.QueueCountsAsync`, `IMlLearningStore.CountOutboxAsync`,
`public.sessions`/`operator_sessions`); инкремент счётчиков токенов/аудита — там же, где пишутся `public.sessions`/`operator_sessions`); инкремент счётчиков токенов/аудита — там же, где пишутся
@@ -100,6 +100,8 @@ public sealed class DealMetricsCollector : IHostedService
DealMetrics.SetPipelineQueueDepth(depths.PipelineQueue); DealMetrics.SetPipelineQueueDepth(depths.PipelineQueue);
DealMetrics.SetMlOutboxDepth(depths.MlOutbox); DealMetrics.SetMlOutboxDepth(depths.MlOutbox);
DealMetrics.SetActiveSessions(depths.ActiveSessions); DealMetrics.SetActiveSessions(depths.ActiveSessions);
DealMetrics.ReplaceBudgetRatios(
(depths.Budgets ?? []).Select(budget => (budget.TenantId, budget.UsedRatio)));
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -39,8 +39,10 @@ public sealed class RuntimeDepthsCollector
{ {
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
int sessions = await CountActiveSessionsAsync(cycleScope, ct); int sessions = await CountActiveSessionsAsync(cycleScope, ct);
(long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct); IReadOnlyList<TenantRecordDto> tenants = await ListTenantsAsync(cycleScope, ct);
return new RuntimeDepthsDto(queue, outbox, sessions); (long queue, long outbox) = await SumTenantDepthsAsync(tenants, ct);
IReadOnlyList<TenantBudgetUsageDto> budgets = await CollectBudgetsAsync(cycleScope, tenants, ct);
return new RuntimeDepthsDto(queue, outbox, sessions, budgets);
} }
// Считает активные непросроченные сессии пользователей и операторов (public-схема). // Считает активные непросроченные сессии пользователей и операторов (public-схема).
@@ -68,24 +70,16 @@ public sealed class RuntimeDepthsCollector
} }
} }
// Суммирует глубины очередей по всем тенантам реестра. // Список тенантов реестра (без tenant-контекста — public-схема).
// cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста). // cycleScope: Scope прохода.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0). // Возвращает: Тенанты реестра; недоступность реестра — пустой список.
private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct) private async Task<IReadOnlyList<TenantRecordDto>> ListTenantsAsync(AsyncServiceScope cycleScope, CancellationToken ct)
{ {
long queueDepth = 0;
long outboxDepth = 0;
try try
{ {
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>(); ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct); return await tenantRepository.ListAsync(ct);
foreach (TenantRecordDto tenant in tenants)
{
(int queue, int outbox) = await CollectTenantAsync(tenant, ct);
queueDepth += queue;
outboxDepth += outbox;
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -94,11 +88,76 @@ public sealed class RuntimeDepthsCollector
catch (Exception exception) catch (Exception exception)
{ {
_logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался");
return [];
}
}
// Суммирует глубины очередей переданных тенантов.
// tenants: Тенанты реестра.
// ct: Токен отмены.
// Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox).
private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(
IReadOnlyList<TenantRecordDto> tenants,
CancellationToken ct)
{
long queueDepth = 0;
long outboxDepth = 0;
foreach (TenantRecordDto tenant in tenants)
{
(int queue, int outbox) = await CollectTenantAsync(tenant, ct);
queueDepth += queue;
outboxDepth += outbox;
} }
return (queueDepth, outboxDepth); return (queueDepth, outboxDepth);
} }
// Собирает долю израсходованного ИИ-бюджета по тенантам.
// cycleScope: Scope прохода (public-схема: tenant_limits — без tenant-контекста).
// tenants: Тенанты реестра.
// ct: Токен отмены.
// Возвращает: Доли 0..1 по тенантам; сбой одного тенанта — он пропускается.
private async Task<IReadOnlyList<TenantBudgetUsageDto>> CollectBudgetsAsync(
AsyncServiceScope cycleScope,
IReadOnlyList<TenantRecordDto> tenants,
CancellationToken ct)
{
var budgets = new List<TenantBudgetUsageDto>(tenants.Count);
try
{
ITenantLimitStore limits = cycleScope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
foreach (TenantRecordDto tenant in tenants)
{
try
{
BudgetStateDto state = await limits.GetStateAsync(tenant.Id, ct);
double ratio = state.BudgetTokens > 0
? Math.Clamp(state.UsedTokens / (double)state.BudgetTokens, 0.0, 1.0)
: 0.0;
budgets.Add(new TenantBudgetUsageDto(tenant.Id.ToString("N"), ratio));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Сборщик глубин: бюджет тенанта {TenantId} не прочитан", tenant.Id);
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Сборщик глубин: секция бюджета не удалась");
}
return budgets;
}
// Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset). // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset).
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;). // tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода. // ct: Токен отмены прохода.
@@ -6,4 +6,9 @@ namespace Deal.Api.Observability;
/// <param name="PipelineQueue">Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.</param> /// <param name="PipelineQueue">Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.</param>
/// <param name="MlOutbox">Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.</param> /// <param name="MlOutbox">Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.</param>
/// <param name="ActiveSessions">Число активных непросроченных сессий пользователей и операторов.</param> /// <param name="ActiveSessions">Число активных непросроченных сессий пользователей и операторов.</param>
public sealed record RuntimeDepthsDto(long PipelineQueue, long MlOutbox, int ActiveSessions); /// <param name="Budgets">Использование ИИ-бюджета по тенантам (пусто — данных нет).</param>
public sealed record RuntimeDepthsDto(
long PipelineQueue,
long MlOutbox,
int ActiveSessions,
IReadOnlyList<TenantBudgetUsageDto>? Budgets = null);
@@ -0,0 +1,8 @@
namespace Deal.Api.Observability;
/// <summary>
/// Использование ИИ-бюджета тенанта — для метрики алертинга.
/// </summary>
/// <param name="TenantId">Id тенанта (Guid в формате «N»).</param>
/// <param name="UsedRatio">Доля израсходованного бюджета периода 0..1; бюджет не задан — 0.</param>
public sealed record TenantBudgetUsageDto(string TenantId, double UsedRatio);
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
@@ -38,6 +39,16 @@ public static class DealMetrics
/// </summary> /// </summary>
public const string AuditActorTagName = "actor"; public const string AuditActorTagName = "actor";
/// <summary>
/// Имя метки тенанта
/// </summary>
public const string TenantTagName = "tenant";
/// <summary>
/// Имя метрики доли израсходованного ИИ-бюджета
/// </summary>
public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio";
// Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics). // Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics).
private static readonly Meter Meter = new(MeterName); private static readonly Meter Meter = new(MeterName);
@@ -98,6 +109,17 @@ public static class DealMetrics
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)), (Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)),
description: "Активные непросроченные сессии пользователей и операторов."); description: "Активные непросроченные сессии пользователей и операторов.");
/// <summary>
/// Доля израсходованного ИИ-бюджета по тенантам
/// </summary>
public static readonly ObservableGauge<double> AiBudgetUsedRatio =
Meter.CreateObservableGauge(
AiBudgetUsedRatioName,
ObserveBudgetRatios,
description: "Доля израсходованного ИИ-бюджета периода по тенантам (0..1).");
private static readonly ConcurrentDictionary<string, double> BudgetRatios = new(StringComparer.Ordinal);
private static long _pipelineQueueDepth; private static long _pipelineQueueDepth;
private static long _mlOutboxDepth; private static long _mlOutboxDepth;
private static long _activeSessions; private static long _activeSessions;
@@ -163,4 +185,23 @@ public static class DealMetrics
/// </summary> /// </summary>
/// <param name="count">Активные непросроченные сессии пользователей и операторов.</param> /// <param name="count">Активные непросроченные сессии пользователей и операторов.</param>
public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count); public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count);
/// <summary>
/// Публикует доли расхода ИИ-бюджета по тенантам (заменяет предыдущий снимок)
/// </summary>
/// <param name="ratios">Пары (id тенанта, доля 0..1).</param>
public static void ReplaceBudgetRatios(IEnumerable<(string TenantId, double Ratio)> ratios)
{
BudgetRatios.Clear();
foreach ((string tenantId, double ratio) in ratios)
{
BudgetRatios[tenantId] = Math.Clamp(ratio, 0.0, 1.0);
}
}
// Снимок долей расхода бюджета для observable-gauge (по измерению на тенант).
private static IEnumerable<Measurement<double>> ObserveBudgetRatios()
=> BudgetRatios.Select(pair => new Measurement<double>(
pair.Value,
new TagList { { TenantTagName, pair.Key } }));
} }
@@ -23,6 +23,7 @@ public sealed class RuntimeDepthsCollectorTests
{ {
private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid TenantC = Guid.Parse("33333333-3333-3333-3333-333333333333");
[Fact] [Fact]
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
@@ -50,6 +51,53 @@ public sealed class RuntimeDepthsCollectorTests
Assert.Equal(3, depths.PipelineQueue); Assert.Equal(3, depths.PipelineQueue);
Assert.Equal(1, depths.MlOutbox); Assert.Equal(1, depths.MlOutbox);
Assert.Equal(0, depths.ActiveSessions); Assert.Equal(0, depths.ActiveSessions);
Assert.NotNull(depths.Budgets);
Assert.Equal(2, depths.Budgets!.Count);
}
[Fact]
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
{
var limits = new FakeTenantLimitStore();
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
limits.Preload(TenantC, 100, TenantLimitPeriods.Month, periodStart, 150);
RuntimeDepthsCollector collector = Build(
PipelineStores(TenantA, TenantB, TenantC),
OutboxStores(TenantA, TenantB, TenantC),
TenantRecords(TenantA, TenantB, TenantC),
limits);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
Assert.NotNull(depths.Budgets);
Assert.Equal(3, depths.Budgets!.Count);
Assert.Equal(0.8, RatioFor(depths.Budgets, TenantA), 3);
Assert.Equal(0.0, RatioFor(depths.Budgets, TenantB), 3);
Assert.Equal(1.0, RatioFor(depths.Budgets, TenantC), 3);
}
[Fact]
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
{
var limits = new FakeTenantLimitStore();
limits.FailStateReads.Add(TenantA);
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
RuntimeDepthsCollector collector = Build(
PipelineStores(TenantA, TenantB),
OutboxStores(TenantA, TenantB),
TenantRecords(TenantA, TenantB),
limits);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
Assert.NotNull(depths.Budgets);
TenantBudgetUsageDto budget = Assert.Single(depths.Budgets!);
Assert.Equal(TenantB.ToString("N"), budget.TenantId);
Assert.Equal(0.8, budget.UsedRatio, 3);
} }
[Fact] [Fact]
@@ -74,11 +122,26 @@ public sealed class RuntimeDepthsCollectorTests
Status = PipelineQueueStatuses.New, Status = PipelineQueueStatuses.New,
}; };
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
=> tenants
.Select((tenant, index) => new TenantRecordDto(tenant, $"T{index}", "active", DateTimeOffset.UtcNow))
.ToArray();
private static double RatioFor(IReadOnlyList<TenantBudgetUsageDto> budgets, Guid tenant)
=> budgets.Single(budget => budget.TenantId == tenant.ToString("N")).UsedRatio;
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build( private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant, IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant, IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null) IReadOnlyList<TenantRecordDto>? tenants = null,
ITenantLimitStore? limitStore = null)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton<ITenantContext, TenantContext>(); services.AddSingleton<ITenantContext, TenantContext>();
@@ -94,6 +157,7 @@ public sealed class RuntimeDepthsCollectorTests
services.AddScoped<PipelineIngestService>(); services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>(); services.AddScoped<PipelineProcessingService>();
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]); services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore());
ServiceProvider provider = services.BuildServiceProvider(); ServiceProvider provider = services.BuildServiceProvider();
return new RuntimeDepthsCollector( return new RuntimeDepthsCollector(
@@ -55,6 +55,11 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
/// </summary> /// </summary>
public string TenantStatus { get; set; } public string TenantStatus { get; set; }
/// <summary>
/// Тенанты, для которых <see cref="GetStateAsync"/> бросает исключение (устойчивость сборщиков)
/// </summary>
public HashSet<Guid> FailStateReads { get; } = new();
/// <summary> /// <summary>
/// Кладёт готовую строку лимита /// Кладёт готовую строку лимита
/// </summary> /// </summary>
@@ -124,6 +129,11 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
/// <inheritdoc /> /// <inheritdoc />
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct) public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
{ {
if (FailStateReads.Contains(tenantId))
{
throw new InvalidOperationException($"Фейковый сбой чтения состояния лимита тенанта {tenantId}.");
}
Row row = Ensure(tenantId, TokenBudgetDefaults.Default); Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
ResetIfPeriodExpired(row); ResetIfPeriodExpired(row);
return Task.FromResult(ToStateDto(row, tenantId)); return Task.FromResult(ToStateDto(row, tenantId));
@@ -0,0 +1,59 @@
using System.Diagnostics.Metrics;
using Deal.SharedKernel.Observability;
namespace Deal.Tests.Unit.SharedKernel.Observability;
public sealed class DealMetricsBudgetRatioTests
{
[Fact]
public void ReplaceBudgetRatios_ClampsAndReplacesSnapshot()
{
var measurements = new Dictionary<string, double>(StringComparer.Ordinal);
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, current) =>
{
if (instrument.Meter.Name == DealMetrics.MeterName
&& instrument.Name == DealMetrics.AiBudgetUsedRatioName)
{
current.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<double>((_, value, tags, _) =>
{
string? tenant = null;
foreach (KeyValuePair<string, object?> tag in tags)
{
if (tag.Key == DealMetrics.TenantTagName)
{
tenant = tag.Value?.ToString();
}
}
if (tenant is not null)
{
measurements[tenant] = value;
}
});
listener.Start();
DealMetrics.ReplaceBudgetRatios(new[]
{
("tenant-a", 1.5),
("tenant-b", -0.2),
});
measurements.Clear();
listener.RecordObservableInstruments();
Assert.Equal(1.0, measurements["tenant-a"], 3);
Assert.Equal(0.0, measurements["tenant-b"], 3);
Assert.Equal(2, measurements.Count);
DealMetrics.ReplaceBudgetRatios(new[] { ("tenant-c", 0.4) });
measurements.Clear();
listener.RecordObservableInstruments();
Assert.Equal(0.4, measurements["tenant-c"], 3);
Assert.False(measurements.ContainsKey("tenant-a"));
Assert.Single(measurements);
}
}