Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
96 lines
4.8 KiB
C#
96 lines
4.8 KiB
C#
using System.Diagnostics;
|
|
|
|
namespace Deal.Api.Middleware;
|
|
|
|
/// <summary>
|
|
/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная
|
|
/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки,
|
|
/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не
|
|
/// логируется (аудит-IP живёт в AuditLog, операторский контур).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех
|
|
/// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса.
|
|
/// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог
|
|
/// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только
|
|
/// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни
|
|
/// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется.
|
|
/// </remarks>
|
|
public sealed class HttpAccessLogMiddleware
|
|
{
|
|
// Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor.
|
|
private const string GrpcContentType = "application/grpc";
|
|
|
|
private readonly RequestDelegate _next;
|
|
private readonly ILogger<HttpAccessLogMiddleware> _logger;
|
|
|
|
/// <summary>
|
|
/// Создаёт middleware access-лога HTTP-запросов.
|
|
/// </summary>
|
|
/// <param name="next">Следующий обработчик конвейера.</param>
|
|
/// <param name="logger">Логгер (Serilog, Ruling 7).</param>
|
|
public HttpAccessLogMiddleware(RequestDelegate next, ILogger<HttpAccessLogMiddleware> logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(next);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
_next = next;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении.
|
|
/// </summary>
|
|
/// <param name="context">Контекст запроса.</param>
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
long startedAt = Stopwatch.GetTimestamp();
|
|
try
|
|
{
|
|
await _next(context);
|
|
LogCall(context, startedAt, null);
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
// Необработанное исключение: фиксируем строку 500 (статус ещё не выставлен) и пробрасываем —
|
|
// обработку ошибки продолжает хостинг (DeveloperExceptionPage в dev/закрытие соединения в prod).
|
|
LogCall(context, startedAt, exception);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// Пишет одну строку access-лога запроса (успех либо исключение).
|
|
// context: Контекст запроса (метод/путь/статус ответа).
|
|
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
|
|
// exception: Необработанное исключение (null — запрос завершился штатно).
|
|
private void LogCall(HttpContext context, long startedAt, Exception? exception)
|
|
{
|
|
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
|
string method = context.Request.Method;
|
|
string path = context.Request.Path.Value ?? "/";
|
|
if (exception is null)
|
|
{
|
|
_logger.LogInformation(
|
|
"HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс",
|
|
method,
|
|
path,
|
|
context.Response.StatusCode,
|
|
elapsedMs);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogError(
|
|
exception,
|
|
"HTTP {Method} {Path} -> исключение за {DurationMs} мс",
|
|
method,
|
|
path,
|
|
elapsedMs);
|
|
}
|
|
}
|
|
}
|