< Summary

Information
Class: Services.ApiServices.ApiSupplierRequestLog.ApiSupplierRawResponse
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ApiSupplierRequestLog/ApiSupplierRequestLogStore.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 1
Coverable lines: 1
Total lines: 185
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Method()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ApiSupplierRequestLog/ApiSupplierRequestLogStore.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Diagnostics;
 3using System.Text.RegularExpressions;
 4using Microsoft.AspNetCore.Http;
 5using Microsoft.EntityFrameworkCore;
 6using Microsoft.Extensions.Hosting;
 7using Microsoft.Extensions.Logging;
 8using OzonDomains.Models;
 9using OzonRepositories.Context;
 10using System.Threading.Channels;
 11
 12namespace Services.ApiServices.ApiSupplierRequestLog;
 13
 14public sealed record ApiSupplierRequestLogEntry(
 15    DateTimeOffset At, string Provider, string Source, string? Actor, string Method, string Endpoint,
 16    int? StatusCode, long DurationMs, bool IsSuccess, string Request, string Response);
 17
 18/// <summary>Немодифицированный ответ поставщика, живущий только в контексте текущего HTTP-запроса.</summary>
 019public sealed record ApiSupplierRawResponse(string Method, string Endpoint, int? StatusCode, string? Response);
 20
 21/// <summary>Live-журнал и очередь постоянного аудита вызовов API-поставщиков.</summary>
 22public sealed class ApiSupplierRequestLogStore
 23{
 24    private const int Limit = 100;
 25    private const string CurrentRequestResponsesItemKey = "ApiSupplierRequestLogStore.CurrentRequestResponses";
 26    // secret — ключ Профит-Лиги: он передаётся в query string, поэтому без него утёк бы в аудит открытым текстом.
 27    private static readonly Regex SecretFields = new("(?i)\\b(password|access_token|authorization|x-berg-api-key|api_key
 28    private readonly ConcurrentQueue<ApiSupplierRequestLogEntry> _entries = new();
 29    private readonly IHttpContextAccessor _http;
 30    private readonly IDbContextFactory<ApplicationDbContext>? _dbFactory;
 31    private readonly Channel<ApiSupplierRequestLogEntry> _persistenceQueue = Channel.CreateUnbounded<ApiSupplierRequestL
 32        new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
 33
 34    public ApiSupplierRequestLogStore(IHttpContextAccessor http) => _http = http;
 35
 36    public ApiSupplierRequestLogStore(IHttpContextAccessor http, IDbContextFactory<ApplicationDbContext> dbFactory)
 37    {
 38        _http = http;
 39        _dbFactory = dbFactory;
 40    }
 41
 42    public void Add(string provider, string method, string endpoint, int? statusCode, Stopwatch timer, string? request, 
 43    {
 44        var context = _http.HttpContext;
 45        var source = ResolveSource(context);
 46        var entry = new ApiSupplierRequestLogEntry(
 47            DateTimeOffset.UtcNow, provider, source, context?.User.Identity?.Name, method, endpoint, statusCode,
 48            timer.ElapsedMilliseconds, statusCode is >= 200 and < 300, Redact(request), Redact(response));
 49        _entries.Enqueue(entry);
 50        AddCurrentRequestResponse(context, provider, method, endpoint, statusCode, response);
 51        if (_dbFactory != null)
 52            _persistenceQueue.Writer.TryWrite(entry);
 53        while (_entries.Count > Limit && _entries.TryDequeue(out _)) { }
 54    }
 55
 56    public IReadOnlyList<ApiSupplierRequestLogEntry> Get(string provider) => _entries.Where(x => x.Provider == provider)
 57
 58    /// <summary>Возвращает исходные ответы внешнего API, вызванные внутри текущего HTTP-запроса.</summary>
 59    public IReadOnlyList<ApiSupplierRawResponse> GetCurrentRequestResponses(string provider)
 60    {
 61        var context = _http.HttpContext;
 62        if (context == null) return [];
 63
 64        lock (context.Items)
 65        {
 66            return context.Items.TryGetValue(CurrentRequestResponsesItemKey, out var value) && value is ConcurrentQueue<
 67                ? responses.Where(x => x.Provider == provider).Select(x => x.Response).ToList()
 68                : [];
 69        }
 70    }
 71
 72    internal IAsyncEnumerable<ApiSupplierRequestLogEntry> ReadForPersistenceAsync(CancellationToken cancellationToken) =
 73        _persistenceQueue.Reader.ReadAllAsync(cancellationToken);
 74
 75    internal async Task PersistAsync(ApiSupplierRequestLogEntry entry, CancellationToken cancellationToken)
 76    {
 77        if (_dbFactory == null) return;
 78        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 79        db.ApiSupplierRequestAuditEntries.Add(new ApiSupplierRequestAuditEntry
 80        {
 81            OccurredAtUtc = entry.At.UtcDateTime,
 82            ProviderKey = entry.Provider,
 83            Source = entry.Source,
 84            Actor = entry.Actor,
 85            Method = entry.Method,
 86            Endpoint = entry.Endpoint,
 87            StatusCode = entry.StatusCode,
 88            DurationMs = entry.DurationMs,
 89            IsSuccess = entry.IsSuccess,
 90            Request = entry.Request,
 91            Response = entry.Response
 92        });
 93        db.SystemMonitoringSignals.Add(new SystemMonitoringSignal
 94        {
 95            SourceKey = ResolveMonitoringSource(entry),
 96            ScopeKey = entry.Provider[..Math.Min(entry.Provider.Length, 120)],
 97            Outcome = entry.IsSuccess ? "success" : "failure",
 98            Message = entry.IsSuccess ? null : "Ошибка вызова API поставщика"
 99        });
 100        await db.SaveChangesAsync(cancellationToken);
 101    }
 102
 103    private static string ResolveMonitoringSource(ApiSupplierRequestLogEntry entry)
 104    {
 105        var operation = $"{entry.Source} {entry.Endpoint}";
 106        if (operation.Contains("PriceController", StringComparison.OrdinalIgnoreCase) ||
 107            operation.Contains("quotation", StringComparison.OrdinalIgnoreCase) ||
 108            operation.Contains("search", StringComparison.OrdinalIgnoreCase))
 109            return "supplier-quotation";
 110
 111        if (operation.Contains("Checkout", StringComparison.OrdinalIgnoreCase) ||
 112            operation.Contains("OrderToSupplier", StringComparison.OrdinalIgnoreCase) ||
 113            operation.Contains("place_order", StringComparison.OrdinalIgnoreCase))
 114            return "supplier-order-placement";
 115
 116        if (operation.Contains("Cart", StringComparison.OrdinalIgnoreCase) ||
 117            operation.Contains("Basket", StringComparison.OrdinalIgnoreCase))
 118            return "supplier-cart";
 119
 120        return "supplier-api";
 121    }
 122
 123    private static string ResolveSource(HttpContext? context)
 124    {
 125        if (context != null)
 126        {
 127            var endpoint = context.GetEndpoint()?.DisplayName;
 128            return string.IsNullOrWhiteSpace(endpoint)
 129                ? $"HTTP {context.Request.Method} {context.Request.Path}"
 130                : $"HTTP {context.Request.Method} {context.Request.Path} ({endpoint})";
 131        }
 132
 133        var caller = new StackTrace().GetFrames()?
 134            .Select(x => x.GetMethod()?.DeclaringType)
 135            .FirstOrDefault(x => x != null && x.Namespace != null &&
 136                                 !x.Namespace.StartsWith("Services.ApiServices", StringComparison.Ordinal) &&
 137                                 !x.Namespace.StartsWith("System", StringComparison.Ordinal));
 138        return caller == null ? "Фоновый вызов" : $"Фон: {caller.FullName}";
 139    }
 140
 141    private static void AddCurrentRequestResponse(HttpContext? context, string provider, string method,
 142        string endpoint, int? statusCode, string? response)
 143    {
 144        if (context == null) return;
 145
 146        lock (context.Items)
 147        {
 148            if (!context.Items.TryGetValue(CurrentRequestResponsesItemKey, out var value) ||
 149                value is not ConcurrentQueue<(string Provider, ApiSupplierRawResponse Response)> responses)
 150            {
 151                responses = new ConcurrentQueue<(string Provider, ApiSupplierRawResponse Response)>();
 152                context.Items[CurrentRequestResponsesItemKey] = responses;
 153            }
 154
 155            responses.Enqueue((provider, new ApiSupplierRawResponse(method, endpoint, statusCode, response)));
 156        }
 157    }
 158
 159    private static string Redact(string? value)
 160    {
 161        if (string.IsNullOrWhiteSpace(value)) return "—";
 162        var safe = SecretFields.Replace(value, match => match.Value.StartsWith('<') ? $"<{match.Groups[2].Value}>***</{m
 163        return safe.Length <= 4000 ? safe : safe[..4000] + "…";
 164    }
 165}
 166
 167/// <summary>Сохраняет очередь аудита отдельно от пользовательских запросов и Hangfire scope.</summary>
 168public sealed class ApiSupplierRequestLogPersistenceService : BackgroundService
 169{
 170    private readonly ApiSupplierRequestLogStore _store;
 171    private readonly ILogger<ApiSupplierRequestLogPersistenceService> _logger;
 172
 173    public ApiSupplierRequestLogPersistenceService(ApiSupplierRequestLogStore store, ILogger<ApiSupplierRequestLogPersis
 174        (_store, _logger) = (store, logger);
 175
 176    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 177    {
 178        await foreach (var entry in _store.ReadForPersistenceAsync(stoppingToken))
 179        {
 180            try { await _store.PersistAsync(entry, stoppingToken); }
 181            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
 182            catch (Exception ex) { _logger.LogError(ex, "Не удалось сохранить аудит API поставщика {Provider}.", entry.P
 183        }
 184    }
 185}

Methods/Properties

get_Method()