| | | 1 | | using System.Diagnostics; |
| | | 2 | | using System.Security.Claims; |
| | | 3 | | using Microsoft.AspNetCore.Diagnostics; |
| | | 4 | | using Microsoft.Extensions.Options; |
| | | 5 | | using OzonOrdersWeb.Build; |
| | | 6 | | |
| | | 7 | | namespace OzonOrdersWeb.Observability; |
| | | 8 | | |
| | 40 | 9 | | public sealed class RequestObservabilityMiddleware( |
| | 40 | 10 | | RequestDelegate next, |
| | 40 | 11 | | ILogger<RequestObservabilityMiddleware> logger, |
| | 40 | 12 | | SensitiveDataRedactor redactor, |
| | 40 | 13 | | IOptions<ObservabilityOptions> options) |
| | | 14 | | { |
| | | 15 | | private const string ReportedItemKey = "__studio2_problem_reported"; |
| | 40 | 16 | | private readonly ObservabilityOptions _options = options.Value; |
| | | 17 | | |
| | | 18 | | public async Task InvokeAsync(HttpContext context) |
| | | 19 | | { |
| | 40 | 20 | | var timer = Stopwatch.StartNew(); |
| | 40 | 21 | | var requestPayload = "[PAYLOAD UNAVAILABLE]"; |
| | 40 | 22 | | context.Response.OnStarting(() => |
| | 40 | 23 | | { |
| | 0 | 24 | | context.Response.Headers["X-Trace-Id"] = context.TraceIdentifier; |
| | 0 | 25 | | return Task.CompletedTask; |
| | 40 | 26 | | }); |
| | | 27 | | |
| | | 28 | | try |
| | | 29 | | { |
| | 40 | 30 | | requestPayload = await ReadRequestPayloadAsync(context); |
| | 40 | 31 | | await next(context); |
| | 37 | 32 | | } |
| | 1 | 33 | | catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) |
| | | 34 | | { |
| | 1 | 35 | | if (!context.Response.HasStarted) |
| | | 36 | | { |
| | 1 | 37 | | var statusCodePages = context.Features.Get<IStatusCodePagesFeature>(); |
| | 1 | 38 | | if (statusCodePages is not null) |
| | | 39 | | { |
| | 0 | 40 | | statusCodePages.Enabled = false; |
| | | 41 | | } |
| | 1 | 42 | | context.Response.StatusCode = 499; |
| | | 43 | | } |
| | | 44 | | |
| | 1 | 45 | | context.Items[ReportedItemKey] = true; |
| | 1 | 46 | | WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, null); |
| | 1 | 47 | | } |
| | 1 | 48 | | catch (BadHttpRequestException badRequestException) |
| | | 49 | | { |
| | 1 | 50 | | var statusCode = badRequestException.StatusCode; |
| | 1 | 51 | | if (!context.Response.HasStarted) |
| | | 52 | | { |
| | 1 | 53 | | var statusCodePages = context.Features.Get<IStatusCodePagesFeature>(); |
| | 1 | 54 | | if (statusCodePages is not null) |
| | | 55 | | { |
| | 0 | 56 | | statusCodePages.Enabled = false; |
| | | 57 | | } |
| | 1 | 58 | | context.Response.StatusCode = statusCode; |
| | | 59 | | } |
| | | 60 | | |
| | 1 | 61 | | context.Items[ReportedItemKey] = true; |
| | 1 | 62 | | WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, null, badRequestException); |
| | 1 | 63 | | throw; |
| | | 64 | | } |
| | 1 | 65 | | catch (Exception exception) |
| | | 66 | | { |
| | 1 | 67 | | context.Items[ReportedItemKey] = true; |
| | 1 | 68 | | WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, exception); |
| | 1 | 69 | | throw; |
| | | 70 | | } |
| | | 71 | | |
| | 38 | 72 | | if (!context.Items.ContainsKey(ReportedItemKey) && !IsErrorReExecution(context)) |
| | | 73 | | { |
| | 35 | 74 | | context.Items[ReportedItemKey] = true; |
| | 35 | 75 | | WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, null); |
| | | 76 | | } |
| | 38 | 77 | | } |
| | | 78 | | |
| | | 79 | | internal static bool IsErrorReExecution(HttpContext context) |
| | 37 | 80 | | => context.Features.Get<IStatusCodeReExecuteFeature>() is not null |
| | 37 | 81 | | || context.Features.Get<IExceptionHandlerPathFeature>() is not null |
| | 37 | 82 | | || context.Features.Get<IExceptionHandlerFeature>() is not null; |
| | | 83 | | |
| | | 84 | | private void WriteRequestLog( |
| | | 85 | | HttpContext context, |
| | | 86 | | long elapsedMilliseconds, |
| | | 87 | | string requestPayload, |
| | | 88 | | Exception? exception, |
| | | 89 | | BadHttpRequestException? badRequestException = null) |
| | | 90 | | { |
| | 38 | 91 | | var statusCode = badRequestException?.StatusCode ?? (exception is null ? context.Response.StatusCode : 500); |
| | 38 | 92 | | var endpoint = context.GetEndpoint(); |
| | 38 | 93 | | var routePattern = (endpoint as RouteEndpoint)?.RoutePattern.RawText; |
| | 38 | 94 | | var routeData = context.GetRouteData(); |
| | 38 | 95 | | var controller = routeData.Values["controller"]?.ToString(); |
| | 38 | 96 | | var action = routeData.Values["action"]?.ToString(); |
| | 38 | 97 | | var page = routeData.Values["page"]?.ToString(); |
| | 38 | 98 | | var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); |
| | | 99 | | // Хабы SignalR держат соединение открытым часами: WebSocket отвечает 101 и живёт до |
| | | 100 | | // закрытия вкладки, ServerSentEvents и LongPolling висят до истечения таймаута. |
| | | 101 | | // Длительность такого запроса — норма, а не деградация, поэтому SlowHttpRequest |
| | | 102 | | // по ним не выставляем. |
| | 38 | 103 | | var isSignalRHub = IsSignalRHubPath(context.Request.Path); |
| | 38 | 104 | | var isPersistentConnection = context.WebSockets.IsWebSocketRequest |
| | 38 | 105 | | || statusCode == StatusCodes.Status101SwitchingProtocols |
| | 38 | 106 | | || (isSignalRHub && !IsSignalRNegotiate(context.Request.Path)); |
| | 38 | 107 | | var isStreaming = isPersistentConnection |
| | 38 | 108 | | || context.Response.ContentType?.Contains("text/event-stream", StringComparison.OrdinalIgnoreC |
| | 38 | 109 | | || context.Request.Headers.Accept.ToString().Contains("text/event-stream", StringComparison.Or |
| | 38 | 110 | | || context.Request.Path.Value?.Contains("progress", StringComparison.OrdinalIgnoreCase) == tru |
| | 38 | 111 | | var longOperation = isStreaming |
| | 38 | 112 | | || context.Request.Path.Value?.Contains("upload", StringComparison.OrdinalIgnoreCase) == tru |
| | 38 | 113 | | || context.Request.Path.Value?.Contains("export", StringComparison.OrdinalIgnoreCase) == tru |
| | 38 | 114 | | || context.Request.Path.Value?.Contains("job", StringComparison.OrdinalIgnoreCase) == true |
| | 38 | 115 | | || context.Request.Path.Value?.Contains("download", StringComparison.OrdinalIgnoreCase) == t |
| | 38 | 116 | | var slowThreshold = longOperation |
| | 38 | 117 | | ? _options.SlowLongOperationMilliseconds |
| | 38 | 118 | | : _options.SlowRequestMilliseconds; |
| | 38 | 119 | | var isSlow = !isStreaming && elapsedMilliseconds >= slowThreshold; |
| | 38 | 120 | | var methodChangesState = HttpMethods.IsPost(context.Request.Method) |
| | 38 | 121 | | || HttpMethods.IsPut(context.Request.Method) |
| | 38 | 122 | | || HttpMethods.IsPatch(context.Request.Method) |
| | 38 | 123 | | || HttpMethods.IsDelete(context.Request.Method); |
| | 38 | 124 | | var payload = statusCode >= 400 || methodChangesState ? requestPayload : string.Empty; |
| | | 125 | | // 404 на путь, который не сматчился ни на один маршрут, да ещё и от анонимного |
| | | 126 | | // клиента — это не отказ приложения, а обращение к несуществующему адресу. Так |
| | | 127 | | // выглядят сканеры, перебирающие чужие CMS, VPN-порталы и файлы секретов: за |
| | | 128 | | // сутки они дают сотни уникальных путей, и каждый заводил GitHub-инцидент. |
| | | 129 | | // 404 из контроллера (endpoint сматчен) и 404 у аутентифицированного пользователя |
| | | 130 | | // остаются Warning — это настоящие битые ссылки приложения. |
| | 38 | 131 | | var isUnroutedAnonymousNotFound = statusCode == StatusCodes.Status404NotFound |
| | 38 | 132 | | && endpoint is null |
| | 38 | 133 | | && context.User.Identity?.IsAuthenticated != true; |
| | | 134 | | // При перезапуске контейнера вкладки переподключаются со старым connectionId, и |
| | | 135 | | // SignalR штатно отвечает 404 «connection not found»: клиент после этого делает |
| | | 136 | | // новый negotiate. Это часть протокола переподключения, а не отказ приложения. |
| | 38 | 137 | | var isStaleSignalRConnection = statusCode == StatusCodes.Status404NotFound && isSignalRHub; |
| | | 138 | | // Прямое обращение сканера или браузера к URL хаба без параметров подключения SignalR |
| | | 139 | | // штатно возвращает 400 Bad Request (требуется negotiate/connectionId) или 405 Method Not Allowed. |
| | | 140 | | // Регрессия #2241: при переподключении long polling второй poll на тот же connectionId |
| | | 141 | | // SignalR отклоняет 409 Conflict — клиент повторит запрос сам, это не отказ приложения. |
| | 38 | 142 | | var isInvalidSignalRProtocolRequest = (statusCode == StatusCodes.Status400BadRequest |
| | 38 | 143 | | || statusCode == StatusCodes.Status405MethodNotAllowed |
| | 38 | 144 | | || statusCode == StatusCodes.Status409Conflict) && isSignalRHub; |
| | | 145 | | // 405 Method Not Allowed от анонимного клиента (например, бот шлёт GET на POST-only endpoint) |
| | | 146 | | // является обращением с неподдерживаемым HTTP-методом, а не отказом бэкенда. |
| | 38 | 147 | | var isAnonymousMethodNotAllowed = statusCode == StatusCodes.Status405MethodNotAllowed |
| | 38 | 148 | | && context.User.Identity?.IsAuthenticated != true; |
| | | 149 | | // Страница ошибки (/Home/Error): отдача клиенту страницы ошибки со статусом 4xx/5xx не |
| | | 150 | | // является отказом эндпоинта ошибки, если только сам рендеринг не завершился необработанным исключением. |
| | 38 | 151 | | var isErrorPage = context.Request.Path.StartsWithSegments("/Home/Error", StringComparison.OrdinalIgnoreCase) |
| | 38 | 152 | | || (string.Equals(controller, "Home", StringComparison.OrdinalIgnoreCase) |
| | 38 | 153 | | && string.Equals(action, "Error", StringComparison.OrdinalIgnoreCase)); |
| | | 154 | | // Регрессия #2152, #2153: обращение к API импорта выдачи Ozon без валидного X-Api-Key |
| | | 155 | | // возвращает 401/403. Отклонение неавторизованного внешнего запроса — штатное поведение безопасности. |
| | 38 | 156 | | var isHandoverUnauthorized = (statusCode == StatusCodes.Status401Unauthorized || statusCode == StatusCodes.Statu |
| | 38 | 157 | | && context.Request.Path.StartsWithSegments("/api/ozon-handover", StringComparison.O |
| | | 158 | | // Регрессия #2128: некорректный JSON/формат документа импорта выдачи Ozon возвращает 400 Bad Request. |
| | | 159 | | // Это ошибка внешнего клиента/пользователя, а не деградация бэкенда. |
| | 38 | 160 | | var isHandoverBadRequest = statusCode == StatusCodes.Status400BadRequest |
| | 38 | 161 | | && context.Request.Path.StartsWithSegments("/api/ozon-handover", StringComparison.Ord |
| | | 162 | | // Регрессия #2159: отсутствие медиа-файла на диске или в БД (фото акта выдачи Ozon, миниатюра этикетки) |
| | | 163 | | // возвращает 404 Not Found. Это отсутствие конкретного файла ресурса, а не отказ доступности сервиса. |
| | 38 | 164 | | var isMediaResourceNotFound = statusCode == StatusCodes.Status404NotFound |
| | 38 | 165 | | && (context.Request.Path.StartsWithSegments("/OrderHistory/GetHandoverPhoto", Stri |
| | 38 | 166 | | || context.Request.Path.StartsWithSegments("/Studio2/OrderHistory/GetHandoverP |
| | 38 | 167 | | || context.Request.Path.StartsWithSegments("/Labels/GetThumbnail", StringCompa |
| | 38 | 168 | | || context.Request.Path.StartsWithSegments("/Studio2/Labels/GetThumbnail", Str |
| | | 169 | | // Регрессия #1218: 400 Bad Request на POST /Identity/Account/Login возникает при несовпадении/отсутствии |
| | | 170 | | // Antiforgery CSRF-токена (__RequestVerificationToken) фреймворком ASP.NET Core (вкладка открыта до |
| | | 171 | | // перезапуска сервиса, сканеры или устаревшая сессия). Запрос отсекается до вызова страницы OnPostAsync, |
| | | 172 | | // и отказ валидации CSRF — штатная функция безопасности, а не сбой приложения. |
| | 38 | 173 | | var isAuthAntiforgeryBadRequest = statusCode == StatusCodes.Status400BadRequest |
| | 38 | 174 | | && HttpMethods.IsPost(context.Request.Method) |
| | 38 | 175 | | && context.Request.Path.StartsWithSegments("/Identity/Account/Login", StringCo |
| | | 176 | | // Регрессия #2424: 400 Bad Request на POST /Studio2/OrdersGrid/GetChanges возникает при несовпадении/отсутствии |
| | | 177 | | // Antiforgery CSRF-токена фреймворком ASP.NET Core на фоновом автообновлении открытой вкладки после перезапуска |
| | | 178 | | // сервиса или истечения сессии. Запрос отсекается до вызова контроллера, и отказ CSRF — штатная функция безопас |
| | 38 | 179 | | var isGetChangesAntiforgeryBadRequest = statusCode == StatusCodes.Status400BadRequest |
| | 38 | 180 | | && HttpMethods.IsPost(context.Request.Method) |
| | 38 | 181 | | && context.Request.Path.StartsWithSegments("/Studio2/OrdersGrid/GetChang |
| | | 182 | | // Регрессия #2359, #2367, #2368: внешний запрос к вебхуку Ozon с некорректным телом (400) или невалидным IP/ток |
| | | 183 | | // является штатным отклонением некорректного или неавторизованного внешнего вызова, а не деградацией сервиса. |
| | 38 | 184 | | var isOzonWebhookRejection = (statusCode == StatusCodes.Status400BadRequest || statusCode == StatusCodes.Status4 |
| | 38 | 185 | | && context.Request.Path.StartsWithSegments("/Studio2/OzonWebhook/Receive", StringCo |
| | | 186 | | // Регрессия #2434, #2435, #2436: внешний запрос к эндпоинтам системных уведомлений деплоя без валидного X-Api-K |
| | | 187 | | // возвращает 401 Unauthorized / 403 Forbidden. Отклонение неавторизованного внешнего запроса — штатное поведени |
| | 38 | 188 | | var isSystemNotificationUnauthorized = (statusCode == StatusCodes.Status401Unauthorized || statusCode == StatusC |
| | 38 | 189 | | && context.Request.Path.StartsWithSegments("/Studio2/SystemNotification", |
| | | 190 | | // Регрессия #2402: 429 Too Many Requests на /Identity/Account/Login (или /Account/Login) возникает при срабатыв |
| | | 191 | | // политики AuthRateLimiterPolicy для защиты от перебора паролей. Ограничение частоты запросов — штатная функция |
| | | 192 | | // безопасности приложения, а не ошибка веб-сервера. |
| | 38 | 193 | | var isAuthRateLimit = statusCode == StatusCodes.Status429TooManyRequests |
| | 38 | 194 | | && (context.Request.Path.StartsWithSegments("/Identity/Account/Login", StringComparison.Or |
| | 38 | 195 | | || context.Request.Path.StartsWithSegments("/Account/Login", StringComparison.OrdinalI |
| | 38 | 196 | | var isExpectedNonFailure = isHandoverUnauthorized || isHandoverBadRequest || isOzonWebhookRejection || isMediaRe |
| | 38 | 197 | | var eventKind = exception is not null |
| | 38 | 198 | | ? "UnhandledException" |
| | 38 | 199 | | : statusCode == 499 |
| | 38 | 200 | | ? "HttpRequestAborted" |
| | 38 | 201 | | : isExpectedNonFailure |
| | 38 | 202 | | ? "HttpRequest" |
| | 38 | 203 | | : statusCode >= 400 && !isErrorPage |
| | 38 | 204 | | ? "HttpRequestFailed" |
| | 38 | 205 | | : isSlow ? "SlowHttpRequest" : "HttpRequest"; |
| | 38 | 206 | | var category = context.Request.Path.StartsWithSegments("/Identity") || context.Request.Path.StartsWithSegments(" |
| | 38 | 207 | | ? "auth" |
| | 38 | 208 | | : isHandoverUnauthorized || isSystemNotificationUnauthorized || (isOzonWebhookRejection && statusCode == Sta |
| | 38 | 209 | | ? "auth" |
| | 38 | 210 | | : !isErrorPage && !isMediaResourceNotFound && (statusCode >= 500 || statusCode == StatusCodes.Status404N |
| | 38 | 211 | | ? "availability" |
| | 38 | 212 | | : "backend"; |
| | | 213 | | |
| | 38 | 214 | | using var scope = logger.BeginScope(new Dictionary<string, object?> |
| | 38 | 215 | | { |
| | 38 | 216 | | ["EventKind"] = eventKind, |
| | 38 | 217 | | ["EventCategory"] = category, |
| | 38 | 218 | | ["HttpMethod"] = context.Request.Method, |
| | 38 | 219 | | ["RequestPath"] = context.Request.Path.Value, |
| | 38 | 220 | | ["QueryString"] = redactor.Redact(context.Request.QueryString.Value, _options.MaxPayloadCharacters), |
| | 38 | 221 | | ["StatusCode"] = statusCode, |
| | 38 | 222 | | ["ElapsedMilliseconds"] = elapsedMilliseconds, |
| | 38 | 223 | | ["TraceId"] = context.TraceIdentifier, |
| | 38 | 224 | | ["Controller"] = controller, |
| | 38 | 225 | | ["Action"] = action, |
| | 38 | 226 | | ["Page"] = page, |
| | 38 | 227 | | ["Endpoint"] = endpoint?.DisplayName, |
| | 38 | 228 | | ["RoutePattern"] = routePattern, |
| | 38 | 229 | | ["UserId"] = userId, |
| | 38 | 230 | | ["UserName"] = context.User.Identity?.Name, |
| | 38 | 231 | | ["RemoteIp"] = context.Connection.RemoteIpAddress?.ToString(), |
| | 38 | 232 | | ["RequestPayload"] = payload, |
| | 38 | 233 | | ["ApplicationVersion"] = AppBuildInfo.CommitHash, |
| | 38 | 234 | | ["ApplicationBuildDate"] = AppBuildInfo.CommitDate, |
| | 38 | 235 | | ["HostName"] = Environment.MachineName |
| | 38 | 236 | | }); |
| | | 237 | | |
| | 38 | 238 | | if (exception is not null) |
| | | 239 | | { |
| | 1 | 240 | | logger.LogError(exception, "HTTP {Method} {Path} завершился необработанной ошибкой за {ElapsedMilliseconds} |
| | 1 | 241 | | context.Request.Method, context.Request.Path, elapsedMilliseconds); |
| | | 242 | | } |
| | 37 | 243 | | else if (badRequestException is not null) |
| | | 244 | | { |
| | 1 | 245 | | logger.LogWarning(badRequestException, "HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс |
| | 1 | 246 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 247 | | } |
| | 36 | 248 | | else if (statusCode >= 500 && !isErrorPage) |
| | | 249 | | { |
| | 2 | 250 | | logger.LogError("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс", |
| | 2 | 251 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 252 | | } |
| | 34 | 253 | | else if (statusCode == 499) |
| | | 254 | | { |
| | 2 | 255 | | logger.LogInformation("HTTP {Method} {Path} отменен клиентом (499) за {ElapsedMilliseconds} мс", |
| | 2 | 256 | | context.Request.Method, context.Request.Path, elapsedMilliseconds); |
| | | 257 | | } |
| | 32 | 258 | | else if (isUnroutedAnonymousNotFound && !isSlow) |
| | | 259 | | { |
| | 1 | 260 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (маршрут не найд |
| | 1 | 261 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 262 | | } |
| | 31 | 263 | | else if (isStaleSignalRConnection && !isSlow) |
| | | 264 | | { |
| | 1 | 265 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (устаревшее соед |
| | 1 | 266 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 267 | | } |
| | 30 | 268 | | else if (isInvalidSignalRProtocolRequest && !isSlow) |
| | | 269 | | { |
| | 5 | 270 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (некорректный за |
| | 5 | 271 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 272 | | } |
| | 25 | 273 | | else if (isAnonymousMethodNotAllowed && !isSlow) |
| | | 274 | | { |
| | 1 | 275 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (метод не поддер |
| | 1 | 276 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 277 | | } |
| | 24 | 278 | | else if (isErrorPage && !isSlow) |
| | | 279 | | { |
| | 2 | 280 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (страница ошибки |
| | 2 | 281 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 282 | | } |
| | 22 | 283 | | else if (isHandoverUnauthorized && !isSlow) |
| | | 284 | | { |
| | 2 | 285 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (доступ к импорт |
| | 2 | 286 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 287 | | } |
| | 20 | 288 | | else if (isHandoverBadRequest && !isSlow) |
| | | 289 | | { |
| | 1 | 290 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (некорректный за |
| | 1 | 291 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 292 | | } |
| | 19 | 293 | | else if (isMediaResourceNotFound && !isSlow) |
| | | 294 | | { |
| | 4 | 295 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (медиа-файл не н |
| | 4 | 296 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 297 | | } |
| | 15 | 298 | | else if ((isAuthAntiforgeryBadRequest || isGetChangesAntiforgeryBadRequest) && !isSlow) |
| | | 299 | | { |
| | 1 | 300 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (отказ валидации |
| | 1 | 301 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 302 | | } |
| | 14 | 303 | | else if (isOzonWebhookRejection && !isSlow) |
| | | 304 | | { |
| | 0 | 305 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (запрос вебхука |
| | 0 | 306 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 307 | | } |
| | 14 | 308 | | else if (isSystemNotificationUnauthorized && !isSlow) |
| | | 309 | | { |
| | 2 | 310 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (доступ к систем |
| | 2 | 311 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 312 | | } |
| | 12 | 313 | | else if (isAuthRateLimit && !isSlow) |
| | | 314 | | { |
| | 2 | 315 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (срабатывание ли |
| | 2 | 316 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 317 | | } |
| | 10 | 318 | | else if (statusCode >= 400 || isSlow) |
| | | 319 | | { |
| | 6 | 320 | | context.Items[ReportedItemKey] = true; |
| | 6 | 321 | | logger.LogWarning("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс", |
| | 6 | 322 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 323 | | } |
| | | 324 | | else |
| | | 325 | | { |
| | 4 | 326 | | logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс", |
| | 4 | 327 | | context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds); |
| | | 328 | | } |
| | 42 | 329 | | } |
| | | 330 | | |
| | | 331 | | // Пути хабов регистрируются в Program.cs через MapHub; держим их здесь одним списком, |
| | | 332 | | // чтобы наблюдаемость знала, какие запросы являются долгоживущими соединениями. |
| | 1 | 333 | | private static readonly string[] SignalRHubPaths = |
| | 1 | 334 | | [ |
| | 1 | 335 | | "/notificationHub", |
| | 1 | 336 | | "/orderHistoryHub", |
| | 1 | 337 | | "/jobProgressHub" |
| | 1 | 338 | | ]; |
| | | 339 | | |
| | | 340 | | internal static bool IsSignalRHubPath(PathString path) |
| | 142 | 341 | | => SignalRHubPaths.Any(hub => path.StartsWithSegments(hub, StringComparison.OrdinalIgnoreCase)); |
| | | 342 | | |
| | | 343 | | internal static bool IsSignalRNegotiate(PathString path) |
| | 8 | 344 | | => path.Value?.EndsWith("/negotiate", StringComparison.OrdinalIgnoreCase) == true; |
| | | 345 | | |
| | | 346 | | private async Task<string> ReadRequestPayloadAsync(HttpContext context) |
| | | 347 | | { |
| | 40 | 348 | | if (!(HttpMethods.IsPost(context.Request.Method) |
| | 40 | 349 | | || HttpMethods.IsPut(context.Request.Method) |
| | 40 | 350 | | || HttpMethods.IsPatch(context.Request.Method) |
| | 40 | 351 | | || HttpMethods.IsDelete(context.Request.Method))) |
| | | 352 | | { |
| | 26 | 353 | | return string.Empty; |
| | | 354 | | } |
| | | 355 | | |
| | 14 | 356 | | var contentType = context.Request.ContentType ?? string.Empty; |
| | 14 | 357 | | if (contentType.Contains("multipart/", StringComparison.OrdinalIgnoreCase) |
| | 14 | 358 | | || !(contentType.Contains("json", StringComparison.OrdinalIgnoreCase) |
| | 14 | 359 | | || contentType.Contains("x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))) |
| | | 360 | | { |
| | 12 | 361 | | return "[PAYLOAD NOT CAPTURED]"; |
| | | 362 | | } |
| | | 363 | | |
| | | 364 | | try |
| | | 365 | | { |
| | 2 | 366 | | context.Request.EnableBuffering(); |
| | 2 | 367 | | using var reader = new StreamReader(context.Request.Body, leaveOpen: true); |
| | 2 | 368 | | var buffer = new char[_options.MaxPayloadCharacters + 1]; |
| | 2 | 369 | | var count = await reader.ReadBlockAsync(buffer, 0, buffer.Length); |
| | 1 | 370 | | context.Request.Body.Position = 0; |
| | 1 | 371 | | return redactor.Redact(new string(buffer, 0, count), _options.MaxPayloadCharacters); |
| | | 372 | | } |
| | 1 | 373 | | catch (BadHttpRequestException ex) |
| | | 374 | | { |
| | 1 | 375 | | return $"[BAD REQUEST PAYLOAD: {ex.Message}]"; |
| | | 376 | | } |
| | 0 | 377 | | catch (Exception ex) when (ex is IOException or OperationCanceledException) |
| | | 378 | | { |
| | 0 | 379 | | return $"[PAYLOAD READ FAILED: {ex.Message}]"; |
| | | 380 | | } |
| | 40 | 381 | | } |
| | | 382 | | } |