< Summary

Information
Class: Services.ApiServices.ApiSupplierRequestLog.ApiSupplierRequestLogStore
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ApiSupplierRequestLog/ApiSupplierRequestLogStore.cs
Line coverage
30%
Covered lines: 27
Uncovered lines: 63
Coverable lines: 90
Total lines: 185
Line coverage: 30%
Branch coverage
33%
Covered branches: 25
Total branches: 74
Branch coverage: 33.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
.ctor(...)100%210%
Add(...)64.28%141490.9%
Get(...)100%11100%
GetCurrentRequestResponses(...)0%4260%
ReadForPersistenceAsync(...)100%210%
PersistAsync()0%7280%
ResolveMonitoringSource(...)0%272160%
ResolveSource(...)55.55%341863.63%
AddCurrentRequestResponse(...)16.66%31611.11%
Redact(...)83.33%66100%

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>
 19public 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, поэтому без него утёк бы в аудит открытым текстом.
 127    private static readonly Regex SecretFields = new("(?i)\\b(password|access_token|authorization|x-berg-api-key|api_key
 41128    private readonly ConcurrentQueue<ApiSupplierRequestLogEntry> _entries = new();
 29    private readonly IHttpContextAccessor _http;
 30    private readonly IDbContextFactory<ApplicationDbContext>? _dbFactory;
 41131    private readonly Channel<ApiSupplierRequestLogEntry> _persistenceQueue = Channel.CreateUnbounded<ApiSupplierRequestL
 41132        new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
 33
 82234    public ApiSupplierRequestLogStore(IHttpContextAccessor http) => _http = http;
 35
 036    public ApiSupplierRequestLogStore(IHttpContextAccessor http, IDbContextFactory<ApplicationDbContext> dbFactory)
 37    {
 038        _http = http;
 039        _dbFactory = dbFactory;
 040    }
 41
 42    public void Add(string provider, string method, string endpoint, int? statusCode, Stopwatch timer, string? request, 
 43    {
 33744        var context = _http.HttpContext;
 33745        var source = ResolveSource(context);
 33746        var entry = new ApiSupplierRequestLogEntry(
 33747            DateTimeOffset.UtcNow, provider, source, context?.User.Identity?.Name, method, endpoint, statusCode,
 33748            timer.ElapsedMilliseconds, statusCode is >= 200 and < 300, Redact(request), Redact(response));
 33749        _entries.Enqueue(entry);
 33750        AddCurrentRequestResponse(context, provider, method, endpoint, statusCode, response);
 33751        if (_dbFactory != null)
 052            _persistenceQueue.Writer.TryWrite(entry);
 33753        while (_entries.Count > Limit && _entries.TryDequeue(out _)) { }
 33754    }
 55
 5756    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    {
 061        var context = _http.HttpContext;
 062        if (context == null) return [];
 63
 064        lock (context.Items)
 65        {
 066            return context.Items.TryGetValue(CurrentRequestResponsesItemKey, out var value) && value is ConcurrentQueue<
 067                ? responses.Where(x => x.Provider == provider).Select(x => x.Response).ToList()
 068                : [];
 69        }
 070    }
 71
 72    internal IAsyncEnumerable<ApiSupplierRequestLogEntry> ReadForPersistenceAsync(CancellationToken cancellationToken) =
 073        _persistenceQueue.Reader.ReadAllAsync(cancellationToken);
 74
 75    internal async Task PersistAsync(ApiSupplierRequestLogEntry entry, CancellationToken cancellationToken)
 76    {
 077        if (_dbFactory == null) return;
 078        await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
 079        db.ApiSupplierRequestAuditEntries.Add(new ApiSupplierRequestAuditEntry
 080        {
 081            OccurredAtUtc = entry.At.UtcDateTime,
 082            ProviderKey = entry.Provider,
 083            Source = entry.Source,
 084            Actor = entry.Actor,
 085            Method = entry.Method,
 086            Endpoint = entry.Endpoint,
 087            StatusCode = entry.StatusCode,
 088            DurationMs = entry.DurationMs,
 089            IsSuccess = entry.IsSuccess,
 090            Request = entry.Request,
 091            Response = entry.Response
 092        });
 093        db.SystemMonitoringSignals.Add(new SystemMonitoringSignal
 094        {
 095            SourceKey = ResolveMonitoringSource(entry),
 096            ScopeKey = entry.Provider[..Math.Min(entry.Provider.Length, 120)],
 097            Outcome = entry.IsSuccess ? "success" : "failure",
 098            Message = entry.IsSuccess ? null : "Ошибка вызова API поставщика"
 099        });
 0100        await db.SaveChangesAsync(cancellationToken);
 0101    }
 102
 103    private static string ResolveMonitoringSource(ApiSupplierRequestLogEntry entry)
 104    {
 0105        var operation = $"{entry.Source} {entry.Endpoint}";
 0106        if (operation.Contains("PriceController", StringComparison.OrdinalIgnoreCase) ||
 0107            operation.Contains("quotation", StringComparison.OrdinalIgnoreCase) ||
 0108            operation.Contains("search", StringComparison.OrdinalIgnoreCase))
 0109            return "supplier-quotation";
 110
 0111        if (operation.Contains("Checkout", StringComparison.OrdinalIgnoreCase) ||
 0112            operation.Contains("OrderToSupplier", StringComparison.OrdinalIgnoreCase) ||
 0113            operation.Contains("place_order", StringComparison.OrdinalIgnoreCase))
 0114            return "supplier-order-placement";
 115
 0116        if (operation.Contains("Cart", StringComparison.OrdinalIgnoreCase) ||
 0117            operation.Contains("Basket", StringComparison.OrdinalIgnoreCase))
 0118            return "supplier-cart";
 119
 0120        return "supplier-api";
 121    }
 122
 123    private static string ResolveSource(HttpContext? context)
 124    {
 337125        if (context != null)
 126        {
 0127            var endpoint = context.GetEndpoint()?.DisplayName;
 0128            return string.IsNullOrWhiteSpace(endpoint)
 0129                ? $"HTTP {context.Request.Method} {context.Request.Path}"
 0130                : $"HTTP {context.Request.Method} {context.Request.Path} ({endpoint})";
 131        }
 132
 337133        var caller = new StackTrace().GetFrames()?
 3458134            .Select(x => x.GetMethod()?.DeclaringType)
 3795135            .FirstOrDefault(x => x != null && x.Namespace != null &&
 3795136                                 !x.Namespace.StartsWith("Services.ApiServices", StringComparison.Ordinal) &&
 3795137                                 !x.Namespace.StartsWith("System", StringComparison.Ordinal));
 337138        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    {
 674144        if (context == null) return;
 145
 0146        lock (context.Items)
 147        {
 0148            if (!context.Items.TryGetValue(CurrentRequestResponsesItemKey, out var value) ||
 0149                value is not ConcurrentQueue<(string Provider, ApiSupplierRawResponse Response)> responses)
 150            {
 0151                responses = new ConcurrentQueue<(string Provider, ApiSupplierRawResponse Response)>();
 0152                context.Items[CurrentRequestResponsesItemKey] = responses;
 153            }
 154
 0155            responses.Enqueue((provider, new ApiSupplierRawResponse(method, endpoint, statusCode, response)));
 0156        }
 0157    }
 158
 159    private static string Redact(string? value)
 160    {
 710161        if (string.IsNullOrWhiteSpace(value)) return "—";
 820162        var safe = SecretFields.Replace(value, match => match.Value.StartsWith('<') ? $"<{match.Groups[2].Value}>***</{m
 638163        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}