SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.SharedKernel;
|
||||
|
||||
/// <summary>
|
||||
/// Маркер слоя SharedKernel
|
||||
/// </summary>
|
||||
public interface ISharedKernel
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace Deal.SharedKernel.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Прикладные метрики Deal
|
||||
/// </summary>
|
||||
public static class DealMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя meter'а прикладных метрик
|
||||
/// </summary>
|
||||
public const string MeterName = "Deal";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метки вида токенов
|
||||
/// </summary>
|
||||
public const string TokenTypeTagName = "type";
|
||||
|
||||
/// <summary>
|
||||
/// Значение метки вида токенов — токены запроса.
|
||||
/// </summary>
|
||||
public const string TokenTypePrompt = "prompt";
|
||||
|
||||
/// <summary>
|
||||
/// Значение метки вида токенов — токены ответа.
|
||||
/// </summary>
|
||||
public const string TokenTypeCompletion = "completion";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метки типа события аудита
|
||||
/// </summary>
|
||||
public const string AuditEventTagName = "event";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метки типа актора события аудита
|
||||
/// </summary>
|
||||
public const string AuditActorTagName = "actor";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метки тенанта
|
||||
/// </summary>
|
||||
public const string TenantTagName = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метки вида события безопасности
|
||||
/// </summary>
|
||||
public const string SecurityKindTagName = "kind";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метрики доли израсходованного ИИ-бюджета
|
||||
/// </summary>
|
||||
public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio";
|
||||
|
||||
// Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics).
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
|
||||
/// <summary>
|
||||
/// Успешные вызовы платного ИИ
|
||||
/// </summary>
|
||||
public static readonly Counter<long> AiCalls =
|
||||
Meter.CreateCounter<long>("deal.ai.calls", description: "Успешные вызовы платного ИИ (ai-service).");
|
||||
|
||||
/// <summary>
|
||||
/// Токены платного ИИ по видам
|
||||
/// </summary>
|
||||
public static readonly Counter<long> AiTokens =
|
||||
Meter.CreateCounter<long>("deal.ai.tokens", description: "Токены платных ИИ-вызовов (prompt/completion).");
|
||||
|
||||
/// <summary>
|
||||
/// Вызовы локального ML
|
||||
/// </summary>
|
||||
public static readonly Counter<long> MlCalls =
|
||||
Meter.CreateCounter<long>("deal.ml.calls", description: "Вызовы локального ML-предсказания.");
|
||||
|
||||
/// <summary>
|
||||
/// Оценка токенов локальных ML-вызовов
|
||||
/// </summary>
|
||||
public static readonly Counter<long> MlTokens =
|
||||
Meter.CreateCounter<long>("deal.ml.tokens", description: "Оценка токенов локальных ML-вызовов.");
|
||||
|
||||
/// <summary>
|
||||
/// События аудита по типам и акторам
|
||||
/// </summary>
|
||||
public static readonly Counter<long> AuditEvents =
|
||||
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>
|
||||
public static readonly ObservableGauge<long> PipelineQueueDepth =
|
||||
Meter.CreateObservableGauge(
|
||||
"deal.pipeline.queue.depth",
|
||||
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._pipelineQueueDepth)),
|
||||
description: "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.");
|
||||
|
||||
/// <summary>
|
||||
/// Суммарная глубина очереди обучения ML
|
||||
/// </summary>
|
||||
public static readonly ObservableGauge<long> MlOutboxDepth =
|
||||
Meter.CreateObservableGauge(
|
||||
"deal.ml.outbox.depth",
|
||||
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._mlOutboxDepth)),
|
||||
description: "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.");
|
||||
|
||||
/// <summary>
|
||||
/// Число активных сессий
|
||||
/// </summary>
|
||||
public static readonly ObservableGauge<long> ActiveSessions =
|
||||
Meter.CreateObservableGauge(
|
||||
"deal.sessions.active",
|
||||
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)),
|
||||
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 _mlOutboxDepth;
|
||||
private static long _activeSessions;
|
||||
|
||||
/// <summary>
|
||||
/// Записывает успешный платный ИИ-вызов
|
||||
/// </summary>
|
||||
/// <param name="promptTokens">Токены запроса (неотрицательные).</param>
|
||||
/// <param name="completionTokens">Токены ответа (неотрицательные).</param>
|
||||
public static void RecordAiUsage(long promptTokens, long completionTokens)
|
||||
{
|
||||
AiCalls.Add(1);
|
||||
if (promptTokens > 0)
|
||||
{
|
||||
AiTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } });
|
||||
}
|
||||
|
||||
if (completionTokens > 0)
|
||||
{
|
||||
AiTokens.Add(completionTokens, new TagList { { TokenTypeTagName, TokenTypeCompletion } });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает вызов локального ML
|
||||
/// </summary>
|
||||
/// <param name="promptTokens">Оценка токенов входного текста (неотрицательная).</param>
|
||||
public static void RecordMlUsage(long promptTokens)
|
||||
{
|
||||
MlCalls.Add(1);
|
||||
if (promptTokens > 0)
|
||||
{
|
||||
MlTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие аудита по типу и актору
|
||||
/// </summary>
|
||||
/// <param name="eventType">Тип события (константа <c>AuditEvents</c>).</param>
|
||||
/// <param name="actorType">Тип актора (tenant/operator/system).</param>
|
||||
public static void RecordAuditEvent(string eventType, string actorType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actorType);
|
||||
AuditEvents.Add(1, new TagList { { AuditEventTagName, eventType }, { AuditActorTagName, actorType } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Публикует глубину очереди пайплайна
|
||||
/// </summary>
|
||||
/// <param name="depth">Суммарная глубина (new+filtered) по всем тенантам.</param>
|
||||
public static void SetPipelineQueueDepth(long depth) => Interlocked.Exchange(ref _pipelineQueueDepth, depth);
|
||||
|
||||
/// <summary>
|
||||
/// Публикует глубину очереди обучения ML
|
||||
/// </summary>
|
||||
/// <param name="depth">Суммарная глубина MlOutbox по всем тенантам.</param>
|
||||
public static void SetMlOutboxDepth(long depth) => Interlocked.Exchange(ref _mlOutboxDepth, depth);
|
||||
|
||||
/// <summary>
|
||||
/// Публикует число активных сессий
|
||||
/// </summary>
|
||||
/// <param name="count">Активные непросроченные сессии пользователей и операторов.</param>
|
||||
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 } }));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
namespace Deal.SharedKernel.Tenants.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Контекст текущего тенанта запроса.
|
||||
/// </summary>
|
||||
public interface ITenantContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Идентификатор текущего тенанта или null для системного контекста
|
||||
/// </summary>
|
||||
public TenantId? TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак наличия текущего тенанта
|
||||
/// </summary>
|
||||
public bool HasTenant { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Имя схемы текущего тенанта или null для системного контекста
|
||||
/// </summary>
|
||||
public string? SchemaName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает текущего тенанта запроса.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Идентификатор тенанта.</param>
|
||||
public void SetTenant(TenantId tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Сбрасывает контекст в системный
|
||||
/// </summary>
|
||||
public void Reset();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.SharedKernel.Tenants.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Идентификатор тенанта.
|
||||
/// </summary>
|
||||
public readonly record struct TenantId
|
||||
{
|
||||
/// <summary>
|
||||
/// Значение идентификатора
|
||||
/// </summary>
|
||||
public string Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт идентификатор из строки; значение обязано быть 32 hex-символами.
|
||||
/// </summary>
|
||||
/// <param name="value">Значение в формате Guid "N" (32 hex).</param>
|
||||
/// <exception cref="ArgumentException">Значение не является 32 hex-символами.</exception>
|
||||
public TenantId(string value)
|
||||
{
|
||||
if (!IsValid(value))
|
||||
{
|
||||
throw new ArgumentException("TenantId должен быть 32 hex-символами (Guid N)", nameof(value));
|
||||
}
|
||||
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт идентификатор из Guid.
|
||||
/// </summary>
|
||||
/// <param name="id">Guid тенанта.</param>
|
||||
/// <returns>Идентификатор тенанта (значение — формат "N").</returns>
|
||||
public static TenantId FromGuid(Guid id) => new(id.ToString("N"));
|
||||
|
||||
/// <summary>
|
||||
/// True — строка является валидным значением TenantId
|
||||
/// </summary>
|
||||
/// <param name="value">Проверяемая строка.</param>
|
||||
/// <returns>True — значение пригодно для TenantId.</returns>
|
||||
public static bool IsValid(string? value)
|
||||
{
|
||||
if (value is null || value.Length != 32)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (char c in value)
|
||||
{
|
||||
if (!Uri.IsHexDigit(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Имя схемы Postgres для тенанта.
|
||||
/// </summary>
|
||||
public string SchemaName => $"tenant_{Value}";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Security.Cryptography;
|
||||
using Deal.SharedKernel;
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.SharedKernel.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Криптостойкие url-safe токены
|
||||
/// </summary>
|
||||
public static class UrlSafeToken
|
||||
{
|
||||
/// <summary>
|
||||
/// Новый токен: <paramref name="byteCount"/> случайных байт в Base64Url
|
||||
/// </summary>
|
||||
/// <param name="byteCount">Число случайных байт (≥ 1).</param>
|
||||
/// <returns>Строка длиной ceil(byteCount·4/3) без padding.</returns>
|
||||
public static string New(int byteCount)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
|
||||
|
||||
byte[] bytes = new byte[byteCount];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user