using System.Diagnostics; namespace Deal.Api.Middleware; /// /// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная /// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки, /// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не /// логируется (аудит-IP живёт в AuditLog, операторский контур). /// /// /// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех /// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса. /// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог /// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только /// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни /// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется. /// public sealed class HttpAccessLogMiddleware { // Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor. private const string GrpcContentType = "application/grpc"; private readonly RequestDelegate _next; private readonly ILogger _logger; /// /// Создаёт middleware access-лога HTTP-запросов. /// /// Следующий обработчик конвейера. /// Логгер (Serilog, Ruling 7). public HttpAccessLogMiddleware(RequestDelegate next, ILogger logger) { ArgumentNullException.ThrowIfNull(next); ArgumentNullException.ThrowIfNull(logger); _next = next; _logger = logger; } /// /// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении. /// /// Контекст запроса. 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); } } }