Расширить детектор подозрительной активности

Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
This commit is contained in:
Rustam Khalimov
2026-09-11 18:15:20 +03:00
parent e62dbfafba
commit f4737b9797
13 changed files with 447 additions and 5 deletions
+1 -1
View File
@@ -45,7 +45,7 @@
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE | | 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 | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE | | BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG | | BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG | | BL-SUSPICIOUS | **Сделано (2026-09-11):** детектор `SuspiciousActivityService` расширен правилом `distinct_logins_per_ip` (перебор разных логинов с одного IP, порог `DistinctLoginsPerIpThreshold`); плюс real-time `SuspiciousActivityReporter` — метрика `deal.security.suspicious{kind}` + warn-лог на 429 rate limiter (`rate_limit`) и блокировке входа (`login_blocked`) | ТЗ §10.5, этап 12 | P3 | DONE |
## 5. Технический долг (качество/архитектура) ## 5. Технический долг (качество/архитектура)
@@ -1387,7 +1387,10 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}` - **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
(общий `RuntimeDepthsCollector`, без дублей SQL). (общий `RuntimeDepthsCollector`, без дублей SQL).
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious` - **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; пороги — константы). (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных
логинов с одного IP `distinct_logins_per_ip`; пороги — константы). Плюс real-time учёт
`SuspiciousActivityReporter`: метрика `deal.security.suspicious{kind}` и предупреждающий лог на 429
rate limiter (`rate_limit`) и блокировке входа (`login_blocked`).
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде. - **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API — Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
+4 -1
View File
@@ -46,12 +46,15 @@ public static class AuthEndpoints
IOptions<CookieOptions> cookieOptions, IOptions<CookieOptions> cookieOptions,
HttpContext context, HttpContext context,
CancellationToken ct, CancellationToken ct,
LoginAttemptGuard loginAttemptGuard) LoginAttemptGuard loginAttemptGuard,
SuspiciousActivityReporter suspicious)
{ {
string? attemptedLogin = NormalizeLogin(body.Login); string? attemptedLogin = NormalizeLogin(body.Login);
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{ {
// Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
} }
@@ -126,7 +126,13 @@ public static class RateLimitPolicies
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
{ {
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; HttpContext http = context.HttpContext;
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken); // Событие подозрительной активности: сработал rate limiter (актор — тенант либо IP анонима).
SuspiciousActivityReporter? reporter = http.RequestServices.GetService<SuspiciousActivityReporter>();
string actor = http.GetCurrentUser() is { } user ? user.TenantId.ToString("N") : ClientKey(http);
reporter?.Report(SuspiciousActivityReporter.RateLimitKind, actor);
http.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await http.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
} }
} }
+1
View File
@@ -177,6 +177,7 @@ builder.Services.AddSingleton(rateLimitOptions);
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер // Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе). // (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
builder.Services.AddScoped<LoginAttemptGuard>(); builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
if (rateLimitOptions.Enabled) if (rateLimitOptions.Enabled)
{ {
builder.Services.AddDealRateLimiter(rateLimitOptions); builder.Services.AddDealRateLimiter(rateLimitOptions);
@@ -0,0 +1,38 @@
using System.Diagnostics;
using Deal.SharedKernel.Observability;
using Microsoft.Extensions.Logging;
namespace Deal.Api.Services;
/// <summary>
/// Признаки подозрительной активности и их учёт (метрика + предупреждающий лог).
/// </summary>
/// <param name="logger">Логгер событий.</param>
public sealed class SuspiciousActivityReporter(ILogger<SuspiciousActivityReporter> logger)
{
/// <summary>
/// Вид: сработал rate limiter (слишком много запросов).
/// </summary>
public const string RateLimitKind = "rate_limit";
/// <summary>
/// Вид: вход заблокирован после серии неудачных попыток.
/// </summary>
public const string LoginBlockedKind = "login_blocked";
// Актор неизвестен (нет ни тенанта, ни IP).
private const string UnknownActor = "-";
/// <summary>
/// Учитывает событие подозрительной активности.
/// </summary>
/// <param name="kind">Вид события (константы класса).</param>
/// <param name="actor">Актор: id тенанта либо IP; null/пусто — «-».</param>
public void Report(string kind, string? actor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
string resolvedActor = string.IsNullOrWhiteSpace(actor) ? UnknownActor : actor;
DealMetrics.SecurityEvents.Add(1, new TagList { { DealMetrics.SecurityKindTagName, kind } });
logger.LogWarning("Подозрительная активность: {Kind}, actor={Actor}", kind, resolvedActor);
}
}
@@ -39,6 +39,11 @@ public sealed class SuspiciousActivityService
/// </summary> /// </summary>
public const int AuthFailuresPerTenantThreshold = 20; public const int AuthFailuresPerTenantThreshold = 20;
/// <summary>
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
/// </summary>
public const int DistinctLoginsPerIpThreshold = 5;
// Кратность порога, с которой уровень поднимается до high (2× порог). // Кратность порога, с которой уровень поднимается до high (2× порог).
private const int HighSeverityMultiplier = 2; private const int HighSeverityMultiplier = 2;
@@ -62,6 +67,11 @@ public sealed class SuspiciousActivityService
/// </summary> /// </summary>
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant"; public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
/// <summary>
/// Правило: перебор разных логинов с одного IP.
/// </summary>
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
/// <summary> /// <summary>
/// Уровень находки /// Уровень находки
/// </summary> /// </summary>
@@ -126,6 +136,7 @@ public sealed class SuspiciousActivityService
AddFailedLoginsPerLogin(records, findings); AddFailedLoginsPerLogin(records, findings);
AddManyIpsPerActor(records, findings); AddManyIpsPerActor(records, findings);
AddAuthFailuresPerTenant(records, findings); AddAuthFailuresPerTenant(records, findings);
AddDistinctLoginsPerIp(records, findings);
findings.Sort(static (left, right) => findings.Sort(static (left, right) =>
{ {
@@ -255,6 +266,48 @@ public sealed class SuspiciousActivityService
} }
} }
// Правило «перебор разных логинов с одного IP» (credential stuffing).
// records: Записи окна.
// findings: Накопитель находок.
private static void AddDistinctLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
{
var loginsByIp = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (AuditRecordDto record in records)
{
if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip))
{
continue;
}
string? login = ExtractLogin(record);
if (string.IsNullOrWhiteSpace(login))
{
continue;
}
if (!loginsByIp.TryGetValue(record.Ip, out HashSet<string>? logins))
{
logins = new HashSet<string>(StringComparer.Ordinal);
loginsByIp[record.Ip] = logins;
}
logins.Add(login);
}
foreach ((string ip, HashSet<string> logins) in loginsByIp)
{
if (logins.Count >= DistinctLoginsPerIpThreshold)
{
findings.Add(new SuspiciousFindingDto(
KindDistinctLoginsPerIp,
SeverityFor(logins.Count, DistinctLoginsPerIpThreshold),
ip,
logins.Count,
$"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)"));
}
}
}
// Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются). // Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются).
// records: Записи окна. // records: Записи окна.
// predicate: Отбор записей правила. // predicate: Отбор записей правила.
@@ -44,6 +44,11 @@ public static class DealMetrics
/// </summary> /// </summary>
public const string TenantTagName = "tenant"; public const string TenantTagName = "tenant";
/// <summary>
/// Имя метки вида события безопасности
/// </summary>
public const string SecurityKindTagName = "kind";
/// <summary> /// <summary>
/// Имя метрики доли израсходованного ИИ-бюджета /// Имя метрики доли израсходованного ИИ-бюджета
/// </summary> /// </summary>
@@ -82,6 +87,12 @@ public static class DealMetrics
public static readonly Counter<long> AuditEvents = public static readonly Counter<long> AuditEvents =
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам."); Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
/// <summary>
/// События подозрительной активности по видам
/// </summary>
public static readonly Counter<long> SecurityEvents =
Meter.CreateCounter<long>("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked).");
/// <summary> /// <summary>
/// Суммарная глубина очереди пайплайна /// Суммарная глубина очереди пайплайна
/// </summary> /// </summary>
@@ -0,0 +1,111 @@
using Deal.Api.Services;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Unit-тесты учёта подозрительной активности
/// </summary>
public sealed class SuspiciousActivityReporterTests
{
// Актор сценариев.
private const string Actor = "tenant-1";
/// <summary>
/// Report инкрементит deal.security.suspicious с меткой kind
/// </summary>
[Fact]
public void Report_IncrementsSecurityCounter_WithKindTag()
{
using var capture = new SecurityEventCapture();
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor);
Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind));
(string? Kind, long Value) measurement = Assert.Single(
capture.Snapshot(),
item => item.Kind == SuspiciousActivityReporter.RateLimitKind);
Assert.Equal(1, measurement.Value);
}
/// <summary>
/// Report пишет предупреждение с видом и актором
/// </summary>
[Fact]
public void Report_WritesWarningWithKindAndActor()
{
var logger = new RecordingLogger();
var reporter = new SuspiciousActivityReporter(logger);
reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message);
Assert.Contains($"actor={Actor}", entry.Message);
}
/// <summary>
/// null/пусто/пробелы actor → в логе «-»
/// </summary>
/// <param name="actor">Пустой актор сценария.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Report_UnknownActor_WritesDash(string? actor)
{
var logger = new RecordingLogger();
var reporter = new SuspiciousActivityReporter(logger);
reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Contains("actor=-", entry.Message);
}
/// <summary>
/// Пустой/пробельный kind → ArgumentException
/// </summary>
/// <param name="kind">Пустой вид события сценария.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Report_EmptyOrWhitespaceKind_Throws(string? kind)
{
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
Assert.ThrowsAny<ArgumentException>(() => reporter.Report(kind!, Actor));
}
// In-memory логгер: копит записи с уровнем, сообщением и исключением.
private sealed class RecordingLogger : ILogger<SuspiciousActivityReporter>
{
/// <summary>
/// Записи лога в порядке поступления.
/// </summary>
public List<LogEntry> Entries { get; } = [];
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception));
}
// Запись лога, снятая in-memory логгером.
private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
}
@@ -137,6 +137,91 @@ public sealed class SuspiciousActivityServiceTests
Assert.Equal(Tenant.ToString("D"), finding.Subject); Assert.Equal(Tenant.ToString("D"), finding.Subject);
} }
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
{
var store = new FakeAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
}
SuspiciousActivityService service = Create(store);
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
SuspiciousFindingDto finding = Assert.Single(
report.Items,
item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
Assert.Equal("10.3.0.1", finding.Subject);
Assert.Equal(SuspiciousActivityService.DistinctLoginsPerIpThreshold, finding.Count);
Assert.Equal(SuspiciousActivityService.SeverityMedium, finding.Severity);
}
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
{
var store = new FakeAuditLogStore();
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++)
{
SeedFailed(store, ip: "10.3.0.2", login: $"user{i}", minutesAgo: i);
}
SuspiciousActivityService service = Create(store);
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
SuspiciousFindingDto finding = Assert.Single(
report.Items,
item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
Assert.Equal(count, finding.Count);
Assert.Equal(SuspiciousActivityService.SeverityHigh, finding.Severity);
}
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
{
var store = new FakeAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
{
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
}
SuspiciousActivityService service = Create(store);
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
}
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
{
var store = new FakeAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
{
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
}
SuspiciousActivityService service = Create(store);
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
}
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
{
var store = new FakeAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
}
SuspiciousActivityService service = Create(store);
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
}
[Fact] [Fact]
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored() public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
{ {
@@ -171,5 +256,24 @@ public sealed class SuspiciousActivityServiceTests
CancellationToken.None); CancellationToken.None);
} }
// Пишет запись «успешный вход» с заданным временем.
private static void SeedSuccess(
FakeAuditLogStore store,
string ip,
string login,
int minutesAgo)
{
store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
ActorId: null,
TenantId: null,
Ip: ip,
DetailJson: AuditService.ToDetailJson(new { login }),
At: Now.AddMinutes(-minutesAgo)),
CancellationToken.None);
}
private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now); private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now);
} }
@@ -96,6 +96,39 @@ public sealed class LoginAttemptEndpointHttpTests
rateLimitOptions: EnabledRateLimitOptions()); rateLimitOptions: EnabledRateLimitOptions());
} }
// ─── Учёт подозрительной активности ──────────────────────────────
/// <summary>
/// Блокировка входа после серии неудач → событие login_blocked в метрике
/// </summary>
[Fact]
public async Task Login_WhenBlocked_ReportsLoginBlockedSecurityEvent()
{
using var capture = new SecurityEventCapture();
await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(),
NewUserStore(),
async (baseAddress, _, _, _, _, _, _) =>
{
HttpClient client = CreateClient(baseAddress);
for (int attempt = 0; attempt < MaxAttempts; attempt++)
{
using HttpResponseMessage failed = await PostJsonAsync(
client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword });
Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode);
}
using HttpResponseMessage blocked = await PostJsonAsync(
client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password });
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
},
rateLimitOptions: EnabledRateLimitOptions());
Assert.True(capture.Sum(SuspiciousActivityReporter.LoginBlockedKind) >= 1);
}
// ─── Хелперы ───────────────────────────────────────────────────────── // ─── Хелперы ─────────────────────────────────────────────────────────
private static RateLimitOptions EnabledRateLimitOptions() => private static RateLimitOptions EnabledRateLimitOptions() =>
@@ -210,6 +210,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton(effectiveRateLimitOptions); builder.Services.AddSingleton(effectiveRateLimitOptions);
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore()); builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore());
builder.Services.AddScoped<LoginAttemptGuard>(); builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseMiddleware<SessionMiddleware>(); app.UseMiddleware<SessionMiddleware>();
@@ -0,0 +1,78 @@
using System.Diagnostics.Metrics;
using Deal.SharedKernel.Observability;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Слушатель счётчика подозрительной активности deal.security.suspicious.
/// </summary>
public sealed class SecurityEventCapture : IDisposable
{
// Защита накопленных измерений (callback может прийти из потока запроса).
private readonly object _gate = new();
private readonly List<(string? Kind, long Value)> _measurements = [];
private readonly MeterListener _listener;
/// <summary>
/// Начинает слушать счётчик подозрительной активности.
/// </summary>
public SecurityEventCapture()
{
_listener = new MeterListener();
_listener.InstrumentPublished = (instrument, current) =>
{
if (instrument.Meter.Name == DealMetrics.MeterName
&& instrument.Name == DealMetrics.SecurityEvents.Name)
{
current.EnableMeasurementEvents(instrument);
}
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
string? kind = null;
foreach (KeyValuePair<string, object?> tag in tags)
{
if (tag.Key == DealMetrics.SecurityKindTagName)
{
kind = tag.Value?.ToString();
}
}
lock (_gate)
{
_measurements.Add((kind, value));
}
});
_listener.Start();
}
/// <summary>
/// Снимок измерений с момента создания слушателя.
/// </summary>
/// <returns>Пары (вид события, значение инкремента).</returns>
public IReadOnlyList<(string? Kind, long Value)> Snapshot()
{
lock (_gate)
{
return [.. _measurements];
}
}
/// <summary>
/// Сумма инкрементов по виду события.
/// </summary>
/// <param name="kind">Вид события (метка kind).</param>
/// <returns>Суммарное значение измерений с указанным видом.</returns>
public long Sum(string kind)
{
lock (_gate)
{
return _measurements.Where(measurement => measurement.Kind == kind).Sum(measurement => measurement.Value);
}
}
/// <inheritdoc />
public void Dispose() => _listener.Dispose();
}