Files
Deal/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs
T
Rustam Khalimov 492950bdd0 Отформатировать списки параметров по код-стайлу
Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
2026-09-11 13:22:56 +03:00

101 lines
4.9 KiB
C#

using System.Diagnostics;
using Deal.Api.Services;
using Deal.Api.Dtos;
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);
}
}
}